React Hooks 里的 useCallback 更新幽灵
本文同时发布于博客 https:// blog.chenlei.me/# /blog/ghost-on-react-hooks , 内含可交互的演示实例
前言
对于 react hooks, 我个人使用最多的是以下三个 hook 方法:
-
useState
-
useRef
-
useCallback
关于 useState 和 useRef 的配合使用,可以参考 Richard:看到赚到!React Hooks 奇技淫巧 —— 副作用, 闭包 与 Timer . 在本文中, 我们来谈谈 useCallback 的常见使用场景, 以及一个对初学者而言匪夷所思的依赖更新问题, 我称之为 " useCallback 的幽灵现象 ".
如果你对 react hooks 还不了解, 可先简单看一下 https://reactjs.org/docs/hooks-reference.html 以了解有哪些 hooks api. 它们对于 React (>= 16.8)中的函数式组件(Functional Component, 下文简称 FC), 都起到了 标记一个有状态变量 的作用.
useCallback 的用法
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
[a, b],
这 React 官方为
useCallback
配的例子, 它解释了
useCallback
的基本工作原理:
- 在一个 FC 中, 包装一个函数, 这个函数会被"记住", 如果没有别的原因, 这个函数在 FC 多次被调用的过程中是不变的 (也就是 react hook 的状态)
-
如果
useCallback(cb, deps)
的 deps 列表中有任意一项发生了 变化 (对于 primitive type 而言, 是值的变化; 对于 object 而言, 是引用的变化/内存地址的变化), 则memoizedCallback
会被更新, 并且在 FC 的下一次渲染中使用 新的副本.
我们举例说明下, 以下面这段代码为例:
const [a, setA] = React.useState('');
const [b, setB] = React.useState({});
const [c, setC] = React.useState('');
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
[a, b],
);
我们用简单的图文字来表示一下
useCallback(cb, [a, b]
的工作机制:
+++++++++ first time render ++++++++
memoizedCallback(snapshot_1) = useCallback(cb, [a, b]); ---- S1
...( asynchronous procedure: setC('newC')! )
...( trigger re-render )
+++++++++ second time render ++++++++
memoizedCallback(snapshot_1) = useCallback(cb, [a, b]); ---- S1
...( asynchronous procedure: setA('newA')! )
...( trigger re-render )
+++++++++ third time render ++++++++
memoizedCallback(snapshot_2) = useCallback(cb, [a, b]); ---- S1
...
对应的文字描述:
-
FC 进行第一次渲染,
useCallback
执行(我们把这个执行点记为 S1)得到 有状态的memoizedCallback
(这是一份副本 snapshot_1) -
在某个异步逻辑中, 执行
setC(...)
, 导致整个 FC 进入 重新渲染(注意 a, b 没有被更新) -
FC 进行第二次渲染, 在 S1 处,
useCallback(cb, deps)
根据 deps 内的各值变化确定是否要对 S1 的值更新; 发现 deps 中并无变化. 于是 保留memoizedCallback
的副本 snapshot_1; -
在某个异步逻辑中, 执行
setA(...)
, 导致整个 FC 进入 重新渲染(注意 a 被更新了) - FC 进行第三次渲染, 在 S1 处, useCallback(cb, deps)根据 deps 内的各值变化确定是否要对 S1 的值更新; 发现 deps 中的 a 发生了变化. 于是 更新 memoizedCallback 的值为 snapshot_2.
以上就是
useCallback
的工作原理简介. 从中可以看出,
useCallback
可以用于减少 FC 中不必要的对函数类状态更新
. 具体来说, 有可能在 FC 中你需要构造一个依赖了 FC 内部变量的函数(往往它所依赖的变量还是 FC 的另一个有状态的变量), 那么, 我们
不妨
用
useCallback
来包装一下这个函数.
一个简单场景
我们想象这样的场景: 你有一个组件, 它包含两部分:
- 一个从 0 开始的计时器, 每过 1 秒, 你需要将其更新, 并将它的值展示出来.
- 一个输入框 input, 你要将其做成响应式的(reactive), 即, 它的值来自于一个 stateful value, 且当你对它输入新的内容时, 其值能够反应到 stateful value 中.
我们将这个 input 组件表达如下:
const SimpleInput = () => {
const [clock, setClock] = React.useState(0);
const [textValue, setTextValue] = React.useState('');
React.useEffect(() => {
const timer = setInterval(() => {
setClock(prev => prev + 1);
}, 1000);
return () => {
clearInterval(timer);
}, []);
return (
<p>time counter: {clock}</p>
<input
value={textValue}
onChange={(domEvt) => {
setTextValue(domEvt.target.value);
placeholder="text value"
这样当然可以达到我们的目的, 但有个小小的不足: 当
<SimpleInput />
重新渲染, 每次
<input />
的 property
onChange
都会得到一个全新的回调函数:
(domEvt) => {
setTextValue(domEvt.target.value);
我们并不担心创建这样一个回调函数的开销, 它微乎其微. 但这样可能导致
<input />
得到的一个 property 发生了变化.
在这个例子中, 每过 1s,
<SimpleInput />
就会因为
setClock
被调用而发生重新渲染, 继而
<input onChange />
会得到一个全新的
onChange
值, 而这个
onChange
的变化可能会导致
<input />
内部发生一些计算. 但实际上,
onChange
做的事一直不变: 从用户的输入中获取最新的值, 更新给
textValue
.
这一动作不受外界任何变化影响, 尤其是, 这个回调函数内部没有依赖任何其它的外部有状态变量
.
假如
<input />
会因为 onChange的变化而在内部产生巨大的重新计算, 而
onChange
要做的事又始终不变, 则这样的重新计算是
巨大的浪费
.
所以, 为了尽可能避免
<input />
产生不必要的重新渲染, 对于
onTextChange
这个其实永远不会变化其执行内容的函数而言, 我们可以用
useCallback
将其包裹起来:
const SimpleInput = () => {
const [clock, setClock] = React.useState(0);
const [textValue, setTextValue] = React.useState('');
const onTextChange = React.useCallback((domEvt) => {
setTextValue(domEvt.target.value);
}, []);
React.useEffect(() => {
const timer = setInterval(() => {
setClock(prev => prev + 1);
}, 1000);
return () => {
clearInterval(timer);
}, []);
return (
<p>time counter: {clock}</p>
<input
value={textValue}
onChange={onTextChange}
placeholder="text value"
这样, 无论
<SimpleInput />
重新渲染多少次, onTextChange 的值会是
一份永远不变的副本
.
对于 React FC, 我们尽可能遵循一个原则: 能不重新渲染的, 就不要重新渲染 . "重新从 Virtual DOM 渲染出对应的 state 一模一样的 DOM"本身就是种浪费.
注意
实际上
<input />
并不会因为传入的
onChange
变化而产生巨大的计算, 但我们打开思路, 假设有一个...
<ComplexInput onEvent={onComplexEvent} />
, 而
onComplexEvent
的变化会导致
<ComplexInput />
的内部做非常多的计算呢?
一个幽灵场景
在之前的例子中, 我们希望
onTextChange
始终保持不变, 但有时候, 我们希望某些函数
仅在某些其它有状态的变量变化的时候发生变化
. 并且, 很有可能你会遇到一些奇怪的现象.
接下来我们想象一个稍微复杂一点的场景: 你有一个 antd 的
<Table />
组件, 并打开了 sort 功能, 当你点击某一列的表头的时候, 你需要根据列的 column index(sortBy) 以及排序顺序(orderBy), 从远端拉取数据, 然后更新列表中的数据.
这个场景略微有点复杂, 我们用 codesandbox 来演示: thirsty-monad-cdvyf - CodeSandbox
在这个例子中:
- 生成了一个列数据数组 dataSource , 其 f1 是稳定的有序数组, 且 dataSource 按照 f1 倒序排序(desc order)
- 将 dataSource 作为 listData 的初始值
-
<Table />
组件以 listData 为数据源 - 将 loadData 作为"根据 sortBy, orderBy 拉取远端"数据的动作, 更新 listData.
我们的设想是: 当
<Table />
的列表头被点击时, 根据 antd Table 组件的特性, 会触发
handlePageChange
, 在其中
setSortBy
,
setOrderBy
会被调用, 并且
loadData
被调用, 去"拉取"按照求排序好的数据列表. 同时, 由于
loadData
内部使用了
sortBy
和
orderBy
, 因此我们用
const loadData = useCallback(cb, [sortBy, orderBy])
来表示:
仅当
sortBy
或
orderBy
更新的时候, 将
loadData
更新为新的副本
看起来, 这一切没什么问题. 我们希望 , 当我们多次点击 f1 这一列的表头时:
-
第一次点击 f1 表头: sorter 会切换到"以 f1 升序排序", loadData 拉到以 f1 asc order 的数据, 更新
<Table />
组件 -
第二次点击 f1 表头: sorter 将会切换到"以 f1 倒序排序", loadData 拉到以 f1 desc order 的数据, 更新
<Table />
组件 - 第三次点击 f1 表头: sorter 将会切换到"默认无状态", 此种状态下, loadData 拉到 dataSource 原始数据的副本(实际上也就是以 f1 倒序排序)
为了验证我们的预期, 我们:
-
在 handlePageChange 的开头打印出
<Table />
给出的 sorter 信息 -
在 loadData 打印
sortBy
和orderBy
现在连续点击 f1 的表头 3 次, 得到如下日志:
What? 为什么会第一点击之后, 我们获取的
sortBy
,
orderBy
的值都是 null?
不仅如此, 似乎每次
loadData
都没有真正获得我们在
handlePageChange
里通过
setSortBy
,
setOrderBy
设定的最新的
sortBy
,
orderBy
. 但是我们不是明明通过
const loadData = useCallback(cb, [sortBy, orderBy]
设定了, 一旦两个依赖的变量更新,
loadData
要更新吗?
为了解释这个问题, 我们要清楚一点,
只有当 FC 进行重新渲染的时候,
useCallback(cb, [sortBy, orderBy])
所标记的有状态的函数副本才会被更新!
回顾一下上文中关于 FC 中
useCallback
机制的工作原理, 我们用同样的方式我们分析下这个例子中的重渲染流程:
+++++++++ first time render ++++++++
sortBy := useState()
orderBy := useState()
loadData = useCallback(cb, [sortBy, orderBy]) // now it's snapshot_v1
handlePageChange = useCallback(cb, [loadData])
...(asynchronous -- "when table head of f1 colum clicked":
------- setSortBy(xxx) ---> update sortBy on next tick
------- setOrderBy(xxx) ---> update orderBy on next tick
------- loadData() --- still snapshot_v1, hasn't been updated!
...( trigger re-render )
+++++++++ second time render ++++++++
sortBy := useState() <--- updated
orderBy := useState() <--- updated
loadData = useCallback(cb, [sortBy, orderBy]) // now it's snapshot_v2
handlePageChange = useCallback(cb, [loadData])
...
显而易见, 当第一次 f1 列的表头被点击的时候, 尽管我们已经调用
setSortBy
,
setOrderBy
, 但紧接着立刻被调用的
loadData
版本依然是之前的
snapshot_v1
!
因此, 和我们的预期相比, 实际上你会感觉每一次"点击 f1 列的表头"对应的更新数据动作总是会"慢一拍", 因为
loadData
的值总是
比你认为的版本要旧
. 这个旧版本值像一个幽灵 , 总是跟在你的最新操作中.
如何改进
对于这种"幽灵"情况, 不要在
setSortBy
,
setOrderBy
之后, 立刻调用对它们的值有依赖的
loadData
. 我们可以转为另外两种方式:
-
当
handlePageChange
被触发时, 将sortBy
和orderBy
直接作为参数传递给 loadData. -
(不推荐)使用
useEffect
观察sortBy
和orderBy
的变化, 当两个值变化时, 再调用 loadData.
在这个例子中, 我推荐方式 1.
方式 2 的问题是: 你可能无法保证按序调用的
setSortBy, setOrderBy
所引起的状态变更总是发生在同一批 React State Update 中. 如果因为 React 的未来的实现产生变更, 或者因为用户未来无意识添加了一些劣化代码, 它们引发了两次 FC re-render, 在效果上, 你可能会观察到连续两次
loadData
.
// 不够好的方式 2
const [sortBy, setSortBy] = React.useState(null);
const [orderBy, setOrderBy] = React.useState(null);
const loadData = React.useCallback(asyncCb, [ sortBy, orderBy ]);
React.useEffect(() => {
// 如果 sortBy, orderBy 的更新被放在了两个 React State Update 更新批次(尽管可能性微乎其微)
// 则这里可能会被连续触发两次 loadData
loadData();
}, [ sortBy, orderBy ]);
如果一定要使用方式 2, 建议将
sortBy
,
orderBy
放在同一个对象中作为一个状态:
// 改进的方式 2
const [ sorterInfo, setSorterInfo ] = React.useState({ sortBy: ..., orderBy: ... });
const loadData = React.useCallback(asyncCb, [ sorterInfo.sortBy, sorterInfo.orderBy ]);