前言 对于 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 ...
对应的文字描述:
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 组件表达如下:
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 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
内部使用了 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
机制的工作原理, 我们用同样的方式我们分析下这个例子中的重渲染流程:
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
. 我们可以转为另外两种方式:
当 handlePageChange
被触发时, 将 sortBy
和 orderBy
直接作为参数传递给 loadData
.
**(不推荐)**使用 useEffect
观察 sortBy
和 orderBy
的变化, 当两个值变化时, 再调用 loadData
.
在这个例子中, 我推荐方式 1.
方式 2 的问题是: 你可能无法保证按序调用的 setSortBy
, setOrderBy
所引起的状态变更总是发生在同一批 React State Update 中. 如果因为 React 的未来的实现产生变更, 或者因为用户未来无意识添加了一些劣化代码, 它们引发了两次 FC re-render, 在效果上, 你可能会观察到连续两次 loadData
.
1 2 3 4 5 6 7 8 9 10 11 const [sortBy, setSortBy] = React .useState (null );const [orderBy, setOrderBy] = React .useState (null );const loadData = React .useCallback (asyncCb, [sortBy, orderBy]);React .useEffect (() => { loadData (); }, [sortBy, orderBy]);
如果一定要使用方式 2, 建议将 sortBy
, orderBy
放在同一个对象中作为一个状态:
1 2 3 4 5 6 7 8 const [ sorterInfo, setSorterInfo ] = React .useState ({ sortBy : ..., orderBy : ... });const loadData = React .useCallback (asyncCb, [ sorterInfo.sortBy , sorterInfo.orderBy ]);React .useEffect (() => { loadData (); }, [ sorterInfo ]);
这样, 当你明确地同时更新 sortBy
和 orderBy
, 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 . 我可协助内推以下公司:
阿里巴巴(深圳, 杭州, 北京, 上海, 成都)
网易(杭州)
腾讯(成都, 深圳)
字节跳动(杭州)