React Hooks 中的闭包陷阱
2023 reactReact Hooks 中的闭包,指函数式组件每次 render 都会产生一个新的函数,这个函数会产生一个在当前阶段的闭包。
最近在工作中刚好遇到这个问题,经过一些资料的查阅,总结出一部分总结。
问题
以下是一个简化版本的代码示例:
const App = (): React.ReactElement | null => {
const [count, setCount] = useState(0);
const [handleCount, setHanleCount] = useState({
action: () => {},
});
useEffect(() => {
setHanleCount({
action: () => setCount(count + 1),
});
}, []);
return (
<div>
<button onClick={handleCount.action}>Click</button>
<div>{count}</div>
</div>
);
};
https://codesandbox.io/s/capture-2l2vq5
上面例子中,Button 组件的 action 需要依赖父组件传递。当我们点击了一次按钮之后,count 变成 1,之后再点击不会增加。
解决方案
如果你安装 eslint-plugin-react-hooks 插件,会得到下面的警告提示,提示我们使用 useEffect
缺少 count 的依赖。
React Hook useEffect has a missing dependency: 'count'. Either include it or remove the dependency array. You can also do a functional update 'setCount(c => ...)' if you only need 'count' in the 'setCount' call. (react-hooks/exhaustive-deps)
从上面的 eslint 插件中,我们可以快速修复这个问题。
useEffect(() => {
setHanleCount({
action: () => setCount(count + 1),
});
}, [count]);
原因
在 React Fiber 架构中,Fiber 节点就是一个组件,其 Hook 节点类型大致如下:
type Hook = {
memoizedState: any;
baseState: any;
baseUpdate: Update<any, any> | null;
queue: Update<any, any> | null;
next: Hook | null;
};
memoizedState 用来存放组件的 state,next 指向 Hook 对象。由于不知道开发者会使用多少个 Hook,所以 Hook 都是链表存储的来保证调用的顺序。
假设我们有如下的代码,则对应的链表为:
function App() {
const [] = useState();
const [] = useState();
useEffect(() => {}, []);
useMemo(() => {}, []);
const [] = useState();
return <></>;
}
当我们每次渲染更新的时候,react 会对 useEffect
和 useMemo
的依赖项进行判断是否改变,有更新会执行回调函数,否则跳过。
回到原先的问题上,react 进行组件渲染调用组件函数,函数执行创建了函数作用域。组件在初次渲染时,count 为 0,点击之后组件重新渲染,创建新的作用域,但是 useEffect
的依赖项未更新,其回调函数被跳过执行,里面的 count 变量引用的还是组件初始化的值。
所以 hooks 产生闭包陷阱的原因是依赖项缺失,在渲染过程中 hooks 的依赖项未更新跳过,内部函数依赖也是未更新前的值。
其他例子
在下面例子中,先点击 alert 按钮,后面点击 add 按钮,那么弹窗的值和页面展示的 value 是什么?
const FunctionComponent = () => {
const [value, setValue] = useState(1);
const log = () => {
setTimeout(() => {
alert(value);
}, 3000);
};
return (
<div>
<div>value: {value}</div>
<button onClick={log}>alert</button>
<button onClick={() => setValue(value + 1)}>add</button>
</div>
);
};
答案是:弹出的值是 1,页面的值是 2
我们发现弹出的值和当前页面显示的值不相同,这个就是一个闭包陷阱。
- 初次渲染,生成一个 log 函数(value = 1)
- 点击 alert 按钮,执行 log 函数(value = 1)
- 点击 add 按钮,value 的值增加,组件重新渲染,生成一个新的 log 函数(value = 2)
- 计时器触发,log 函数(value = 1)弹出闭包内的 value
使用 useRef 解决闭包陷阱
使用 useRef 每次渲染是都返回同一个引用类型的对象,读取和设置 value 都在这个对象上处理。
const FunctionComponent = () => {
const [value, setValue] = useState(1);
const countRef = useRef(value);
const log = () => {
setTimeout(() => {
alert(countRef.current);
}, 3000);
};
useEffect(() => {
countRef.current = value;
}, [value]);
return (
<div>
<div>value: {value}</div>
<button onClick={log}>alert</button>
<button onClick={() => setValue(value + 1)}>add</button>
</div>
);
};