其实你可以删除 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