React Hooks 里的 useCallback 更新幽灵

本文同时发布于博客 blog.chenlei.me/# , 内含可交互的演示实例


幽——灵——!

前言

对于 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 的基本工作原理:

  1. 在一个 FC 中, 包装一个函数, 这个函数会被"记住", 如果没有别的原因, 这个函数在 FC 多次被调用的过程中是不变的 (也就是 react hook 的状态)
  2. 如果 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
...

对应的文字描述:

  1. FC 进行第一次渲染, useCallback 执行(我们把这个执行点记为 S1)得到 有状态的 memoizedCallback (这是一份副本 snapshot_1)
  2. 在某个异步逻辑中, 执行 setC(...) , 导致整个 FC 进入 重新渲染(注意 a, b 没有被更新)
  3. FC 进行第二次渲染, 在 S1 处, useCallback(cb, deps) 根据 deps 内的各值变化确定是否要对 S1 的值更新; 发现 deps 中并无变化. 于是 保留 memoizedCallback 的副本 snapshot_1;
  4. 在某个异步逻辑中, 执行 setA(...) , 导致整个 FC 进入 重新渲染(注意 a 被更新了)
  5. FC 进行第三次渲染, 在 S1 处, useCallback(cb, deps)根据 deps 内的各值变化确定是否要对 S1 的值更新; 发现 deps 中的 a 发生了变化. 于是 更新 memoizedCallback 的值为 snapshot_2.

以上就是 useCallback 的工作原理简介. 从中可以看出, useCallback 可以用于减少 FC 中不必要的对函数类状态更新 . 具体来说, 有可能在 FC 中你需要构造一个依赖了 FC 内部变量的函数(往往它所依赖的变量还是 FC 的另一个有状态的变量), 那么, 我们 不妨 useCallback 来包装一下这个函数.

一个简单场景

我们想象这样的场景: 你有一个组件, 它包含两部分:

  1. 一个从 0 开始的计时器, 每过 1 秒, 你需要将其更新, 并将它的值展示出来.
  2. 一个输入框 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 这一列的表头时:

  1. 第一次点击 f1 表头: sorter 会切换到"以 f1 升序排序", loadData 拉到以 f1 asc order 的数据, 更新 <Table /> 组件
  2. 第二次点击 f1 表头: sorter 将会切换到"以 f1 倒序排序", loadData 拉到以 f1 desc order 的数据, 更新 <Table /> 组件
  3. 第三次点击 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 . 我们可以转为另外两种方式:

  1. handlePageChange 被触发时, 将 sortBy orderBy 直接作为参数传递给 loadData.
  2. (不推荐)使用 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 ]);