Hello 大家好, 我是程序员Knight。今天带来的是React源码阅读。希望通过这一篇文章能够帮你对React有一个新的认知,以及帮助到正在阅读源码的你。
在本篇分享中,我将通过对React源码18.2.0的分析来解析:
- 核心架构一览(Scheduler, Reconciler, Renderer)
- 4大核心流程分析:
- Trigger
- Schedule
- Render
- Commit
希望通过阅读本文后,能够掌握其基本原理,并能在进行React编码过程中,在脑海中构造一个Fiber树,并模拟代码运行过程。
核心流程一览(Trigger,Schedule, Render, Commit)
在React中,从更新到展示一共分为4个阶段,分别是:
🎯 触发阶段 Trigger: 在这个阶段中的主要任务为 "通知部分应用需要渲染",在这个阶段,更像是创建一个任务,并在之后将任务交给调度器处理。比如在初始化React应用时,会通过 ReactDOM.render
或者 setState()
来触发渲染。
🎯 调度阶段 Schedule:在这个阶段,React会通过其内部实现的优先队列(Priority Queue)与任务优先级来处理"待处理的任务"。
🎯 渲染阶段 Render:通过 Diff算法 来计算出新的Fiber(WIP)树与当前Fiber树的区别,并将需要处理的更新应用到真实DOM上。
🎯 提交阶段 Commit:处理需要被更新的内容到真实DOM中,并处理所有的Effects(副作用)。
在这几个阶段中,一共三个比较重要的部分发挥着作用,分别是调度器(Scheduler)、协调器(Reconciler)、渲染器(Renderer):
1️⃣ Scheduler 调度器:主要是用来管理更新优先级,调度器(Scheduler)通过优先级调度、任务切片(time slicing)、支持任务中断与恢复达到了高优任务先执行的特性。确保了UI更新更顺畅,避免了长时间任务的堵塞
2️⃣ Reconciler 协调器:主要是用来计算并更新UI组件树,协调器(Reconciler)通过双缓存技术、任务拆分与调度、Diff算法实现了可中断渲染、自动批量更新、并发模式地高效更新UI。
3️⃣ Renderer 渲染器:主要负责将React Fiber树转换为目标环境可展示的UI,渲染器(Renderer)在不同的宿主环境中存在着不同的版本来适应对应的宿主环境,负责在React内部Commit阶段将已经生成的DOM节点挂载到根节点下来展示给用户。
全流程一览:
接下来让我们一起来看React工作的全流程
我们通常在React项目中都会通过这样的语法来初始化React应用:
javascript
import { createRoot } from 'react-dom/client';
import React from 'react';
import App from './App';
const reactRoot = document.querySelector('#app');
const root = createRoot(reactRoot);
root.render(<App />);
在React中,这段代码涉及到两个部分:
createRoot
初始化ReactDOM对象render(<App key={'app'} />);
: 初始化渲染React应用
初始化 createRoot:
初始化一共做了这三件事: 1️⃣:树结构: 初始化React树结构(FiberRootNode 与 HostRootNode),
-
FiberRootNode 用来管理整个
Fiber
树markdownFiberRootNode ├── current (指向当前 UI 的 Fiber 树) ├── workInProgress (指向正在渲染的新 Fiber 树)
-
初始化
HostRootFiber
的更新队列
2️⃣:React 事件代理: React会对传入的DOM元素(id=root)进行事件代理,在内部会对事件集(例如click、 drag、contextMenu等)进行监听(通过addEventListener)。之后在我们使用 onClick
事件时,React并不会真正的把事件绑定到对应的DOM元素上,而是通过上述代理的方式在React内部找到对应的事件回调进行执行与触发。
3️⃣: 初始化ReactDOM并返回,ReactDOM中只有一个 _internalRoot
属性用来记录当前FiberRootNode,其 prototype
上有两个函数分别为 render
函数与unmount
函数
javascript
function ReactDOMRoot(internalRoot) {
this._internalRoot = internalRoot;
}
ReactDOMHydrationRoot.prototype.render = ReactDOMRoot.prototype.render = function (children) {
}
ReactDOMHydrationRoot.prototype.unmount = ReactDOMRoot.prototype.unmount = function () {
}
在初始化完成之后, 我们一起来看React的四大流程
Trigger 阶段
如果是React初次更新时, 由用户主动调用 render
函数来触发,用户需要将渲染的组件传给 render
函数,获取 HostRoot、时间、更新车道(用于调度系统。)、创建更新对象(记录了 payload、callback、next 等信息,用于最后在 render 阶段计算新的 state。)通过scheduleUpdateOnFiber -> ensureRootIsScheduled
触发调度器,并处理过渡更新(startTransition)
如果是React再次更新,通常会调用 dispatchSetState()
(该函数也是React中的useState与useReducer状态更新的核心函数),相同地,Recat也会通过当前Fiber获取对应的更新优先级,并生成Update对象,并将其放入到更新队列中,通过scheduleUpdateOnFiber -> ensureRootIsScheduled
触发调度器,并处理过渡更新(startTransition)
Schedule 阶段
接下来React进入到调度阶段,在这里如果任务为可中断时,则通过调度器来进行任务处理,调度器负责任务的调度、优先级管理、时间切片。
这里一共有两个区分点:
- 如果优先级为
Synclane
: 则通过scheduleMicrotask
微任务 的方式执行performSyncWorkOnRoot
- 其他的话:
- 调用 调度器中的 scheduleCallback
- 调度器会通过 优先级队列 进行任务队列管理和定时任务队列管理。
- 简单来说,每次会把最高优先级的任务拿出来执行。
总结React调度器的实现如下: 调度器通过任务队列(即将到期的任务)与时间队列(处理延迟任务)协同管理任务调度优先级,将任务按照过期时间进行排序,保证高优先级任务快速被取出。并提供了通过时间判断实现了让出JS主线程的能力(在并发更新下的React会通过该方式判断是否需要让出主线程给到用户输入)。以异步的方式处理任务,减少堵塞主线程的可能。
Render 阶段
根据调用方式不同,接下来React会进入到以下两个函数(还未正式标志进入render阶段): performConcurrentWorkOnRoot
- 规则判断:
- 当前运行上下文不能在
renderContext
或commitContext
, 如果存在则报错 - 是否存在
passiveEffects
如果存在并且没有任务时,则直接退出。
- 当前运行上下文不能在
- 调用
getNextLanes
获取当前更新优先级 - 如果当前优先级 既不是
blockingLane
也不是expiredlane
时 调用renderRootConcurrent
否则调用renderRootSync
- 获取render结果:
RootErrored
: 重试机制RootFatalErrored
: 重大错误RootDidNotComplete
: concurrent render其他/RootCompleted
:调用finishConcurrentRender
准备进入commit
阶段
- 调用
ensureRootIsScheduled(root, now());
performSyncWorkOnRoot
-
规则判断
- 当前运行上下文不能在
renderContext
或commitContext
, 如果存在则报错 flushPassiveEffects
- 当前运行上下文不能在
-
调用
getNextLanes
获取当前更新优先级 -
调用
renderRootSync
, 当结束结果为:-
RootErrored
: 重试机制 -
RootFatalErrored
: -
RootDidNotComplete
: 错误 -
RootCompleted
:javascriptvar finishedWork = root.current.alternate; root.finishedWork = finishedWork; root.finishedLanes = lanes; commitRoot(root, workInProgressRootRecoverableErrors, workInProgressTransitions);
-
-
ensureRootIsScheduled(root, now());
当调用到 renderRootConcurrent/renderRootSync
则标志着进入到render阶段中
不论是renderRootConcurrent()/renderRootSync()
, 其大致流程如下:
workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes
: 检查当前正在渲染的 Fiber 树是否已经发生了变化,如果变化了,就可能需要重新开始渲染。如果需要重新渲染,则调用prepareFreshStack(root, lanes);
生成 WIPprepareFreshStack(root, lanes)
:根据当前树,克隆出双缓存中的"正在构建中"的树,
- 接下来则进入到
workLoopSync()
或者workLoopConcurrent()
: 两者的主要不同在于workLoopConcurrent中具有可中断的特性(shouldYield()
),可以在render过程中退出,从而避免过多任务导致主线程堵塞,利用空闲时间处理未完成的Fiber任务。
javascript
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
performUnitOfWork(workInProgress) 从 performUnitOfWork
开始,则开始处理 Diff 过程。
整个函数的遍历流程如下函数:从传参进来的 unitOfWork/全局变量WorkInProgress
从顶向下遍历,
javascript
function performUnitOfWork(unitOfWork) { // unitOfWork === workInProgress
var current = unitOfWork.alternate;
var next;
next = beginWork(current, unitOfWork, subtreeRenderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
整个Render阶段做的事情可以整理为三件事情: 1️⃣ 在初始化时:根据当前React Element生成对应的Fiber树。在更新阶段,根据Props,state或context更新Fiber阶段。 2️⃣ 为Fiber树进行打标(flags
)根据变更类型(如插入、更新、删除)标记 subtreeFlags 和 flags 3️⃣ 在CompleteWork结束后,生成Fiber树对应的真实DOM树
简单的延伸:
- 函数式组件会在
beginWork()
中调用。 - 当React检查没有`props, st
- Reconciler会进行一系列优化来减少时间复杂度:
-
如果当前Fiber, Props 未变化、Context 未变化、组件类型未变化、无待处理的更新或 Context 变更、未处于错误/Suspense 的第二次渲染,跳过当前 Fiber 节点及其子树的Diff过程(即"提前退出"优化)。(beginWork中核心流程)(通过使用memo来包裹函数式组件,能够触发该流程,达到减少开销的目的)
-
React通过唯一key来识别元素,减少不必要的节点操作。我们在列表场景下需要给每个元素使用唯一的key来保证下次render时的元素复用
-
当React进行更新时,识别到不同的元素时,React直接销毁旧的子树并创建新树。
-
Commit 阶段
当Render结束后,会进入到React的Commit阶段,在Commit阶段中,React 任务如下: 1️⃣ DOM更新:按照Render阶段后带处理标签的Fiber树结构变更对真实DOM进行 删除(先处理删除), 插入,属性更新等操作 2️⃣ 生命周期调用与Ref绑定处理: 调用函数式组件以及类组件中的生命周期相关函数。 3️⃣ 收尾:将Fiber树标记为 当前树(current
)
commitRoot(): 该函数是Commit阶段的起点,其函数大致结构如下:
javascript
do {
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);
// ...
// DOM操作前:
commitBeforeMutationEffects(root, finishedWork);
// 执行DOM改变操作
commitMutationEffects(root, finishedWork, lanes);
// DOM 更新后的副作用运行
commitLayoutEffects(finishedWork, root, lanes);
整个流程可以总结为4大阶段:
- 首先通过do-while语句 + flushPassiveEffects() 来处理所有待处理的被动副作用,通过do-while语句可以将重复的副作用在这一次处理完成。
- 在处理完副作用后,则进入到
commitBeforeMutationEffects()
,在这个运行过程中会执行类组建中的getSnapshotBeforeUpdate()
函数。 commitMutationEffects()
的调用则标志着DOM变更的开始。在这个阶段中会将render阶段中已经被打好标签的Fiber应用到真实DOM中。React会遍历Fiber结构中的副作用链表,根据每个Fiber中的flags
执行不同的操作。针对于删除操作,React会进行组件卸载的操作:- 针对类组件,会调用
componentWillUnmount
进行同步处理 - 针对于函数组件且存在
useEffect
则将其副作用的销毁函数标记为代执行(会在下次flushPassiveEffects)时进行执行。 - 针对于Ref,也会进行对应的解绑操作 在这个函数结束则会执行
root.current = finishedWork
的操作(将FiberRoot的current指针切换到新的Fiber树)表示完成了本次更新。
- 针对类组件,会调用
- 执行Layout阶段的副作用,在这个阶段主要是执行需要在DOM变更后才需要的副作用,对于:
- 类组件:
componentDidMount(), componentDidUpdate()
(mount或update)会在这个阶段被执行 - 函数组件:
useLayoutEffect()
会在这个阶段被执行,React会遍历Fiber节点中的Hooks链表,如果链表中存在useLayoutEffect()
则会在这个阶段同步执行。 - Ref:针对于Ref与真实DOM的绑定,也会在这个阶段进行替换。
简单的延伸:
- 在
commitMutationEffects()
运行中,真实DOM已经随之改变。 - useLayoutEffect是在DOM更新完成,浏览器会之前同步执行,如果在回调函数中调用了setState,会立即触发更新,并在当前帧内同步完成新的渲染和提交,确保最终用户看到的是最新状态。useEffect是异步触发的/浏览器绘制后执行,对于不依赖DOM状态的副作用可以放到该回调函数中运行。
- render阶段是可被中断的,但commit阶段是不可中断的。
javascript
// 异步执行:可能导致布局闪烁
useEffect(() => {
const newWidth = divRef?.current.offsetWidth;
setWidth(newWidth); // 立即更新宽度
}, []);
// 同步执行:在绘制前调整布局
useLayoutEffect(() => {
const newWidth = divRef?.current.offsetWidth;
widthRef.current = newWidth
setWidth(newWidth); // 立即更新宽度
}, []);
总结
在本文中,我们主要探讨了React的核心流程。希望对你平时的编程时在脑海中构建React Fiber树有帮助。如果你对我的文章感兴趣, 也麻烦进行点赞、收藏和关注~ React的源码涉及的内容实在过多,一篇文章还是显得过于单薄了。之后也会考虑再次总结一下出一个React源码阅读系列篇。
我是Knight,让我们下次再见~