react性能优化|bailout策略

1,911 阅读15分钟

前面的文章梳理了Fiber架构的render 流程,我们知道 beginWork的目的是为传入的workInprogress fiberNode生成子fiberNode,生成的方式有两种:

  1. 通过对比wip.child(workInprogress简写)对应的current fiberNode和新的reactElement,生成子fiberNode,称为reconcile流程。
  2. 通过bailout策略复用子fiberNode

bailout策略展示在beginWork中的流程如图所示:

在 react 中,引起fiberNode变化的因素包括

  • state
  • props
  • context

流程图中两次判断是否命中bailout都是围绕这三点来的,具体如下:

bailout策略

第一次判断

下图是 react18.2 中关于第一次判断的源码,有四个条件

image.png

条件1:oldProps === newProps

这里的比较是全等比较,也意味着满足这个条件并不容易,我们知道:

  1. 一个对象不全等于另一个对象。
  2. createElement方法每次接收的props参数都是一个新的对象,即使没有props也是一个空对象。
  3. beginWork有两种方式生成子fiberNode,命中bailout复用节点或者通过reconcile生成新节点,reconcile需要子节点新的reactElement,这就需要执行createElement

通过这三点就知道,要想满足这个条件,父fiberNode进入beginWork后必须命中bailout策略去复用子fiberNode,这样在子fiberNodebeginWork中,oldProps全等于newProps 才会成立,子fiberNode才有可能命中bailout策略。

换句话说,在 react 中渲染是具有传染性的,只要父节点没有命中策略,子节点就一定不会命中,孙节点也不会,如此往复。

条件2:Legacy Context没有变化

Legacy Context 是旧的 Context API,有旧的就会有新的,这里简单介绍一下。

⚠️注意,建议先跨过这一小节,看完其他内容再回来看。

在旧的 Context 系统中,上下文数据会被存在栈里:

  1. 在每一个ProviderbeginWork流程中,对应的 Context 都会入栈,在Consumer中就可以通过 Context 栈向上找到对应的上下文。
  2. 在每一个ProvidercompleteWork流程中,对应的 Context 又会出栈。

reconcile和优化程度较低的bailout(即只复用了子fiberNode)中,这个系统没有问题,但如果命中优化程度高的bailout,就会跳过整个子树的beginWorkcompleteWork,Context 出入栈自然会被跳过,子树中如果存在Consumer,就不会响应到更新。react 官网中有对旧 Context 的介绍,这篇文章结尾也指出了这个问题(链接是旧版文档,新版文档可以自行查阅)。

为了解决这个问题,react 团队设计了新的 Context API,原理是这样的:当Provider进入beginWork中,会判断 Context 是否有变化,如果有变化,会立刻向下开启一次深度优先遍历,去寻找Consumer,找到之后,会为Consumer对应的fiberNode.lanes附加renderLanes,然后再从这个Consumer fiberNode向上遍历,依次为祖先fiberNode.childLanes附加renderLanes

image.png

🌟renderLaneslaneschildLanes是和调度有关的内容,这里只需要知道

  • fiberNode.lanes附加renderLanes就代表该fiberNode存在更新。
  • fiberNode.childLanes附加renderLanes就代表该fiberNode的子树中存在更新。

所以,即使Provider命中了bailout策略,在选择优化程度时,子树有更新,就选择低程度的优化,不会跳过整颗子树的beginWork,当然就不会影响子树中Consumer对 Context 更新的响应。

条件3:fiberNode.type没有变化

这是一个非常重要的条件,事实上,一旦这个条件没满足,无论如何也不能命中bailout策略,甚至也就不存在第二次判断了(后文会解释第二次判断)。也很好理解,组件的type都变了,就比如 div 变成了 button,组件当然要重新渲染。

条件4:当前fiberNode没有更新发生

没有更新发生意味着state没有变化,但是有更新发生并不代表state就会变化,判断是否有更新发生是判断fiberNode.lanes属性,该属性和调度有关,这里不细说。 image.png

比如下面的例子:

function Button() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(1)}>测试</button>
}

按钮点击时,Button的fiberNode就有更新发生,但是每次更新的都是1,state就没有变化。

选择优化程度

当以上条件都满足时,第一次判断就命中了bailout策略,会执行bailoutOnAlreadyFinishedWork方法,选择优化程度。

image.png

  • 😄高程度:整颗子树没有更新时(判断fiberNode.childLanes)选择,可以想到如果这颗子树很庞大,那么性能优化的效果是显著的。
  • 😊低程度:子树中存在更新,只复用子fiberNode,看方法名cloneChildFibers可以猜到,复用的方式就是基于当前子节点的current fiberNode克隆出wip fiberNode,这里就优化了这个子节点的reconcile流程。

第二次判断

第一次判断没有命中,会根据fiberNode.tag走不同逻辑,其中部分类型节点还有第二次判断,有两种命中的可能

使用了性能优化API

函数组件的memo和类组件的PureComponentshouldComponentUpdate

在第一次判断时,props通过全等方式比较,只要调了reactElement,newProps 就是一个新对象,即使是属性都相同也不全等,如果使用浅比较的方式,命中概率会高很多。

如果给函数组件使用memofiberNode.tag就会是SimpleMemoComponentMemoComponent,这取决于是否给组件设置了比较函数(默认是shallowEqual),设置了就是MemoComponent

const Child = memo(() => <div>Child</div>);
Child.displayName = 'Child';
Child.compare = (p, c) => p === c;

这里以MemoComponent为例看一下,会走updateMemoComponent

image.png

如果fiberNode没有更新发生,通过比较函数props也没变,ref也没变,就命中bailout,否则就去创建新的fiberNode

类组件这里就不细说了,只要shouldComponentUpdate返回false,就满足类似于函数组件props没变的效果。

有更新,但是state没变化

这条路径算是 react 中的一个边界情况,先来看一个例子

function Button2() {
  const [count, setCount] = useState(0)

  function handleClick(){
    setCount(1)
    setCount(0)
  }
  
  return <button onClick={handleClick}>测试</button>
}

Button 组件点击后有更新发生,但是state没改变,尽管有一次更新改变了,但是最终 state 是没改变的,这涉及到 react 批量更新的特性。

使用过 react 的同学肯定会认为这不会引起render,因为 state 都没变。

确实不会render,但看一下第一次判断的条件4,是判断有没有更新发生,并不是判断 state 有没有改变,所以这里 Button 组件第一次判断是不会命中bailout的,那为什么不会render呢?🤔

其实在 react 中有一个全局变量didReceiveUpdate,一些类型的fiberNode即使在第一次没命中并且没有使用性能优化API时,在beginWork时候还会根据didReceiveUpdate来决定命不命中bailoutdidReceiveUpdate===false就会命中:

image.png

在更新发生时,会判断 state 有没有改变,如果有改变,didReceiveUpdate就会被赋值为true,从而不会命中bailout,反之则不会被赋值为true,就会命中。

但要注意,didReceiveUpdate是一个全局变量,很多地方都有赋值操作,并不代表某组件的更新没有让 state 改变didReceiveUpdate就一定会是false,只是这个机制可以解释上面 Button 组件的例子不render

eagerState策略

细心的同学可能发现上面的例子我刻意调了两次setState,这是因为只有一次更新并且state还不改变,走的就是另外一个逻辑:

function Button2() {
  const [count, setCount] = useState(0)

  function handleClick(){
    setCount(0)
  }
  
  return <button onClick={handleClick}>测试</button>
}

按钮点击后当然还是不会render,但此时不render的原因和上面的例子不一样了,这是另一个策略,称为eagerState策略:如果当前fiberNode不存在待执行的更新,某个状态更新前后没有变化,可以跳过后续更新流程。

具体解释:有一个前提条件是不存在待执行的更新,意味着此时的更新是第一个更新,并且不会被其他更新所影响,所以这次更新可以提前到schedule阶段之前执行,如果state 没有改变,则不会进入schedule阶段,schedule是调度render任务的,自然也就不会有render发生。 image.png 这个策略我个人认为没有太大的学习价值,因为一般我们不会写出示例中的代码。

当第二次判断成功命中bailout,接下来和第一次判断命中一样,执行bailoutOnAlreadyFinishedWork方法选择优化程度。

bailout规则流程总结

优化代码示例

直接看代码

import React, { useState } from 'react';
import ReactDOM from 'react-dom/client';

function Example(props) {
  const [num, setNum] = useState(0);
  const handleClick = () => setNum(n => n + 1);

  return (
    <>
      <button onClick={handleClick}>
        {num}
      </button>
      <Child />
    </>
  );
}

const Child = () => {
  return (
    <ul>
      <li />
      {/* 一个长列表 */}
    </ul>
  );
};

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<Example />);

在这个例子中,Child 组件没有使用到state,没有传入props,它甚至只是一个静态组件。我们期望 Example 组件的 state 改变不会使它渲染,但事实上Child 仍然会渲染,我们来用上面的知识分析一下原因:

  1. hostRootFiber 进入beginWork,第一次判断是否命中bailout策略,四个条件都满足,命中,子树有更新发生,低程度优化,复用子fiberNode(Example 对应的fiberNode)。
  2. Example 进入beginWork,第一次判断,有更新发生,条件4不满足,未命中,没有使用性能优化API,状态发生改变,走recondile流程(会生成新的reactElement)。
  3. button 进入beginWork,第一次判断,newProps !== oldProps,条件1就不满足,未命中,HostComponent类型,不存在第二次判断,走recondile流程。
  4. button 进入completeWork
  5. Child 进入beginWork,第一次判断,newProps !== oldProps,条件1就不满足,未命中,没有使用性能优化API,走recondile流程。
  6. 接下来的长列表也是一样,不会命中,一直走reconcile

我们使用 react 开发者工具可以看到这个结果

image.png

如果 Child 内包含了非常多节点,这样的渲染流程肯定会对性能造成影响。为了命中bailout策略,有两种改法

优化组件结构

import React, { useState } from 'react';
import ReactDOM from 'react-dom/client';

function Example(props) {
  return (
    <>
      <Button />
      <Child />
    </>
  );
}

const Button = () => {
  const [num, setNum] = useState(0);
  const handleClick = () => setNum(n => n + 1);
  return (
    <button onClick={handleClick}>
      {num}
    </button>
  );
};

const Child = () => {
  return (
    <ul>
      <li />
      {/* 一个长列表 */}
    </ul>
  );
};

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<Example />);

把 state 的定义和引起 state 改变的内容封装为组件,Example 组件只是应用这个组件。现在再来分析一下:

  1. hostRootFiber 进入beginWork,第一次判断是否命中bailout策略,四个条件都满足,命中,子树有更新发生,低程度优化,复用子fiberNode(Example 对应的fiberNode)。
  2. Example 进入beginWork,第一次判断,四个条件都满足,命中,子树有更新发生,低程度优化,复用子fiberNode([Button, Child])。
  3. Button 进入beginWork,第一次判断,有更新发生,条件4不满足,未命中,未使用性能优化API,状态发生变化,未命中,走reconcile流程。
  4. button 进入beginWork,第一次判断,newProps!==oldProps,未命中,走reconcile
  5. button 进入completeWork
  6. Button 进入completeWork
  7. Child 进入 beginWork,第一次判断,四个条件都满足,子树没有更新,高程度优化,整个子树跳过beginWork阶段。
  8. Child 进入completeWork

通过分析我们知道,只有Button组件内部走了完整了reconcile流程,其他阶段都命中了bailout,Child 组件甚至是高程度优化,显著提升了性能。

再来看一下开发者工具

image.png

可以看到只有 Button 组件渲染了,其他组件都是置灰状态,也就表示没有渲染。

让我们来看一下具体是怎么优化的组件结构,我们可以分析出来优化前的代码,主要是因为 Example 组件走了reconcil流程,使用了新的reactElement,所以每一个子节点的 props 都变成了新的对象(即使是空对象),所以也就无法命中bailout策略,前面也说了,在 react 中渲染是具有传染性的。那我们可以想办法让 Example 组件命中bailout策略,所以把引起改变的部分抽离成组件,这个方法用一句话概括就是:变的部分和不变的部分分离。

使用性能优化API

或者可以使用一种比较简单的方式,直接使用memo

import React, { useState } from 'react';
import ReactDOM from 'react-dom/client';

function Example(props) {
  const [num, setNum] = useState(0);
  const handleClick = () => setNum(n => n + 1);

  return (
    <>
      <button onClick={handleClick}>
        {num}
      </button>
      <Child />
    </>
  );
}

const Child = memo(() => {
  return (
    <ul>
      <li />
      {/* 一个长列表 */}
    </ul>
  );
});
Child.displayName = 'Child'

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<Example />);

我们看一下优化前的流程分析的第五点,因为使用了memo,所以需要改一下:

Child 进入beginWork,第一次判断,newProps !== oldProps,条件1就不满足,未命中,使用性能优化API,经过比较发现props没有变化,命中,子树没有更新发生,跳过整颗子树的beginWork

开发者工具的效果和第一种优化方法一致。

可以看到这种方式比较简单,可以降低开发者的心智负担,但要论最极致的优化方式,还是第一种更高,因为任何一个性能优化API都有其本身的优化开销。

实际上在硬件设备越来越好的今天,这种性能优化本身的性能开销可以忽略不计,但需要知道的是, memo往往要配合 useMemo、useCallback 等一起使用,否则大多数情况下没有意义,除非都是写死的基本类型。

对开发的启示

下面针对这部分内容,我列一些针对于开发中的启示,遵循的原则基本只有一条:尽量避免不必要的渲染。

1. 注意组件结构

根据变的部分和不变的部分分离这个原则来尽可能的优化组件结构,详细内容上面已经阐述,这里来看一个不一样的例子

import React, { useState } from 'react';
import ReactDOM from 'react-dom/client';

function Example(props) {
  const [num, setNum] = useState(0);
  const handleClick = () => setNum(n => n + 1);

  return (
    <div onClick={handleClick}>
      {num}
       <Child />
    </div>
  );
}

const Child = () => {
  return (
    <ul>
      <li />
      {/* 一个长列表 */}
    </ul>
  );
};

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<Example />);

这里会引起改变的 div 和 Child 组件看起来不容易分离,其实可以使用 children 属性

import React, { useState } from 'react';
import ReactDOM from 'react-dom/client';

function Example(props) {
  return (
    <Div>
       <Child />
    </Div>
  );
}

function Div({children}) {
  const [num, setNum] = useState(0);
  const handleClick = () => setNum(n => n + 1);

  return (
    <div onClick={handleClick}>
      {num}
      {children}
    </div>
  )
}

const Child = () => {
  return (
    <ul>
      <li />
      {/* 一个长列表 */}
    </ul>
  );
};

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<Example />);

实际开发总是复杂的,demo总归是过于理想化,对于确实不好优化的结构可以选择在性能较差的子树的根节点使用性能优化API,然后在子树内部仍然优先选择优化组件结构的方式。

2. 不要定义不必要的state

  1. 可以通过已有 state 计算出的状态,就不要再重新定义一个新的 state
  2. 某些不需要引起组件更新的状态,考虑使用 ref 来替代

虽然说 react 有批量更新的机制,但是我认为避免定义可以推导出的 state 本身就是一种更好的编码习惯。

3. 避免在组件内定义组件

会造成fiberNode.type发生改变,命中不了bailout策略

function App(props) {
  const Child = () => <h1>Child</h1>
  return (
    <>
       <Child />
    </>
  );
}

那如果使用useMemo缓存呢?也是有问题的,看代码

function App() {
  const [visible, setVisible] = useState(false);

  const Child = useMemo(() => {
    return () => 111;
  }, []);
  Child.displayName = 'Child';

  return (
    <>
      <button onClick={() => setVisible(v => !v)}>测试</button>
      <Child1 />
    </>
  );
}

引入useMemo可以解决fiberNode.type改变的问题,但在此例中, App 发生更新后,由于 Child 的 props 会变化,Child 依然不会命中bailout。 而且如果把useMemo改成memo也是不行的,因为memo又解决不了fiberNode.type改变的问题,这个时候可以通过如下代码解决

function App() {
  const [visible, setVisible] = useState(false);

  const Child = useMemo(() => {
    return memo(() => 111);
  }, []);
  Child.displayName = 'Child';

  return (
    <>
      <button onClick={() => setVisible(v => !v)}>测试</button>
      <Child1 />
    </>
  );
}

useMemo+memo就可以让 Child 命中bailout策略,但是这样的代码太不优雅了,完全可以把 Child 放在组件外面定义,省去useMemo

即使抛开性能问题,也不应该嵌套定义组件,下面看一个典型错误:

function App() {
  const [status, setStatus] = useState(false);
  function Input() {
    const [text, setText] = useState('');
  
    return (
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
    );
  }
  return (
    <>
      <Input />
      <button onClick={() => setCounter(s => !s) }>提交</button>
    </>
  );
}

image.png 我们实现的是输入内容并点击提交,在此例中当点击提交后,由于 App 组件发生更新重新渲染,会重新生成 Input 函数,文本框的内容就会被清空。 因为fiberNode.type发生变化,这相当于是在同一个地方渲染不同的组件,react 会重置函数组件内部所有的状态。

总而言之,应该总是避免嵌套定义组件。

4. 真的需要响应式API吗

reduxmobx或者其周边库给我们提供了一些响应式的API,比如mobx的autorun、observer,redux的useSelector、connect。 我在开发中见过一些组件在并不真的需要的情况下仍然使用这些API,组件体量不大的时候可以不管,但是当子树的性能开销比较大的时候可能就要注意了。

5. 状态管理库没有意义吗

有些人认为状态管理库并不是很有必要,因为 react 的useStateuseReducerContext就已经是一套很好用的状态管理方案了。

这里不谈论状态管理库在其他方面的优势,仅关注性能因素。

通过上面的内容我们知道全等比较或者Object.is对于性能有一定的影响,而判断 Context 是否变化使用的是Object.is,所以使用到 Context 内容的组件,不论属性是不是真的改变了,都会被视为 Context 改变了,所以就不会命中bailout策略,因为“传染性”,也会影响到它的子树。

而大多数的状态管理库实现的API实现了更好的比较方式,性能会优于 Context。

6. 学会使用 react 开发者工具

开发者工具能很直观的让我们看到组件的渲染信息。尤其是Profiler。