1. 起因

项目从 class component 迁移到 hooks 有一阵子了,useEffect 用得越多,越觉得它比生命周期函数难缠。最近一个列表页面出现了”接口被重复调用”的问题,排查完发现是依赖项数组没写对,顺手记一下几个容易犯的错。

2. 依赖项少写了,闭包拿到的是旧值

最常见的坑:eslint 的 exhaustive-deps 规则不开,或者开了但手动忽略掉,然后依赖项里少写了某个变量。

1
2
3
4
5
6
7
8
9
function SearchList({ keyword }) {
const [list, setList] = useState([]);

useEffect(() => {
fetchList(keyword).then(setList);
}, []); // 少写了 keyword

return <List data={list} />;
}

keyword 变化时组件会重新渲染,但 useEffect 因为依赖数组是空的,只在挂载时跑一次,fetchList 里闭包捕获的永远是第一次渲染时的 keyword。表现出来就是”搜索框输入了新关键字,但列表没更新”,一开始还以为是接口没触发,其实是根本没执行到里面那行代码。

结论就是老生常谈的一句话:把 eslint-plugin-react-hooks 的 exhaustive-deps 打开,别手动忽略。它报的警告十次有九次是对的,剩下一次可以用 useCallback/useRef 之类的方式正面解决,而不是直接注释掉规则。

3. 依赖项对象/数组每次渲染都是新引用,导致死循环或重复调用

反过来的坑是依赖项写多了,但依赖的是一个每次渲染都会重新创建的对象:

1
2
3
4
5
6
7
function UserPanel({ userId }) {
const options = { userId, includeProfile: true }; // 每次渲染都是新对象

useEffect(() => {
fetchUser(options).then(setUser);
}, [options]); // options 每次都变,effect 每次渲染都执行
}

options 是字面量对象,每次渲染函数体重新执行都会创建一个新的引用,useEffectObject.is 比较依赖项,引用不同就认为变化了,于是每次渲染都会重新触发。如果 effect 里又调用了 setUser 这类会触发重新渲染的操作,很容易演变成死循环,或者接口被高频重复调用。

修法也很直接,要么把 optionsuseMemo 包一层,要么拆开成基础类型依赖:

1
2
3
useEffect(() => {
fetchUser({ userId, includeProfile: true }).then(setUser);
}, [userId]); // 只依赖真正变化的基础类型

4. 清理函数忘了写,异步请求race condition

组件卸载或者依赖项变化前,上一次 effect 里发出去的异步请求可能还没返回。如果没写清理函数,请求返回时组件可能已经不在了,或者 keyword 已经变了,但 setState 还是会执行:

1
2
3
4
5
6
7
8
9
10
11
useEffect(() => {
let cancelled = false;

fetchList(keyword).then(data => {
if (!cancelled) setList(data);
});

return () => {
cancelled = true;
};
}, [keyword]);

没有这个 cancelled 标志位的话,快速切换关键字搜索时,如果慢请求比快请求后返回,最终显示的列表会是旧关键字的结果——这种问题在网速不稳定的环境下特别容易复现,本地开发环境网速快反而不容易发现。

5. 小结

useEffect 依赖项数组本质上是”告诉 React 这个副作用依赖哪些值”,不是可以随便塞或者随便删的可选项。踩过这几次坑之后,现在写 useEffect 前都会先问自己三个问题:依赖数组是不是把用到的变量都列全了?依赖项里有没有每次渲染都变的引用类型?有没有可能出现竞态的异步操作?