最近去了新公司,发现有的同事还是准规守矩的写着useCallback
、useMemo
,让随意的我很头大。
本文内容只限于函数组件,本文中的“更新”指代的是函数组件重新执行。
下面就简单的谈一谈,组件究竟在什么情况下会更新。
正文之前
在探究原理之前,需要先简单的了解几个知识点。
- React中的diff是Fiber节点与JSX对象做比较,并不是所谓的新老两个Fiber节点做比较。
- React中的diff并不会决定组件是否会更新,只会决定是否要复用Fiber节点。
- React中的diff并不是作用于当前的Fiber节点,而是作用于子节点。
组件究竟是因为什么更新?
这有一个简单的例子。
const Child = () => {
console.log("render");
return null;
};
const App = () => {
const [, setState] = useState();
return (
<div onClick={() => setState({})}>
<Child />
</div>
);
};
<Child>
组件没有接收state
的值,每次点击<div>
让state
更新,<Child>
组件会更新吗?
...
...
...
答案是会更新的,有些小伙伴可能会好奇,为什么子组件没有接收任何的props
,为什么也会更新呢?
组件更新的先决条件
- 父级经过了diff阶段,产生了新的子级Fiber节点(对应该函数组件)。
jsx => { props: { value: 123 } }
oldFiber.memoizedProps => { value: 123 }
⬇️ diff后,key相同,type不变,复用Fiber节点
newFiber.pendingProps = jsx.props = { value: 123 }
oldFiber.memoizedProps
即是目前的props
。newFiber.penderProps
即是即将更新的props
,它被赋值为了JSX对象的props
。
- 该函数组件Fiber节点进入
beginWork
阶段,经判断props
不相同。
const oldProps = current.memoizedProps; // 当前节点的props
const newProps = workInProgress.pendingProps; // 本次更新该节点的props
if (oldProps !== newProps) {
didReceiveUpdate = true; // 标记有更新
}
这里就是新旧props
最初对比的地方,在React中当前组件的对比仅用了!==
来判断,每次生成JSX对象时,即使为空,也会生成不同的props
空对象。
有什么方法可以避免这种无效更新呢?
避免组件更新的3种方式
- 使用
React.memo
,它可以像类组件的PureComponent
一样在对比的时候做第一层的浅对比,具体原理可以看我之前的文章React中Props的浅对比。 - 使用useMemo来包住子组件,让每次更新时子组件都为同一个JSX对象,这样props的比较必然相同。
const App = () => {
const [, setState] = useState();
const child = useMemo(() => <Child />, []);
return <div onClick={() => setState({})}>{child}</div>;
};
- 将子组件作为children来传递。
const App = ({ children }) => {
const [, setState] = useState();
return (
<div onClick={() => setState({})}>
{children}
</div>
);
};
<App>
<Child /> === <App children={<Child />} />
</App>
这种方式可以理解为父级将<Child>
作为props
传入了当前组件。
推荐React团队成员Dan的一篇文章在你写memo()之前。
何时使用useCallback和useMemo?
考虑以下情况,经过React.memo
的 <Child>
组件在state
改变后会更新吗?
const Child = React.memo(() => null);
const App = () => {
const [, setState] = useState();
const callback = () => null;
return (
<div onClick={() => setState({})}>
<Child callback={callback} />
</div>
);
};
...
...
...
答案是会更新的。
因为函数组件更新实际会重新执行一次该函数本身,所以内部的函数也会重新生成,即便函数内容相同,但不是同一个函数,在React.memo
浅对比时也不相同。
解决这种问题的方法即是使用useCallback
。
const Child = React.memo(() => null);
const App = () => {
const [, setState] = useState();
const callback = useCallback(() => null, []);
return (
<div onClick={() => setState({})}>
<Child callback={callback} />
</div>
);
};
由useCallback
将每次函数执行产生的内部函数返回为同一个函数,这样在React.memo
的时候浅对比就相同了。
其实还有一种方式来固定函数。
const callback = useRef(() => null)
可能会比较奇怪,但是useRef
其实可以做到更好的固定效果。
useCallback和useMemo是最优解吗?
可能会有小伙伴和我同事一样的想法,不管三七二十一,总之全包上就行,总不会出错了吧~
其实并不是这样,我们在使用useCallback
和useMemo
的时候其实都会有额外的消耗。
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T { if (areHookInputsEqual(nextDeps, prevDeps)) { // 两个依赖数组 return prevState[0]; // 旧函数 } // 新函数 hook.memoizedState = [callback, nextDeps]; return callback;}
这是useCallback
的源码截取,实际上我们在使用它的时,最多会产生两个函数,和两个依赖数组,同时还会有依赖数组的遍历比较。
所以使用它们并不一定会起到性能优化的效果。
我觉得适用的场景
useCallback
- 子组件有React.memo时,不希望仅仅传递的无状态函数导致导致子组件函数重新执行。
如果子组件一定会更新,那么固定函数的意义就不大了。
<Child state={state} callback={callback} />
当每次更新时state
都会改变,那么固定函数也是徒劳的。
useMemo
- 让一个组件固定为同一个JSX对象,这样在对比时
props
相同。 - 将一个不包含
state
值的对象缓存后传递给子组件,不会让子组件更新。 - 一个组件内有大量数据计算,每次更新都会重新计算,确保性能可以使用
useMemo
缓存计算结果。
结语
滥用useCallback
和useMemo
可能不会起到性能优化的情况,还是需要酌情考虑,过早的优化也不一定是正确的。
React里面的水太深,小张也把握不住,如果文中有错误,还望大佬们指出讨论。