useCallBack的使用场景
useCallback 的真正目的还是在于缓存了每次渲染时 inline callback 的实例,但并不是每个函数都需要使用,无意义的使用反而会使性能下降。
看到这里,有些笔友就要发问三连了。
1.为什么不用useCallBack把每个函数都包一下呢?
2.useCallBack不是缓存工具吗?
3.将每个函数都缓存不是可以更好提升性能吗?
useCallBack是一个缓存工具没错。但实际上他并不能阻止函数都重现构建。
我们来看一下下面这个例子:
//Com组件
const Com = () => {
//示例1包裹了useCallBack的函数
const fun1 = useCallBack(() => {
console.log('示例一函数');
...
},[])
//示例2没有包裹useCallBack的函数
const fun2 = () => {
console.log('示例二函数');
...
}
return <div></div>
}
大家看上方这种结构的组件,Com组件中包含了fun1和fun2两个函数。
是不是认为当Com组件重新渲染的时候,只有fun2(没有使用useCallBack的函数)函数会被重新构建,而fun1(使用了useCallBack的函数)函数不会被重新构建。
实际上,被useCallBack包裹了的函数也会被重新构建并当成useCallBack函数的实参传入。
useCallBack的本质工作不是在依赖不变的情况下阻止函数创建,而是在依赖不变的情况下不返回新的函数地址而返回旧的函数地址。不论是否使用useCallBack都无法阻止组件render时函数的重新创建!!
每一个被useCallBack的函数都将被加入useCallBack内部的管理队列。而当我们大量使用useCallBack的时候,管理队列中的函数会非常之多,任何一个使用了useCallBack的组件重新渲染的时候都需要去便利useCallBack内部所有被管理的函数找到需要校验依赖是否改变的函数并进行校验。
在以上这个过程中,寻找指定函数需要性能,校验也需要性能。所以,滥用useCallBack不但不能阻止函数重新构建还会增加“寻找指定函数和校验依赖是否改变”这两个功能,为项目增添不必要的负担。
关于这一点我们通过源码实现来看就更明确了。我们在使用hooks的时候是使用的函数式,也就是function来实现,function 组件不能做继承,因为 function 本来就没这个特性,所以是提供了一些 api 供函数使用,这些 api 会在内部的一个数据结构上挂载一些函数和值,并执行相应的逻辑,通过这种方式实现了 state 和类似 class 组件的生命周期函数的功能,这种 api 就叫做 hooks。
hooks 挂载数据的数据结构叫做 fiber。
我们知道,React 是通过 jsx 来描述界面结构的,会把 jsx 编译成 render function,然后执行 render function 产生 vdom,当有更新时把 vdom 树转成 fiber 链表,然后再渲染 fiber。vdom 转 fiber 的过程叫做 reconcile,是可打断的,React 加入了 schedule 的机制在空闲时调度 reconcile,reconcile 的过程中会做 diff,打上增删改的标记(effectTag),并把对应的 dom 创建好。然后就可以一次性把 fiber 渲染到 dom,也就是 commit。
这个 schdule、reconcile、commit 的流程就是 fiber 架构。当然,对应的这个数据结构也叫 fiber。
hooks 就是通过把数据挂载到组件对应的 fiber 节点上来实现的。
fiber 节点的 memorizedState 就是保存 hooks 数据的地方。它是一个通过 next 串联的链表,执行的时候各自在自己的那个 memorizedState 上存取数据,完成各种逻辑,这就是 hooks 的原理。
这个 memorizedState 链表是什么时候创建的呢?
好问题,确实有个链表创建的过程,也就是 mountXxx。链表只需要创建一次,后面只需要 update:
//创建阶段
HooksDispatcherOnMountInDEV={
useCallback:funciton(create,deps){
currentHookNameInDev = "useCallback"
mountHookTypesDev();
checkDepsAreArrayDev(deps);
return mountCallback(callback, deps);
}
}
//更新阶段
HooksDispatcherOnUpdateInDEV = {
useCallback: function (callback, deps) {
currentHookNameInDev = 'useCallback';
updateHookTypesDev();
return updateCallback(callback, deps);
}
}
我们可以看到在创建的时候调用了mountHookTypesDev,更新的时候调用的是updateHookTypesDev。
function mountHookTypesDev() {
{
var hookName = currentHookNameInDev;
if (hookTypesDev === null) {
hookTypesDev = [hookName];
} else {
hookTypesDev.push(hookName);
}
}
}
function updateHookTypesDev() {
{
var hookName = currentHookNameInDev;
if (hookTypesDev !== null) {
hookTypesUpdateIndexDev++;
if (hookTypesDev[hookTypesUpdateIndexDev] !== hookName) {
warnOnHookMismatchInDev(hookName);
}
}
}
}
function mountCallback(callback, deps) {
var hook = mountWorkInProgressHook();
var nextDeps = deps === undefined ? null : deps;
hook.memoizedState = [callback, nextDeps];
return callback;
}
function updateCallback(callback, deps) {
var hook = updateWorkInProgressHook();
var nextDeps = deps === undefined ? null : deps;
var prevState = hook.memoizedState;
if (prevState !== null) {
if (nextDeps !== null) {
var prevDeps = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
}
hook.memoizedState = [callback, nextDeps];
return callback;
}
useCallback 在 memorizedState 上放了一个数组,第一个元素是传入的回调函数,第二个是传入的 deps(对 deps 做了下 undefined 的处理)。
更新的时候把之前的那个 memorizedState 取出来,和新传入的 deps 做下对比,如果没变,那就返回之前的回调函数,也就是 prevState[0]。
如果变了,那就创建一个新的数组,第一个元素是传入的回调函数,第二个是传入的 deps。
所以,useCallback 的功能也就呼之欲出了:useCallback 可以实现函数的缓存,如果 deps 没变就不会创建新的,否则才会返回新传入的函数。
使用场景
在往子组件传入了一个函数并且子组件被 memo 缓存了的时候使用
同源源码我们可以发现,useCallBack的作用不是阻止函数创建,而是在依赖不变的情况下返回旧函数地址(保持地址不变)。
memo,是一种缓存技术。它是通过校验props中的数据是否改变的来决定组件是否需要重新渲染的一种缓存技术,具体点说 UseMemo 其实是通过校验 Props 中的数据的内存地址是否改变来决定组件是否重新渲染组件的一种技术。
假设我们往子组件(假设子组件为Child组件)传入一个函数呢?当父组件的其他State(与Child组件无关的state)改变的时候。那么,因为状态的改变,父组件需要重新渲染,那被 memo 保护的子组件(Child组件)是否会被重新构建?
就这个问题,举个栗子。有如下↓代码片段
import {useCallBack,memo} from 'react';
/**父组件**/
const Parent = () => {
const [parentState,setParentState] = useState(0); //父组件的state
//需要传入子组件的函数
const toChildFun = () => {
console.log("需要传入子组件的函数");
...
}
return (<div>
<Button onClick={() => setParentState(val => val+1)}>
点击我改变父组件中与Child组件无关的state
</Button>
//将父组件的函数传入子组件
<Child fun={toChildFun}></Child>
<div>)
}
/**被memo保护的子组件**/
const Child = memo(() => {
consolo.log("我被打印了就说明子组件重新构建了")
return <div><div>
})
问:当我点击父组件中的Button改变父组件中的state。子组件会不会重新渲染。乍一看,改变的是parentState这个变量,和子组件半毛钱关系没有,子组件还被React.memo保护着,好像是不会被重新渲染。但这里的问题是,你要传个其他变量进去这也就走的通了。但是传入的是函数,不行,走不通。会重新渲染。
React.memo检测的是props中数据的栈地址是否改变。而父组件重新构建的时候,会重新构建父组件中的所有函数(旧函数销毁,新函数创建,等于更新了函数地址),新的函数地址传入到子组件中被props检测到栈地址更新。也就引发了子组件的重新渲染。
所以,在上面的代码示例里面,子组件是要被重新渲染的。
解决方案
使用useCallBack包一下需要传入子组件的那个函数。那样的话,父组件重新渲染,子组件中的函数就会因为被useCallBack保护而返回旧的函数地址,子组件就不会检测成地址变化,也就不会重选渲染。
还是上面的代码示例,我们进行以下优化。
import {useCallBack,memo} from 'react';
/**父组件**/
const Parent = () => {
const [parentState,setParentState] = useState(0); //父组件的state
//需要传入子组件的函数
//只有这里和上一个示例不一样!!
const toChildFun = useCallBack(() => {
console.log("需要传入子组件的函数");
...
},[])
return (<div>
<Button onClick={() => setParentState(val => val+1)}>
点击我改变父组件中与Child组件无关的state
</Button>
//将父组件的函数传入子组件
<Child fun={toChildFun}></Child>
<div>)
}
/**被memo保护的子组件**/
const Child = memo(() => {
consolo.log("我被打印了就说明子组件重新构建了")
return <div><div>
})
这样,子组件就不会被重新渲染了。
代码示例一和代码示例二中的区别只有被传入的子组件的函数(toChildFun函数)是否被useCallBack保护。
我们只需要使用useCallBack保护一下父组件中传入子组件的那个函数(toChildFun函数)保证它不会在没有必要的情况下返回一个新的内存地址就好了。
总结
useCallBack不要每个函数都包一下,否则就会变成反向优化,useCallBack本身就是需要一定性能的
useCallBack并不能阻止函数重新创建,它只能通过依赖决定返回新的函数还是旧的函数,从而在依赖不变的情况下保证函数地址不变
useCallBack需要配合React.memo使用
文章参考掘金作者:工边页字
链接:https://juejin.cn/post/7107943235099557896