react 更新流程
基本概念
- update: 在 react 中通过类组件 setState 或者函数组件的 state hook 的 setState 会产生一个 update。update 挂载到 fiber 的 updateQueue 或者其 hook 的 updateQueue 上。
- task: 产生 update 后 react 生成一个(或复用已存在的) task。在一个 task 中主要涉及到以下工作
- 遍历 fiber 树,计算组件最新状态,并 diff 组件收集副作用。这个过程在 react 中也称为协调(Reconciliation)
- 根据步骤 1 中的 diff 结果更改真实 dom,调用各种生命周期函数。这个过程在 react 中也称为提交(Commit)
- 副作用: 在 react 中数据改变引起的 dom 改变,以及一些回调函数(componentDidMount, useEffect 等)在 react 中会称作副作用。副作用会在进行协调阶段保存在 fiber.flags 上,在 commit 阶段根据副作用的处理时机进行处理。
- 双缓冲: 在 react 进行 fiber 树的生成时会采用双缓冲技术。即在内存中最多存在两颗树: current 以及 WIP(WorkInProgress)。current 表示当前展现在屏幕上的 fiber 树,而 wip 树表示发生更新后计算出来的最新的 fiber 树。首次渲染时只存在 WIP 树,而后续更新时同时存在 current 树以及 WIP 树。当更新完成之后,wip 树会变为 current 树。current 树以及 wip 树中的节点通过 alternate 互相引用。例如:
current.alternate = wip
wip.alternate = current
- 优先级: 在 react 中每个 update 以及每次协调阶段都会涉及到各种优先级。这些优先级决定了某些fiber 是否需要跳过本次更新,或者某些 update 是否需要跳过本次更新。每个优先级的作用请参考:从更新流程看 react 中的各种优先级
fiber 树
fiber 树结构
react 会使用 fiber 在内存结构中表示用户编写的组件,一般来说一个组件就有一个对应的 fiber。根据组件的父/子/兄弟关系,fiber 也会形成对应的关系。但是 fiber 树除了用户编写的组件形成的 fiber 外还有一些 react 内部的节点,例如经常提到的 FiberRoot 以及 RootFiber。这里用一张图来表示 FiberRoot 以及 RootFiber 和挂载 DOM 节点 DOMRoot的关系:
其中 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 会不断的访问子节点,其停止条件为:
- 访问到的节点不存在子节点
- 访问到的节点只有一个纯文本节点,例如
<input>+1</input>,访问到 input 对应的 fiber 就会结束本次 beginWork - 该 fiber 本次不需要更新,其所有的子 fibre 也不需要更新,则会提前结束 beginWork阶段。在 react 中这种情况也称为
bailout。
当访问到每一个 fiber 节点时,可能存 3 情况:
- 该节点需要更新
- 该节点不需要更新,某个子节点需要更新
- 该节点不需要更新,所有的子节点都不需要更新
情况1
该情况下需要根据 updateQUeue 需要计算最新的 state。并生成最新的 ReactElement 子节点,利用 ReactElement 子节点与 current 的子节点进行 diff。收集副作用挂载到 fiber.flags。
情况2
该情况不会计算该节点的 state,并且会直接拷贝 current 的 children 作为当前节点的子节点。
情况3
该情况会提前结束 beginWork 节点,其所有的子节点都不会被遍历。
completeWork
当遍历到某个节点时 beginWork 结束,会从该节点开始进行 completeWork 阶段。completeWork 阶段结束条件如下:
- 当前节点存在兄弟节点
- 当前节点不存在兄弟节点,但是其祖先节点存在兄弟节点
- 当前节点不存在兄弟节点,也不存在父节点
其中情况 1,2 会以得到的兄弟节点为起点,开始新一轮的 beginWork 阶段。情况 3 表示该 fiber 树已经遍历完成,整个协调阶段也会结束。
在 completeWork 阶段所遍历到的节点也会有一些操作:
- 对于所有类型的 fiber 都会将其副作用合并到其父节点的
subtreeFlags上(18.0.0 之后已经没有 effectList),便于 commit 阶段可以跳过一些子树 - 对于 DOM 类型的 fiber 还会进行属性的 diff,获得本次需要修改的属性,挂载到组件的 updateQueue 上。另外,新 DOM 的创建以及将子 DOM 的插入到当前节点也是发生在 completeWork 阶段。
首次渲染与更新
无论时首次渲染还是更新,react 都会遍历 fiber 树但是其中还是存在一定的差异:
- 首次渲染会创建一些内部对象,例如 FiberRoot 以及 RootFiber
- 遍历 fiber 树时一般会通过 current === null 来判断是否是首次渲染,如果是首次渲染:
- 不会跳过 fiber 节点
- 对于类组件的 fiber,会创建类组件实例并挂载
- 对于函数式组件,首次渲染调用的 hook useXXX 与更新阶段调用的 useXXX 并不是同一个函数
- 无论是首次渲染还是更新阶段 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>,
);
最开始式界面如下:
我们可以通过点击 添加 content 来增加一条内存。此时对应的 fiber 树如下:
首次渲染
由于所有 diff 工作都会基于 wip 树,因此此时 react 会新建一个 wip FiberRoot 并从该节点开始 fiber 树遍历:
遍历到 RootFiber 节点,react 会将 直接挂载到 RootFiber 下:
由于是首次渲染,会直接进入到计算最新状态生成子节点的过程。此时 <App/> 子节点不为空,继续 beginWork 的过程:
同理,直到遍历 <Head> 下的 <span/> 节点,由于其只有一个文本节点。因此会不断的向上寻找,直到找到其祖先节点 <Head/> 的兄弟节点 <Content/>。
由于此时 <Content/> 没有子节点,所以在完成 beginWork节点后会进入到 completeWork 节点。下一个节点为其兄弟节点<input/>
同理 <Input/> 节点没有子节点,因此下一个遍历的节点为其兄弟节点 <button/>, 由于 <button/> 只有一个纯文本节点,因此继续进入 completeWork 节点。而 <button/> 节点已经没有具有兄弟节点的祖先节点,因此不断向上寻找后就会结束整个过程:
当然,构造完成之后,当前的 wip RootFiber 就会成为 current 挂载到 FiberRoot 上:
对比更新
-
对比更新同样从 RootFiber 开始,该节点没有 props 或者状态更新,但其子节点有状态更新。因此需要复制子节点fiber,继续 beginWork。
-
<App/>节点本身有状态更新,因此需要生成子节点与 current 进行对比,发现类型相同,因此会按照 current 的子节点创建一个新 fiber,新建的 fiber 只在 props 上与 current 的子 fiber 不同。 -
此时遍历到
<div/>,我们会发现:- div 是 dom 组件,不存在状态,因此状态没有更新
<App/>并没有传递 props 给 div,因此 props 也不存在更新- 应用中没有使用 context,因此同样不存在更新
乍一看这 3 个条件命中了我们上面提到的
bailout逻辑,需要提前结束 beginWork。那么如果此时结束 beginWork,div 的所有子节点都不会进行 diff。本次更新的目的是需要在<Content/>中插入一个新节点,因此此时 bailout 明显会导致不正确的结果。而事实上条件 2 并不满足,因为 props 中不但存放了父节点传递给子节点的属性,还有一个特殊的属性 children 存放了所有子节点的 ReactElement。由于每一轮渲染时调用React.createElement得到的都是新的ReactElement。因此对于<div>来说,props 中的 children 属性进行了改变。注意这里说的子节点是嵌套在 jsx 中的子节点,而不是某个组件返回的子节点。因此需要 diff 子节点并继续 beginWork。 -
此时对于
<Head/>节点:- div 是 dom 组件,不存在状态,因此状态没有更新
<div/>并没有传递 props 给 div,也没有嵌套在 jsx 中的子节点。因此 props 也不存在更新。- 应用中没有使用 context,因此同样不存在更新
因此 Head 会命中 bailout 逻辑,提前进入 completeWork。
-
对于
<Content/>节点由于有新的 props 产生,因此需要 diff 子节点,对比 current 的<Content/>的子节点,此时需要新增一个<span/>子节点并继续 beginWork。 -
此时 span 只有一个文本节点,因此结束 beginWork。后续的逻辑除了 diff 以外与首次更新基本一致,这里不再赘述。