一文搞懂React中props导致的更新

4,723 阅读4分钟

最近去了新公司,发现有的同事还是准规守矩的写着useCallbackuseMemo,让随意的我很头大。

本文内容只限于函数组件,本文中的“更新”指代的是函数组件重新执行。

下面就简单的谈一谈,组件究竟在什么情况下会更新。

正文之前

在探究原理之前,需要先简单的了解几个知识点。

  1. React中的diff是Fiber节点与JSX对象做比较,并不是所谓的新老两个Fiber节点做比较。
  2. React中的diff并不会决定组件是否会更新,只会决定是否要复用Fiber节点。

想了解diff源码可以看我之前的文章React中Diff算法源码浅析

组件究竟是因为什么更新?

这有一个简单的例子。

const Child = () => {
  console.log("render");
  return null;
};

const App = () => {
  const [, setState] = useState();
  return (
    <div onClick={() => setState({})}>
      <Child />
    </div>
  );
};

<Child>组件没有接收state的值,每次点击<div>state更新,<Child>组件会更新吗?

...

...

...

答案是会更新的,有些小伙伴可能会好奇,为什么子组件没有接收任何的props,为什么也会更新呢?

组件更新的先决条件

  1. 父级经过了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
  1. 该函数组件Fiber节点进入beginWork阶段,经判断props不相同。
const oldProps = current.memoizedProps; // 当前节点的props
const newProps = workInProgress.pendingProps; // 本次更新该节点的props
if (oldProps !== newProps) {
  didReceiveUpdate = true; // 标记有更新
}

这里就是新旧props最初对比的地方,在React中当前组件的对比仅用了!==来判断,每次生成JSX对象时,即使为空,也会生成不同的props空对象。

有什么方法可以避免这种无效更新呢?

避免组件更新的3种方式

  1. 使用React.memo,它可以像类组件的PureComponent一样在对比的时候做第一层的浅对比,具体原理可以看我之前的文章React中Props的浅对比
  2. 使用useMemo来包住子组件,让每次更新时子组件都为同一个JSX对象,这样props的比较必然相同。
const App = () => {
  const [, setState] = useState();
  const child = useMemo(() => <Child />, []);
  return <div onClick={() => setState({})}>{child}</div>;
};
  1. 将子组件作为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是最优解吗?

可能会有小伙伴和我同事一样的想法,不管三七二十一,总之全包上就行,总不会出错了吧~

其实并不是这样,我们在使用useCallbackuseMemo的时候其实都会有额外的消耗。

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缓存计算结果。

结语

滥用useCallbackuseMemo可能不会起到性能优化的情况,还是需要酌情考虑,过早的优化也不一定是正确的。


React里面的水太深,小张也把握不住,如果文中有错误,还望大佬们指出讨论。