React Hooks闭包陷阱问题详解与解决方案
React Hooks闭包陷阱问题详解与解决方案
React Hooks的闭包陷阱是一个常见但容易被忽视的问题。本文通过一个实际开发案例,深入分析了闭包陷阱产生的原因,并提供了三种有效的解决方案:使用useRef、函数式更新和useReducer。这些方案不仅解决了闭包陷阱问题,还在批量更新、依赖管理等场景下提供了实用的解决方案。
近日,在开发过程中再次遇到了React Hooks的闭包陷阱问题,特此记录下来,希望能帮助其他开发者避免类似问题。
发现bug
需求很简单:一个卡片上有多个下载按钮,点击后请求文件地址。大概实现如下:
export default function App() {
const [loadingMap, setLoadingMap] = useState({});
const handleClick = async (key) => {
setLoadingMap({ ...loadingMap, [key]: true });
await delay(3000);
setLoadingMap({ ...loadingMap, [key]: false });
};
return (
<div className="App">
<button onClick={() => handleClick("a")}>
{loadingMap.a ? "下载中" : "下载a"}
</button>
<button onClick={() => handleClick("b")}>
{loadingMap.b ? "下载中" : "下载b"}
</button>
</div>
);
}
测试时发现问题:如果连续点击 a 和 b 按钮,当 b 下载结束时,a 按钮会被重置为”下载中“。
显然,这段简单的代码忽略了闭包问题。下图描述了这段逻辑里闭包问题产生的原因:
每次渲染都有独立的 state 上下文。b 按钮的点击发生在第二次渲染后,对应的闭包中 loadingMap
的值为 {a: true}
,当前渲染中的 eventHandler 只能获取到本次渲染环境对应的 state。因此,当在 eventHandler 中通过 setState 触发第五次渲染时,此处的 loadingMap
依然是第二次渲染时的旧值,而非最新的第四次渲染时的值。
解决方案
关键点在于:在执行 setState 时,如何能获取到最新的 state,而不是本次渲染时的 state。
方案一:useRef
useRef
是 React 开发者的老朋友,是解决 Hooks 问题的万金油。它能存储一个指向不变的变量。
代码做如下更改:
const loadingMap = useRef({});
const forceUpdate = useForceUpdate();
const setLoading = (key, value) => {
loadingMap.current[key] = value;
forceUpdate();
};
把 loadingMap
存在 ref.current
里,就永远能找到它了,所有的渲染里,它的指向都是唯一的。唯一的问题是,修改 ref.current
是不会触发 re-render 的,因此需要使用 forceUpdate
强制刷新。
方案二:setState 的函数式更新
函数式更新好,获取 state 没烦恼。setState
可以接收一个函数作为参数,即函数式更新,我们把这个函数称为更新器,这种更新方式不会直接触发 re-render,而是会把更新器推到一个队列里,最后经过计算统一触发一次更新。更新器的参数是上一个计算后该状态的值,可以理解为最新的 state。
代码做如下更改:
const setMap = (key, value) => {
setLoadingMap((v) => ({
...v,
[key]: value,
}));
};
const handleClick = async (key) => {
setMap(key, true);
await delay(3000);
setMap(key, false);
};
函数式更新有如下优点:
- 总是可以拿到最新的 state
- 没有依赖,如果要在 useEffect 中更新 state,但又不想因 state 更新触发 effect 时,用函数式更新很合适
- 批量更新时只会触发一次 re-render,这可以避免不必要的 re-render
干净简洁、没有负担,函数式更新可以说是处女座之友。不过函数式更新只能用在 setState
时,如果需要获取到最新的 state 做其他事情(比如发送一个请求),就无法使用这个方案。
方案三:useReducer
useReducer
的情况跟 ref
类似,它也是将 state 的状态管理转移了,得以避免 Hooks 的闭包陷阱。
代码修改如下:
const reducer = (state, action) => {
return { ...state, ...action };
};
const [loadingMap, dispatch] = useReducer(reducer, {});
const handleClick = async (key) => {
dispatch({ [key]: true });
await delay(3000);
dispatch({ [key]: false });
};
某种程度上,reducer 被称为 Hooks 的作弊模式,它可以解耦业务逻辑和更新方法的关系。和函数式更新一样,它也不需要依赖当前渲染上下文的 state,因此很适合在以下场景使用:需要在 useEffect 这类需要传入依赖的 Hooks 中修改状态,但又不想因依赖改变触发 effect 的场景。不过使用 useReducer
往往意味着更多的代码逻辑(action、reducer、dispatch... )。
总结
Hooks 的闭包陷阱是非常常见的问题,当代码中有【异步逻辑+get/setState】的场景时,需要注意可能产生闭包陷阱。本文的三种方法除了解决闭包陷阱问题,在批量更新、依赖诚实、避免不必要的 effect 等场景下也都提供了解决方案。最后,希望 useEvent
尽快到来。