【每日一面】React Hooks闭包陷阱
基础问答
问题:谈一谈你对 React Hook的闭包陷阱的理解。
产生问题的原因:JavaScript 闭包特性 + Hooks 渲染机制
闭包的本质:函数能够访问其定义时所在的词法作用域,即使函数在作用域外执行,也可以记住定义时的词法作用域的内容,后续执行时,使用这些信息。
function callback(index) {let idx = index;let op;return (type) => {op = type;console.log(op);switch (type) {case 'add':idx++;break;case 'sub':idx--;break;}return idx;}
}const fn = callback(8);
console.log(fn('add')); // 9
console.log(fn('sub')); // 8
这里的 idx 正常会在 callback 函数执行结束后释放,但是由于我们返回的是一个函数,函数中依赖这个 idx 变量,所以未能释放,此时这个变量被这个匿名函数持有,而在 fn 变量存续期间,idx 和 op 都是不会释放的,这也就形成了一个闭包。
不过经典闭包还是 for 循环
Hooks 渲染逻辑:React 组件每次渲染都是独立的快照,可以理解为,每次重新执行相关钩子的时候,组件都会重新生成一个新的作用域。
闭包陷阱:根据上面两点,React Hooks 的闭包陷阱产生过程应当是这样的,React 在渲染开始前创建了新的状态包(作用域),而我们写代码的时候无意中创建了一个闭包,持有了 React 的当前状态,再下次渲染开始时,React 重新创建了状态包,但是我们在一开始创建的闭包持有的依旧是前一次 React 创建的状态,是旧的,这就是产生闭包陷阱的根源。这里我们以一个具体例子来看:
import { useEffect, useState } from "react"const App = () => {const [count, setCount] = useState(1);useEffect(()=> {const timer = setInterval(() => console.log(count), 1000);return () => clearInterval(timer)}, []);const addOne = () => {setCount(pre => pre+1);}return (<div className="main" > <p>Hello: {count}</p><button onClick={addOne}>+1</button></div>)
}export default App
这里在组件首次渲染的时候,useEffect
帮我们设置了一个定时器,定时器执行的函数持有了外部作用域的 count 变量,产生了一个闭包。
再之后,我们在页面上点击按钮时,触发了 setCount(pre => pre+1)
状态更新,但是由于没有配置 useEffect
的更新依赖,所以定时器还是持有旧的状态包。此时打印的还是 1,没有更新。
闭包陷阱破解方式:
- 使用
useRef
:useRef 在初始化后,是一个形如{ current: xxx }
的不可变对象,不可变可以理解为,这个对象的地址不会发生变化,所以在浅层次的比较(===)中,更新后的前后对象是一个。所以取值的时候,总是能拿到最新的值。 - 添加 Hooks 依赖:在
useEffect
钩子的依赖列表中增加 count,当 count 发生变化的时候,会重新执行useEffect
,内部的 timer 会重新生成,拿到最新的作用域的值。 - 修改 state 为一个对象:类似于 useRef,我们在更新 state 的时候,可以直接把内容写入该对象中,避免直接替换 state 对象。
扩展知识
React 官方要求我们不能将 hooks 用 if 条件判断包裹,其原因是 React 的 Fiber 架构中收集 Hooks 信息的时候是按顺序收集的,并以链表的形式进行存储的。如下示例:
function App() {const [count, setCount] = useState(0);const [isFirst, setIsFirst] = useState(false);useEffect(() => {console.log('hello init');}, []);useEffect(() => {console.log('count change: ', count);}, [count]);const a = 1;
}
示例中存在 4 个 hooks,所以 React 收集完成后形成的链表应当是这样的:
React 为链表节点设计了如下数据结构:
type Hook = {memoizedState: any,/** 省略这里不需要的内容 */next: Hook | null,
};
其中 next 就是链表节点用于指向下一个节点的指针,memoizedState 则是上一次更新后的相关 state。组件更新的时候,hooks 会严格按照这个顺序进行执行,按顺序拿到对应的 Hook 对象,所以如果我们用 if else 包裹了其中一个 hook,就会出现链表执行过程中,Hooks 对象取值错误的情况。
同样的,React 官方告诉我们,如果想在更新的时候拿到当前 state 的值,建议使用回调函数的写法,即:setCount(pre => pre + 1)
这种写法,这个原因,通过 Hook 的数据结构也大致可以判断,因为 memoizedState 存储了前一次更新的数据,使用回调时,这个 memoizedState 就可以作为参数提供给我们,并且保证总是正确的。
面试追问
- 能手写一个闭包吗?
参考前文代码。
- 使用
useRef
存储值,会有什么问题?
useRef
在初始化后,是形如 { current: xxx }
的对象,这个对象地址不会变化,所以我们监听 ref 是不起作用的,同时,和 useState
不同,useRef
内容的变更不会触发组件重新渲染。
- 请谈谈 hooks 在 React 中的更新逻辑?
React 是以链表形式来组织管理 hooks 的,在收集过程中按照顺序组装成链表,然后每次触发状态更新时,会从链表头开始依次判断执行更新。
- 那 hooks 中,useState 的更新是同步还是异步?
可以理解为异步的,展开来说,则是: state 更新函数(如触发 setCount)是同步触发的,React 执行更新(即 count 被更新)是异步的。这种设计主要是出于性能考虑,避免重复渲染,减少重绘重排。
- useEffect 依赖数组传空数组和不传依赖,二者有什么区别?
空数组:effect 仅在组件首次渲染时执行一次,后续不会再执行,相当于组件挂载阶段。
不传依赖:effect 会在组件首次渲染时、每次重新渲染后都执行。这种形式隐含存在渲染循环的风险,即 effect 中存在修改 state 的操作,那么按照不传依赖时执行的规则,就会陷入渲染 -> 更新 -> 触发重渲染 -> 更新 -> 触发重渲染……这样的循环。