其实你可以删除 useEffect 的某些 dependencies
引入场景: 分页请求
useEffect 有一个非常常见的用途, 那就是用来发请求. 考虑一个最简单的场景:
const [list, setList] = useState([]);
const fetchItem = useCallback(async () => {
const data = await callAPI();
setList(prev => [...prev, data]);
}, [])
useEffect(() => {
fetchItem();
}, []);
上述场景非常简单, 在组件挂载时请求一条数据, 插入到 list 中.
现在考虑新增一个分页功能:
- 页面里会新增两个按钮
- 第一个按钮点击回到上一页, 第二个按钮点击进入下一页.
- 在进入下一页时, 如果当前页面没有数据, 那么需要先请求数据, 再进入下一页.
- 每一页只展示一条数据
代码大致会长成下面这样:
const [list, setList] = useState([]);
const fetchItem = useCallback(async () => {
const data = await callAPI();
setList(prev => [...prev, data]);
}, [])
const [curPage, setCurPage] = useState(0);
useEffect(() => {
if(list.length <= curPage) {
fetchItem();
}
}, [curPage]); // React Hook useEffect has a missing dependency: 'list.length'. Either include it or remove the dependency array.
return <>
<button onClick={() => setCurPage(prev => Math.max(0, prev - 1))}>prev</button>
<button onClick={() => setCurPage(prev => prev + 1}>next</button>
</>
上面代码中, ESLint 会在useEffect 末尾抛出一个 warning:
React Hook useEffect has a missing dependency: 'list.length'. Either include it or remove the dependency array.
众所周知, 这是因为在 effect 中使用了 list.length, 但是并没有在 deps, 也就是依赖数组参数中声明 list.length.
于是, 如同 ESLint 告诉你的那样, 你试着在 deps 中加入 list.length 作为依赖:
useEffect(() => {
if(list.length <= curPage) {
fetchItem();
}
}, [curPage, list.length]);
此时问题来了:
-
curPage更改 (+1) -
触发 effect, 调用
fetchItem请求下一条数据 -
请求返回后, 更新
list,list.length自然也跟着更新 -
再次触发执行 effect
对于这个场景, 二次触发 effect 好像没有什么问题, 因为此时 list.length <= curPage 是 false, 什么也不会做. 但是如果稍微复杂的场景呢?
useEffect(() => {
const expensiveOperation = () => {
// some expensive operations
}
if(list.length <= curPage) {
fetchItem().then(expensiveOperation);
} else {
expensiveOperation();
}
}, [curPage, list.length]);
此时就会导致 expensiveOperation 的额外执行, 可能带来 bug.
方案 1: 使用 ref
props 和 state 属于 react 的数据流, 但 ref 不属于.
换言之, props 和 state 的更新会被 react 感知到, 并且做出响应的动作 (重新渲染, 执行 effect 等), 但 ref 的更新不会.
重构以上代码为:
const [list, setList] = useState([]);
const listRef = useRef(list); // 用一个 ref 来保存最新的 list
const fetchItem = useCallback(async () => {
const data = await callAPI();
setList(prev => {
const newList = [...prev, data];
listRef.current = newList; // 每次 setList 时, 同步更新 listRef
return newList;
});
}, [])
const [curPage, setCurPage] = useState(0);
useEffect(() => {
if(listRef.current.length <= curPage) {
fetchItem();
}
}, [curPage]);
return <>
<button onClick={() => setCurPage(prev => Math.max(0, prev - 1))}>prev</button>
<button onClick={() => setCurPage(prev => prev + 1}>next</button>
</>
这样就成功了, 既遵守了 react 官方的最佳实践, 避免了 ESLint 抛 warning, 又修复了二次执行 effect 的 bug.
方案 2: 其实可以无视 warning, 直接删掉
相信你已经看过无数次下面这段代码:
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
useEffect(() => {
const interval = setInterval(() => {
setCount((prev) => prev + step);
}, 1000);
return () => {
clearInterval(interval);
};
}, []); // eslint 会抛出同样的 warning, 告诉你把 step 加进去
上面这段经典代码经常被用来跟你阐述, 为什么需要把 effect 中用到的所有在 react 数据流中的变量都放进去, 以及不放进去可能产生的 bug.
众所周知, 上面一段代码的预期是:
-
每次执行定时器时, 都会将
count更新为count + step的值 -
初始时
count每次 +1 -
假如将
step更新为 2, 之后count每次 +2
但是实际上, 不管怎么更新 step, count 每次更新永远只会 +1. 这是因为闭包的原因, setInterval 接收的函数为第一次创建时的函数, 此时 step 为 1, 这个值会被闭包记录下来, 永远不会更新. 所以需要将 step 放到 deps 中, 这样每次 step 更新后, 都会重新执行 effect:
-
关掉上一个
effect开启的定时器 -
创建一个新的函数, 该函数闭包内的
step为最新的值 -
开启一个新的定时器
所以 react 希望你不要对 deps 撒谎, 因为每次渲染都会产生一个新的 effect 闭包, 保存本次渲染的所有变量. 如果对 deps 撒谎, 可能导致一个较旧的闭包在一次较新的渲染中被执行的问题.
但是所有的情况都是这样吗? 并不是. 考虑以下代码:
export default function App() {
const [data, setData] = useState('data');
const [other, setOther] = useState('other');
const [result, setResult] = useState(data + other);
useEffect(() => {
setResult(data + other);
}, [data]);
return (
<div className="App">
<input value={data} onChange={(e) => setData(e.target.value)} />
<input value={other} onChange={(e) => setOther(e.target.value)} />
<div>result: {result}</div>
</div>
);
}
上述代码描述了一个场景: 在 data 更新时, 需要基于 data 和 other 来更新 result.
上述代码也存在, other 不在 deps 中的问题.
- 初始时,
data的值为 "data",other的值为 "other", result 的值为 "dataother". - 此时我们通过第一个输入框修改
data为 "data1", 那么result也被更新成了 "data1other", 符合预期. - 此时我们通过第二个输入框修改
other为 "other1",result没有被更新, 这也符合预期.
那么问题来了, 此时我们再通过第一个输入框, 修改 data 为 "data13", 那么 result 的值会是什么呢?
- data13other
- data13other1
其实答案是 2, 符合预期, 并没有出现 "在最新的一次渲染中拿到了较旧的值" 的情况.
事实上, 永远不可能在 a 渲染中拿到 b 渲染的变量.
接下来, 我们仔细分析上面两个场景 (定时器, 输入框) 为什么一个不符合预期, 一个符合预期.
定时器
- 首次渲染 (组件挂载): 在组件内部创建了一个闭包 e1, 该闭包为
effect, 被传入给了useEffect作为第一个参数. 在useEffect中又创建一个闭包 i1, 该闭包中引用了step= 1, 被传入给setInterval作为第一个参数. - 在未修改
step的情况下, 定时器每次执行 i1, 将count更新为count + 1( i1 里的 step 永远为 1 ), 触发重新渲染. - 重新渲染过程中, 会创建一个新的闭包 e2, 传入给
useEffect作为第一个参数. 但是由于deps数组为空, e2 永远不会被执行. - 修改
step, 触发一次新的渲染, 创建一个新的闭包 e3, 传入给useEffect作为第一个参数. 同样的, e3 也永远不会被执行. - 定时器继续执行 i1, 将
count更新为count + 1.
输入框
-
首次渲染 (组件挂载): 在组件内部创建了一个闭包 e1, 被传入给了
useEffect作为第一个参数. -
修改
data, 触发重新渲染. 在这次重新渲染的过程中, 会创建一个新的闭包 e2, 传入给useEffect作为第一个参数. 该闭包中,data为 "data1",other为 "other" , 正确setResult为 "data1other". -
修改
other, 触发重新渲染. 在这次重新渲染的过程中, 会创建一个新的闭包 e3, 传入给useEffect作为第一个参数. 该闭包中,data为 "data1",other为 "other1" . 但是由于other不在deps中, 该闭包没有执行. -
修改
data, 触发重新渲染. 在这次重新渲染的过程中, 会创建一个新的闭包 e4, 传入给useEffect作为第一个参数. 该闭包中,data为 "data13",other为 "other1" , 正确setResult为 "data13other".
根本原因就在于, react 每次渲染都会创建一个新的 effect 闭包, 传给 useEffect. 在输入框的场景里, data 变化时, 执行的 effect 是最新的闭包, 里面当然能拿到最新的 data 和 other.
而定时器的场景里, 虽然每次也会创建新的 effect 给 useEffect, 但是新的 effect 永远不会被执行, (旧的也是). 从头到尾, 只在组件挂载时开了一个定时器, 反复执行挂载时传进去的第一个闭包 i1.
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
const effectClosure = () => { // 很容易被人忽略, 这里其实创建了一个新的函数
const callback = () => { // 这里同样创建了一个新的函数
setCount((prev) => prev + step);
};
const interval = setInterval(callback, 1000);
return () => {
clearInterval(interval);
};
};
useEffect(effectClosure, []);
所以, 回到最开始分页请求数据的场景, 就算 deps 里只保留 curPage, 当 curPage 更新时, 重新创建的 effect 也会拿到最新的 list, 不会出现 bug.
建议 hooks 初学者刚开始写的时候, 可以将每个函数都单独定义, 而不是内联写法, 这样可能更有助于理解 react 每次渲染都会创建新的闭包 这句话.
const effect = () => { // 每次渲染都是新的
console.log('data changed', data);
};
useEffect(effect, [data]); // BTW, [data] 每次渲染也是一个新的数组
const callback = () => {}; // 每次渲染都是新的
const cb = useCallback(callback, []);
另外, 从上面的写法也能看出来, 不要无脑使用 useCallback. 初学者可能会有误解, 认为 useCallback 可以避免创建新的函数, 提升性能. 但实际上, useCallback 无法避免创建新的函数, 从上面的代码应该也能看到了, 每次渲染都会创建一个新的 callback 作为第一个参数. useCallback 只是保证返回的 cb 在 deps 不变的情况下保持不变, 并且 useCallback 本身也是一次函数调用, 也是有调用成本的 ( 比如对比 deps 每个元素是否变了 ). 所以, 如果不是为了在传递给子组件等场景, 需要保证 cb 不变, 使用 useCallback 反而会降低性能.
方案 3: useEffectEvent
本文写作时间: 2023 年 5 月 27 日. 目前 react 的 experimental 版本中, 新增了一个新的 hook: useEffectEvent, 终于对这类场景提供了优雅的解决方案.
目前因为是实验版本, 所以需要 import { experimental_useEffectEvent as useEffectEvent } from 'react'. 未来不确定是否会加入正式版
useEffectEvent 只接收一个 callback 参数, 返回一个新的函数, 该函数会直接调用传入的 callback.
// useEffectEvent 源码
function mountEvent<Args, Return, F: (...Array<Args>) => Return>(
callback: F,
): F {
const hook = mountWorkInProgressHook();
const ref = {impl: callback};
hook.memoizedState = ref;
// $FlowIgnore[incompatible-return]
return function eventFn() {
if (isInvalidExecutionContextForEventFunction()) {
throw new Error(
"A function wrapped in useEffectEvent can't be called during rendering.",
);
}
return ref.impl.apply(undefined, arguments);
};
}
function updateEvent<Args, Return, F: (...Array<Args>) => Return>(
callback: F,
): F {
const hook = updateWorkInProgressHook();
const ref = hook.memoizedState;
useEffectEventImpl({ref, nextImpl: callback});
// $FlowIgnore[incompatible-return]
return function eventFn() {
if (isInvalidExecutionContextForEventFunction()) {
throw new Error(
"A function wrapped in useEffectEvent can't be called during rendering.",
);
}
return ref.impl.apply(undefined, arguments);
};
}
注意看, 其实该 hook 每次都创建了一个新的 eventFn , 然后返回, 所以每次返回值也是新的, 这一点和 useCallback 有本质区别.
该 hook 返回的函数就可以放心在 useEffect 中使用了, ESLint ( eslint-plugin-react-hooks 同样的 experimental 版本 ) 也不会报错. 因为每次都是返回的最新的函数, 该函数也能获取到所有最新的变量.
使用 useEffectEvent 重构分页请求场景:
const onCurPageChange = useEffectEvent(() => {
if(list.length <= curPage) {
fetchItem();
}
});
useEffect(() => {
onCurPageChange();
}, [curPage]);
重构输入框场景:
const tick = useEffectEvent(() => {
setCount(count + step);
});
useEffect(() => {
const timer = setInterval(() => { // 定时器只会开一个
tick();
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
References
转载自:https://juejin.cn/post/7237827233351057465