React Hooks 中的更新幽灵

前言

对于 react hooks, 我个人使用最多的是以下三个 hook 方法:

  • useState
  • useRef
  • useCallback

关于 useState 和 useRef 的配合使用,可以参考 React Hooks 中的副作用, 闭包 与 Timer. 在本文中, 我们来谈谈 useCallback 的常见使用场景, 以及一个对初学者而言匪夷所思的依赖更新问题, 我称之为 “useCallback 的幽灵现象”.

如果你对 react hooks 还不了解, 可先简单看一下 https://reactjs.org/docs/hooks-reference.html 以了解有哪些 hooks api. 它们对于 React (>= 16.8)中的函数式组件(Functional Component, 下文简称 FC), 都起到了标记一个有状态变量的作用.

useCallback 的用法

1
2
3
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);

这 React 官方为 useCallback 配的例子, 它解释了 useCallback 的基本工作原理:

在一个 FC 中, 包装一个函数, 这个函数会被”记住”, 如果没有别的原因, 这个函数在 FC 多次被调用的过程中是不变的 (也就是 react hook 的状态)
如果 useCallback(cb, deps) 的 deps 列表中有任意一项发生了变化 (对于 primitive type 而言, 是值的变化; 对于 object 而言, 是引用的变化/内存地址的变化), 则 memoizedCallback 会被更新, 并且在 FC 的下一次渲染中使用新的副本.
我们举例说明下, 以下面这段代码为例:

1
2
3
4
5
6
7
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] 的工作机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+++++++++ 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 组件表达如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* defaultShowCode: true
*/
import React from 'react';

export default function 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 都会得到一个全新的回调函数:

1
2
3
(domEvt) => {
setTextValue(domEvt.target.value);
};

我们并不担心创建这样一个回调函数的开销, 它微乎其微. 但这样可能导致 <input /> 得到的一个 property 发生了变化.

在这个例子中, 每过 1s, <SimpleInput /> 就会因为 setClock 被调用而发生重新渲染, 继而 <input onChange /> 会得到一个全新的 onChange 值, 而这个 onChange 的变化可能会导致 <input /> 内部发生一些计算. 但实际上, onChange 做的事一直不变: 从用户的输入中获取最新的值, 更新给 textValue. 这一动作不受外界任何变化影响, 尤其是, 这个回调函数内部没有依赖任何其它的外部有状态变量.

假如 <input /> 会因为 onChange 的变化而在内部产生巨大的重新计算, 而 onChange 要做的事又始终不变, 则这样的重新计算是巨大的浪费.

所以, 为了尽可能避免 <input /> 产生不必要的重新渲染, 对于 onTextChange 这个其实永远不会变化其执行内容的函数而言, 我们可以用 useCallback 将其包裹起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import React from 'react';

export default function 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), 从远端拉取数据, 然后更新列表中的数据.

这个场景略微有点复杂, 你有必要阅读下这个例子的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import React from 'react';
import { Table } from 'antd';

const ascOrder = (a, b) => (a <= b ? -1 : 1);
const descOrder = (a, b) => (a > b ? -1 : 1);

const dataSource = Array(100)
.fill(undefined)
.map((x, idx) => {
return {
f1: idx + 1,
f2: Math.floor(Math.random() * 100),
f3: Math.floor(Math.random() * 1000),
};
})
.sort((a, b) => descOrder(a.f1, b.f1));

const fakeResortByRemote = async (sortBy, order) => {
return dataSource.sort((a, b) => {
switch (order) {
case 'ascend':
return ascOrder(a[sortBy], b[sortBy]);
default:
return Array.from(dataSource);
case 'descend':
return descOrder(a[sortBy], b[sortBy]);
}
});
};

const columnsConfig = [
{
title: 'f1',
dataIndex: 'f1',
sorter: true,
},
{
title: 'f2',
dataIndex: 'f2',
sorter: true,
},
{
title: 'f3',
dataIndex: 'f3',
sorter: true,
},
];

export default function App() {
const [listData, setListData] = React.useState(dataSource);
const [sortBy, setSortBy] = React.useState(null);
const [orderBy, setOrderBy] = React.useState(null);

const loadData = React.useCallback(async () => {
console.log('[loadData] get sortBy', sortBy);
console.log('[loadData] get orderBy', orderBy);
const newDataSource = await fakeResortByRemote(sortBy, orderBy);
setListData(newDataSource);
}, [sortBy, orderBy]);

const handlePageChange = React.useCallback(
async (_, _1, sorter) => {
console.log('[handlePageChange] setSortBy, setOrderBy from sorter', sorter);
setSortBy(sorter.field);
setOrderBy(sorter.order);
loadData();
},
[loadData],
);

return (
<div className="App">
<div>
sortBy: {sortBy || '-'} <br />
orderBy: {orderBy || '-'} <br />
</div>
<Table dataSource={listData} columns={columnsConfig} onChange={handlePageChange} />
</div>
);
}

在这个例子中:

  • 生成了一个列数据数组 dataSource , 其 f1 是稳定的有序数组, 且 dataSource 按照 f1 倒序排序(desc order)
  • 将 dataSource 作为 listData 的初始值
  • <Table /> 组件以 listData 为数据源
  • 将 loadData 作为”根据 sortBy, orderBy 拉取远端”数据的动作, 更新 listData.

我们的设想是: 当 <Table /> 的列表头被点击时, 根据 antd Table 组件的特性, 会触发 handlePageChange, 在其中 setSortBy, setOrderBy 会被调用, 并且 loadData 被调用, 去”拉取”按照求排序好的数据列表. 同时, 由于 loadData 内部使用了 sortByorderBy, 因此我们用 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 打印 sortByorderBy

现在连续点击上面那个例子的 f1 的表头 3 次, 得到如下日志:

img

What? 为什么会第一点击之后, 我们获取的 sortBy, orderBy 的值都是 null?

不仅如此, 似乎每次 loadData 都没有真正获得我们在 handlePageChange 里通过 setSortBy, setOrderBy 设定的最新的 sortBy, orderBy. 但是我们不是明明通过 const loadData = useCallback(cb, [sortBy, orderBy]) 设定了, 一旦两个依赖的变量更新, loadData 要更新吗?

为了解释这个问题, 我们要清楚一点, 只有当 FC 进行重新渲染的时候, useCallback(cb, [sortBy, orderBy]) 所标记的有状态的函数副本才会被更新!

回顾一下上文中关于 FC 中 useCallback 机制的工作原理, 我们用同样的方式我们分析下这个例子中的重渲染流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
+++++++++ 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 被触发时, 将 sortByorderBy 直接作为参数传递给 loadData.
  2. **(不推荐)**使用 useEffect 观察 sortByorderBy 的变化, 当两个值变化时, 再调用 loadData.

在这个例子中, 我推荐方式 1.

方式 2 的问题是: 你可能无法保证按序调用的 setSortBy, setOrderBy 所引起的状态变更总是发生在同一批 React State Update 中. 如果因为 React 的未来的实现产生变更, 或者因为用户未来无意识添加了一些劣化代码, 它们引发了两次 FC re-render, 在效果上, 你可能会观察到连续两次 loadData.

1
2
3
4
5
6
7
8
9
10
11
// 不够好的方式 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 放在同一个对象中作为一个状态:

1
2
3
4
5
6
7
8
// 改进的方式 2
const [ sorterInfo, setSorterInfo ] = React.useState({ sortBy: ..., orderBy: ... });
const loadData = React.useCallback(asyncCb, [ sorterInfo.sortBy, sorterInfo.orderBy ]);

React.useEffect(() => {
// 迫使用户有意识地更新 sorterInfo 对象, 从而保证 sortBy/orderBy 同时更新的时候也只触发一次 loadData
loadData();
}, [ sorterInfo ]);

这样, 当你明确地同时更新 sortByorderBy, useEffect 只会观察到 sorterInfo 的更新, 从而只触发一次 loadData.

最后, 放出完整版的可用的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import React from 'react';
import { Table } from 'antd';

const ascOrder = (a, b) => (a <= b ? -1 : 1);
const descOrder = (a, b) => (a > b ? -1 : 1);

const dataSource = Array(100)
.fill(undefined)
.map((x, idx) => {
return {
f1: idx + 1,
f2: Math.floor(Math.random() * 100),
f3: Math.floor(Math.random() * 1000),
};
})
.sort((a, b) => descOrder(a.f1, b.f1));

const fakeResortByRemote = async (sortBy, order) => {
return dataSource.sort((a, b) => {
switch (order) {
case 'ascend':
return ascOrder(a[sortBy], b[sortBy]);
default:
return Array.from(dataSource);
case 'descend':
return descOrder(a[sortBy], b[sortBy]);
}
});
};

const columnsConfig = [
{
title: 'f1',
dataIndex: 'f1',
sorter: true,
},
{
title: 'f2',
dataIndex: 'f2',
sorter: true,
},
{
title: 'f3',
dataIndex: 'f3',
sorter: true,
},
];

export default function App() {
const [listData, setListData] = React.useState(dataSource);
const [sortBy, setSortBy] = React.useState(null);
const [orderBy, setOrderBy] = React.useState(null);

const loadData = React.useCallback(async (sortField, order) => {
const newDataSource = await fakeResortByRemote(sortField, order);
setListData(newDataSource);
setSortBy(sortField);
setOrderBy(order);
}, []);

const handlePageChange = React.useCallback(
async (_, _1, sorter) => {
loadData(sorter.field, sorter.order);
},
[loadData],
);

return (
<div className="App">
<div>
sortBy: {sortBy || '-'} <br />
orderBy: {orderBy || '-'} <br />
</div>
<Table dataSource={listData} columns={columnsConfig} onChange={handlePageChange} />
</div>
);
}

工作机会

若你是校招生正在寻求实习, 或者你考虑换工作, 可将简历发到 richardo2016#gmail.com. 我可协助内推以下公司:

  • 阿里巴巴(深圳, 杭州, 北京, 上海, 成都)
  • 网易(杭州)
  • 腾讯(成都, 深圳)
  • 字节跳动(杭州)
首页归档简历