react 更新流程

1,310 阅读8分钟

react 更新流程

基本概念

  1. update: 在 react 中通过类组件 setState 或者函数组件的 state hook 的 setState 会产生一个 update。update 挂载到 fiber 的 updateQueue 或者其 hook 的 updateQueue 上。
  2. task: 产生 update 后 react 生成一个(或复用已存在的) task。在一个 task 中主要涉及到以下工作
    1. 遍历 fiber 树,计算组件最新状态,并 diff 组件收集副作用。这个过程在 react 中也称为协调(Reconciliation)
    2. 根据步骤 1 中的 diff 结果更改真实 dom,调用各种生命周期函数。这个过程在 react 中也称为提交(Commit)
  3. 副作用: 在 react 中数据改变引起的 dom 改变,以及一些回调函数(componentDidMount, useEffect 等)在 react 中会称作副作用。副作用会在进行协调阶段保存在 fiber.flags 上,在 commit 阶段根据副作用的处理时机进行处理。
  4. 双缓冲: 在 react 进行 fiber 树的生成时会采用双缓冲技术。即在内存中最多存在两颗树: current 以及 WIP(WorkInProgress)。current 表示当前展现在屏幕上的 fiber 树,而 wip 树表示发生更新后计算出来的最新的 fiber 树。首次渲染时只存在 WIP 树,而后续更新时同时存在 current 树以及 WIP 树。当更新完成之后,wip 树会变为 current 树。current 树以及 wip 树中的节点通过 alternate 互相引用。例如:
current.alternate = wip
wip.alternate = current
  1. 优先级: 在 react 中每个 update 以及每次协调阶段都会涉及到各种优先级。这些优先级决定了某些fiber 是否需要跳过本次更新,或者某些 update 是否需要跳过本次更新。每个优先级的作用请参考:从更新流程看 react 中的各种优先级

fiber 树

fiber 树结构

react 会使用 fiber 在内存结构中表示用户编写的组件,一般来说一个组件就有一个对应的 fiber。根据组件的父/子/兄弟关系,fiber 也会形成对应的关系。但是 fiber 树除了用户编写的组件形成的 fiber 外还有一些 react 内部的节点,例如经常提到的 FiberRoot 以及 RootFiber。这里用一张图来表示 FiberRoot 以及 RootFiber 和挂载 DOM 节点 DOMRoot的关系:

Untitled Diagram.drawio (1).png

其中 FiberRoot 是整颗 Fiber 树的根节点,其 current 属性指向 RootFiber 节点。RootFiber 实际就是一个 Fiber,但它并不是来自用户编写的组件,因此为了区分一般的 Fiber 所以约定俗成称为 RootFiber。一般来说,RootFiber 的子节点就是用户编写的 <App/> 产生的 fiber。RootFiber 的 stateNode 属性会指向 FiberRoot,FiberRoot 以及 RootFiber 通过自己的属性引用对方:

               current
FiberRoot ----------------------> RootFiber
FiberRoot <---------------------- RootFiber
               stateNode

react 还会在 DOMRoot 上增加一个 __reactContainer$xxx 的属性指向 RootFiber。该属性并不是固定的,而是以 __reactContainer$ + 随机数的方式生成,在代码中为:

const randomKey = Math.random()
  .toString(36)
  .slice(2);
const internalContainerInstanceKey = '__reactContainer$' + randomKey; // 该变量就是实际的属性名

DOMRoot 还会通过 containerInfo 挂载到 FiberRoot 上。

fiber 树的遍历流程

一旦任务开始执行,react 会以RootFiber为起点进行深度优先遍历来创建或者 diff fiber 树。在fiebr 书中 fiber 和 fiber 之间通过以下几个属性连接:

  • child:该 fiber 的第一个子节点
  • sibling:该 fiber 的第一个兄弟节点
  • return:该 fiber 的父节点

遍历 fiber 时会存在 beginWork, completeWork 两个阶段交替进行:

beginWork

一次 beginWork 会不断的访问子节点,其停止条件为:

  1. 访问到的节点不存在子节点
  2. 访问到的节点只有一个纯文本节点,例如<input>+1</input>,访问到 input 对应的 fiber 就会结束本次 beginWork
  3. 该 fiber 本次不需要更新,其所有的子 fibre 也不需要更新,则会提前结束 beginWork阶段。在 react 中这种情况也称为 bailout

当访问到每一个 fiber 节点时,可能存 3 情况:

  1. 该节点需要更新
  2. 该节点不需要更新,某个子节点需要更新
  3. 该节点不需要更新,所有的子节点都不需要更新

情况1

该情况下需要根据 updateQUeue 需要计算最新的 state。并生成最新的 ReactElement 子节点,利用 ReactElement 子节点与 current 的子节点进行 diff。收集副作用挂载到 fiber.flags。

情况2

该情况不会计算该节点的 state,并且会直接拷贝 current 的 children 作为当前节点的子节点。

情况3

该情况会提前结束 beginWork 节点,其所有的子节点都不会被遍历。

completeWork

当遍历到某个节点时 beginWork 结束,会从该节点开始进行 completeWork 阶段。completeWork 阶段结束条件如下:

  1. 当前节点存在兄弟节点
  2. 当前节点不存在兄弟节点,但是其祖先节点存在兄弟节点
  3. 当前节点不存在兄弟节点,也不存在父节点

其中情况 1,2 会以得到的兄弟节点为起点,开始新一轮的 beginWork 阶段。情况 3 表示该 fiber 树已经遍历完成,整个协调阶段也会结束。

在 completeWork 阶段所遍历到的节点也会有一些操作:

  1. 对于所有类型的 fiber 都会将其副作用合并到其父节点的 subtreeFlags 上(18.0.0 之后已经没有 effectList),便于 commit 阶段可以跳过一些子树
  2. 对于 DOM 类型的 fiber 还会进行属性的 diff,获得本次需要修改的属性,挂载到组件的 updateQueue 上。另外,新 DOM 的创建以及将子 DOM 的插入到当前节点也是发生在 completeWork 阶段。

首次渲染与更新

无论时首次渲染还是更新,react 都会遍历 fiber 树但是其中还是存在一定的差异:

  1. 首次渲染会创建一些内部对象,例如 FiberRoot 以及 RootFiber
  2. 遍历 fiber 树时一般会通过 current === null 来判断是否是首次渲染,如果是首次渲染:
    1. 不会跳过 fiber 节点
    2. 对于类组件的 fiber,会创建类组件实例并挂载
    3. 对于函数式组件,首次渲染调用的 hook useXXX 与更新阶段调用的 useXXX 并不是同一个函数
    4. 无论是首次渲染还是更新阶段 diff 都会发生。不过首次渲染的 diff 过程相对更简单,只需要直接创建新 fiber 即可

实例

我们已一个简单的 TodoList 应用来说明更新的过程:

export default class Head extends React.Component{
    render(){
        return (
            <h1>{this.props.title}</h1>
        )
    }
}

export default class Content extends React.Component{
    render(){
        return (
            <ul>
                {
                    this.props.notes.map(note => <li key={note}>{note}</li>)
                }
            </ul>
        )
    }
}

export default class TodoList extends React.Component{
    constructor(props){
        super(props)
        this.state = {value: '', notes: []}
    }
    render(){
        return (
            <div>
                <Head title="Todo"/>
                <Content notes={this.state.notes}/>
                <input value={this.state.value} onChange={e => this.setState({value: e.target.value})}/>
                <button onClick={e => {
                    if(this.state.value && this.state.value !== ''){
                        this.setState({notes: [...this.state.notes, this.state.value]})
                    }
                }}>添加 content</button>
            </div>
        )
    }
}

function App() {
  return <TodoList/>
}

const rootNode = ReactDOM.createRoot(document.getElementById('root'));
rootNode.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);

最开始式界面如下:

image.png

我们可以通过点击 添加 content 来增加一条内存。此时对应的 fiber 树如下:

image.png

首次渲染

由于所有 diff 工作都会基于 wip 树,因此此时 react 会新建一个 wip FiberRoot 并从该节点开始 fiber 树遍历:

image.png

遍历到 RootFiber 节点,react 会将 直接挂载到 RootFiber 下:

image.png

由于是首次渲染,会直接进入到计算最新状态生成子节点的过程。此时 <App/> 子节点不为空,继续 beginWork 的过程:

image.png

同理,直到遍历 <Head> 下的 <span/> 节点,由于其只有一个文本节点。因此会不断的向上寻找,直到找到其祖先节点 <Head/> 的兄弟节点 <Content/>。 由于此时 <Content/> 没有子节点,所以在完成 beginWork节点后会进入到 completeWork 节点。下一个节点为其兄弟节点<input/>

同理 <Input/> 节点没有子节点,因此下一个遍历的节点为其兄弟节点 <button/>, 由于 <button/> 只有一个纯文本节点,因此继续进入 completeWork 节点。而 <button/> 节点已经没有具有兄弟节点的祖先节点,因此不断向上寻找后就会结束整个过程:

image.png

当然,构造完成之后,当前的 wip RootFiber 就会成为 current 挂载到 FiberRoot 上:

image.png

对比更新

  1. 对比更新同样从 RootFiber 开始,该节点没有 props 或者状态更新,但其子节点有状态更新。因此需要复制子节点fiber,继续 beginWork。

  2. <App/> 节点本身有状态更新,因此需要生成子节点与 current 进行对比,发现类型相同,因此会按照 current 的子节点创建一个新 fiber,新建的 fiber 只在 props 上与 current 的子 fiber 不同。

  3. 此时遍历到 <div/>,我们会发现:

    1. div 是 dom 组件,不存在状态,因此状态没有更新
    2. <App/> 并没有传递 props 给 div,因此 props 也不存在更新
    3. 应用中没有使用 context,因此同样不存在更新

    乍一看这 3 个条件命中了我们上面提到的 bailout 逻辑,需要提前结束 beginWork。那么如果此时结束 beginWork,div 的所有子节点都不会进行 diff。本次更新的目的是需要在 <Content/> 中插入一个新节点,因此此时 bailout 明显会导致不正确的结果。而事实上条件 2 并不满足,因为 props 中不但存放了父节点传递给子节点的属性,还有一个特殊的属性 children 存放了所有子节点的 ReactElement。由于每一轮渲染时调用 React.createElement 得到的都是新的 ReactElement。因此对于 <div> 来说,props 中的 children 属性进行了改变。注意这里说的子节点是嵌套在 jsx 中的子节点,而不是某个组件返回的子节点。因此需要 diff 子节点并继续 beginWork。

  4. 此时对于 <Head/> 节点:

    1. div 是 dom 组件,不存在状态,因此状态没有更新
    2. <div/> 并没有传递 props 给 div,也没有嵌套在 jsx 中的子节点。因此 props 也不存在更新。
    3. 应用中没有使用 context,因此同样不存在更新

    因此 Head 会命中 bailout 逻辑,提前进入 completeWork。

  5. 对于 <Content/> 节点由于有新的 props 产生,因此需要 diff 子节点,对比 current 的 <Content/> 的子节点,此时需要新增一个 <span/> 子节点并继续 beginWork。

  6. 此时 span 只有一个文本节点,因此结束 beginWork。后续的逻辑除了 diff 以外与首次更新基本一致,这里不再赘述。