引言:
在我学习React原理的时候,一上来看的非常全而细节的书/博客(头大),或者是看的教你实现一个简单mini-react(还是一知半解),最终学的痛苦又效果不好。所以,写了这篇博客,希望能帮助您入门React原理。此外,在我看来,这篇文章帮助你应付面试完全足够了。
说明:
- 本文章主要围绕Zachary Lee的 400行实现mini-react 项目进行分析,结合图文详细分析,带你弄懂React原理。
- 这个项目的一些API命名会和React有些出入,我会尽量对齐这些命名概念,同时本项目为了减少代码量会弱化很多细节,我根据实际React的实现做补充。
- 本文很多图都出自7kms大佬的 图解React,对理解React非常有帮助,强烈推荐大家学习。(P.S. 7kms大佬是基于React17进行分析的,有些地方(比如EffectList)和最新的React 18/19是有出入的,所以另外再推荐一本书:卡颂的《React设计原理》)
通过本文你能收获什么:
- 理解并实现Zachary Lee的mini-react。
- 更深刻理解React的原理,包括渲染流程、Diff算法、bailout策略和hooks等。
1、React基本概念
a.常用对象:ReactElement,FiberNode,虚拟DOM
1.JSX编译:
我们都知道React支持JSX语法,类似html标签的写法,如下:
<div id="root">
<div>
<h1>hello</h1>
world
</div>
</div>
那么实际上它会被转换为JS代码来创建ReactElement,每一个标签和文本都可以视为ReactElement。
当我们import React from 'react'时就引入了React.createElement和React.render这些API,然后代码会被babel编译如下:
React.render(React.createElement('div', {},
React.createElement('h1', {}, 'hello'),
'world'),
document.getElementById('root'));
P.S.为什么早期react项目要引入import React from 'react'这句话,就是因为编译后需要引入React.createElement。
2.介绍ReactElement:
通过React.createElement创建出来的一个普通JS对象就是ReactElement类型(在本项目代码中使用的VirtualElement,可认为是同一个东西)
// Class Component组件和Function组件组合定义
interface ComponentFunction {
new (props: Record<string, unknown>): Component; //能new出Component实例
(props: Record<string, unknown>): VirtualElement | string; //直接调用返回虚拟DOM VirtualElement
}
type VirtualElementType = ComponentFunction | string;
interface VirtualElementProps {
children?: VirtualElement[];
[propName: string]: unknown;
}
interface VirtualElement {
type: VirtualElementType;
props: VirtualElementProps;
}
// 判断是否是VirtualElement(即ReactElement)
const isVirtualElement = (e: unknown): e is VirtualElement =>
typeof e === 'object';
// Text elements require special handling.
const createTextElement = (text: string): VirtualElement => ({
type: 'TEXT',
props: {
nodeValue: text,
},
});
// 创建一个VirtualElement(即ReactElement)
const createElement = (
type: VirtualElementType,
props: Record<string, unknown> = {},
...child: (unknown | VirtualElement)[]
): VirtualElement => {
const children = child.map((c) =>
isVirtualElement(c) ? c : createTextElement(String(c)),
);
return {
type,
props: {
...props,
children,
},
};
};
// Component组件定义
//(class MyComp extends Component, 自定义class组件都要继承这个Component)
abstract class Component {
props: Record<string, unknown>;
abstract state: unknown;
abstract setState: (value: unknown) => void;
abstract render: () => VirtualElement;
constructor(props: Record<string, unknown>) {
this.props = props;
}
// Identify Component.
static REACT_COMPONENT = true;
}
简单来看,VirtualElement(ReactElement)他包含了
-
type:实际React中type指
ClassComponent/FunctionComponent/HostComponent(div/span/a这些原生标签)/HostText/HostRoot(FiberTree根节点)等,本项目代码的type做了简化,并把ClassComponent和FunctionComponent定义在一起了成ComponentFunction,然后按REACT_COMPONENT来区分。
-
props:就是使用React时传入的props,包括
children.
P.S.下文我提到ReactElement,你可以认为就是VirtualElement
3.介绍FiberNode:
// 真实DOM
type FiberNodeDOM = Element | Text | null | undefined;
// 定义FiberNode(Fiber节点)
interface FiberNode<S = any> extends VirtualElement {
alternate: FiberNode<S> | null; //指向当前Fiber节点的旧版本,用于Diff算法比较
dom?: FiberNodeDOM; //指向真实DOM节点
effectTag?: string; //用于标记Fiber节点的副作用,如添加、删除、更新等,实际react中是flags
child?: FiberNode; //指向第一个孩子Fiber节点
return?: FiberNode; //指向父Fiber节点
sibling?: FiberNode; //指向兄弟Fiber节点
hooks?: { //hooks数组(实际React是hooks链表)
state: S;
queue: S[];
}[];
}
- 一定程度上你可以认为ReactElement是虚拟DOM, 也可以认为FiberNode是虚拟DOM。FiberNode是在ReactElement基础上进一步封装,补充描述了状态、dom、副作用标记、节点关系等等。
-
alternate:我们在下面的双缓存-离屏FiberTree的概念中进一步说明作用,它指向对应的old FiberNode。
-
effectTag:(实际React是flags)在下面的「渲染流程」会进一步分析,它标记了这个Fiber是否存在副作用要执行。
-
dom:真实DOM。(实际React的FiberNode上有stateNode属性 👈 与该 Fiber 对应的“实例”或 DOM 节点,本项目代码这里简单用dom替代了。)
-
hooks:用来表示组件的hooks状态。(实际React中Fiber用 memoizedState 属性表示,这个属性用来是用来保存组件的局部状态的。memoizedState对于FunctionComponent来说是一个hooks链表,对于ClassComponent则是普通对象{})
下表展示了stateNode对应的内容:
Fiber tag
|
stateNode 内容 |
示例 |
HostComponent |
对应的 DOM 节点
|
`` → stateNode 指向 HTMLDivElement
|
ClassComponent |
对应的 类组件实例
|
new MyComponent() |
FunctionComponent |
null |
因为函数组件没有实例 |
HostRoot |
对应的 root 容器实例
|
ReactRoot(如 ReactDOM.createRoot(container)) |
HostText |
对应的 文本节点
|
TextNode |
下图展示了ReactElement和FiberTree分别在内存中的样子:
(图片来自图解React)

b.双缓存-离屏FiberTree
(图片来自图解React)

- React中会同时存在两棵FiberTree,如图,中间的是内存中的FiberTree,也叫
离屏FiberTree,是通过workInProgress指针(简称wip)进行移动来构建的;右边的是页面FiberTree,代表实际页面(表示不会再发生变化,对应实际页面DOM结构)。
- 为什么要两棵呢?
页面FiberTree代表旧Fibers, 离屏FiberTree代表新Fibers,需要根据ReactElement结构来创建新Fibers。创建过程中需要比较(Diff)新旧FiberTree进行「打标签」来表示需要做哪些dom更新操作。 当我遍历离屏FiberTree时,通过alternate指针找到旧Fiber,然后对它们的孩子节点进行Diff。
-
FiberRoot是React应用的辅助节点,用来管理FiberTree。它的current指针指向的那个FiberTree代表页面FiberTree。当内存中的FiberTree构建完成后,FiberRoot.current切换到内存的FiberTree,表示新旧页面切换,完成更新。
-
HostRootFiber就是FiberTree的根。挂载/更新都是从HostRootFiber开始DFS的。
c.宏观理解React 运行原理
(图片来自图解React)

1.这个workLoop就是一个函数,会被反复执行的一个函数,在React渲染流程中「render阶段」和「commit阶段」会做不同的事情。
2.当一次渲染任务开始(由renderRootSync或renderRootConcurrent触发):
- render阶段,从上到下DFS,递归时调用
beginWork函数,回溯时调用completeWork。这阶段核心工作是创建Fiber节点和打副作用标签。(副作用的简单理解:修改实际DOM就是副作用)
- commit阶段,从上到下DFS,根据Fiber上的副作用标签(
flags)和父Fiber上的deletions标记进行实际DOM操作:新增/移动、修改和删除。
3.如何让workLoop反复不断执行呢?,本项目代码使用了requestIdleCallback(React很早期也是这个)来调用workLoop,当浏览器空闲时,就会分配一个时间片给workLoop执行。
4.当requestIdleCallback存在下面的问题:
-
不可预测、触发频率太低。页面在保持高帧率的时候(如动画、滚动)时,浏览器几乎没有空闲时间,导致 requestIdleCallback 回调迟迟不能执行。
- 优先级机制太弱。只提供了一个 “空闲时执行” 的概念,没有优先级控制。
- 这个API的兼容性
由于上述原因,React 自己实现了任务调度的算法(Scheduler):
-
MessageChannel(微任务方式,能精确控制调用时机);
-
setTimeout(作为后备机制);
- 可控的时间切片(每个任务执行 5ms 左右后中断);
- 多级优先级(Immediate、UserBlocking、Normal、Low、Idle);
workLoop
当引入min-react时( import React from './mini-react';),就开始workLoop了,就开始工作循环了,按调度不断执行workLoop这个函数
void (function main() {
window.requestIdleCallback(workLoop);
})();
在workLoop内,通过deadline.timeRemaining()判断剩余时间来决定是否继续执行「任务单元」。 一个任务单元(unitOfWork)是一个执行时间比较短的JS任务,一次requestIdleCallback给的空闲时间内一般能执行多个「任务单元」,即使超过时间了也影响不大,因为单个「任务单元」很短。这就是时间分片。
const workLoop: IdleRequestCallback = (deadline) => {
while (nextUnitOfWork && deadline.timeRemaining() > 1) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
window.requestIdleCallback(workLoop);
};
2、performUnitOfWork对应 「render阶段」
「render阶段」会从离屏FiberTree的根节点(HostRootFiber)开始向下DFS,当然这种DFS是基于迭代方式的(而不是递归),这样才能做到前面提到的时间分配,一个个执行短的「任务单元」。
这个阶段的主要任务就是Diff比较和对Fiber打flags标签。每次执行performUnitOfWork(wip:FiberNode)就是处理一个Fiber节点(这里的wip指 离屏FiberTree中的工作指针workInProgress,代表当前要处理的Fiber)一开始wip指向HostRootFiber。
在实际React中,「render」阶段的流程可以分成两种流程来看,一种是初次渲染流程,另一种是更新渲染流程。
a.初次渲染流程
如下面这组图片所示,此时页面FiberTree是空的,会根据一步步根据render函数产生的ReactElement来构建离屏FiberTree。
(下图1,2,3,4 来自 图解React)




1.向下递归beginWork的流程:
- 图1、此时
wip指向HostRootFiber,执行performUnitOfWork(wip)时会调用updateHostRoot,拿HostRootFiber的children和HostRootFiber.alternate的children进行Diff比较,然后创建内存中的Fiber(App)(HostRootFiber的children只有一个,就是React.render(App, container)时传入的App)。最后wip指向子Fibers的第一个,即Fiber(App);
- 图2、此时
wip指向Fiber(App),执行App组件的render方法,产生ReactElement('div')(这个就是App的children),执行performUnitOfWork(wip)时会那App的children和App.alternate的children进行Diff比较,然后创建内存中的Fiber(div)。最后wip指向子Fibers的第一个,即Fiber(div);
- 图3、此时
wip指向Fiber(div),执行performUnitOfWork(wip)时拿Fiber(div)的children和Fiber(div).alternate的children进行Diff比较(初次渲染实际上不会比较,更新渲染才会真正的比较),然创建内存中的Fiber(header)和Fiber(Content)。最后wip指向子Fibers的第一个,即Fiber(header);
2.向上回溯completeWork的流程:
- 构建dom节点,挂在Fiber上,同时把子dom节点通过
appendChild关联上(注意:这个添加dom节点是从下往上的,所以此时回溯构建的真实DOM并没有真的挂载到Document)
当挂载完成后,再经过「commit阶段」后就变成下面这样,此时原本离屏的FiberTree变成页面FiberTree,而原来的页面的FiberTree被清理成一棵空树。

b.更新渲染流程
React有三种发起主动更新的方式:
-
Class组件中调用setState.
-
Function组件中调用hook对象暴露出的dispatchAction(就是setState).
- 在
container节点上重复调用render
更新渲染和初次挂载的区别:
- 有一个预处理的步骤——
markUpdateLaneFromFiberToRoot(fiber, lane),标记优先级,等进入「render」阶段DFS时通过bailout策略可能跳过一些子树的协调。
- 更新渲染的
beginWork是需要打副作用标签flags的,completeWork是需要收集子Fiber的flags到父Fiber.subtreeFlags的,这是为了下一个阶段「commit阶段」准备的,这样在commit时DFS可以跳过一些子树。
markUpdateLaneFromFiberToRoot
初次挂载是React.render触发,直接从根节点向下DFS了。而更新渲染流程中则有所不同,有一个预处理的步骤——markUpdateLaneFromFiberToRoot(fiber, lane),名字记不记无所谓,关键理解这一步做了什么。

如上图所示,在App组件内发生更新,先执行markUpdateLaneFromFiberToRoot动作:给Fiber(App)设置lanes,然后不断回溯,父Fiber会收集所有子Fiber的lanes并入childLanes。markUpdateLaneFromFiberToRoot结束后再安排一个调度任务(就是进入workLoop),等待执行。
-
childLanes在渲染流程的「render阶段」的优化起到作用。在命中优化条件情况下,如果Fiber的childLanes不包含了当前更新优先级,将跳过Fiber和它的整个子树的协调/Diff(这个将在下面的bailout优化策略中介绍)
- 注意:
childLanes是挂在old Fiber上的,在比较new FiberTree和old FiberTree后,发现某个Fiber命中优化才会去检查对应old Fiber的childLanes。
bailout策略
bailout策略 讲的是如何在beginWork中比较新旧Fiber的优化问题(命中bailout策略能减少render工作量)。如何命中:
-
oldProps全等于newProps(但对于Memo纯组件,条件会变宽松,只需要浅比较oldProps和newprops)
-
legacy context不变(对于新版Context,只要所处的context的value变化就意味着更新,那么就不会命中bailout)
-
没有更新操作(哪怕状态不变,但只要做了更新动作,比如
state:{}->setState({})也不会命中bailout)
- FiberNode.type不变
这里必须贴一下React的源码帮我们理解这个逻辑:
function updateMemoComponent(wip: FiberNode, renderLane: Lane) {
// bailout四要素
// props浅比较
const current = wip.alternate;
const nextProps = wip.pendingProps;
const Component = wip.type.type;
if (current !== null) {
const prevProps = current.memoizedProps;
// state context
if (!checkScheduledUpdateOrContext(current, renderLane)) {
// 浅比较props
if (shallowEqual(prevProps, nextProps) && current.ref === wip.ref) {
didReceiveUpdate = false;
wip.pendingProps = prevProps;
// 满足四要素
wip.lanes = current.lanes;
return bailoutOnAlreadyFinishedWork(wip, renderLane);
}
}
}
return updateFunctionComponent(wip, Component, renderLane);
}
当执行bailoutOnAlreadyFinishedWork时,你就会发现childLanes起作用了,childLanes决定了命中bailout后的优化程度。
function bailoutOnAlreadyFinishedWork(wip: FiberNode, renderLane: Lane) {
// 高程度优化,跳过子树协调
if (!includeSomeLanes(wip.childLanes, renderLane)) {
return null;
}
//低程度优化,复用Fiber,继续子树协调
cloneChildFibers(wip);
return wip.child;
}
(图片来自react性能优化|bailout策略)

- 如果
childLanes数组中包含本次更新优先级,则复用Fiber,继续子树的DFS/协调。
- 如果不包含,则跳过子树DFS/协调。
完整更新流程
递归打flags,回溯收集flags
递归执行beginWork的顺序和「初次渲染」流程一样,不过不同的是:
- 做新旧Fiber的比较,打
flags
- 会遇到
bailout策略,可能跳过子树协调/Diff。
回溯执行completeWork的顺序和「初次渲染」流程也一样,不同的是:
- 收集子Fiber
flags,并入父Fiber的subtreeFlags
beginWork:
(下图1,2,3,4 来自 图解React)




其实这组图已经很好说明了流程,我就不每一步说明了。重点关注:当wip指向Fiber(Header)时:
-
<Header>组件是PureComponent,满足四要素(props浅比较相同,没有更新,没有context变化,type没变),命中bailout
- 然后
Fiber(Header)上childLanes没有包含本次更新优先级,所以高程度优化,直接跳过了子树的比较,返回的wip就指向了兄弟节点Fiber(button)。
completeWork:
7kms大佬的绘的图关于completeWork是使用EffectList(React v18已经不用了)收集副作用,下面是7kms大佬的图:

然后我自己画了个图,表示收集subtreeFlags,一些细节就没画出来,重点关注subtreeFlags和deletions数组(希望我的画功没让你失望~)

这个subtreeFlags是收集子flags和子subtreeFlags合并得来的,在React中实际是一个二进制的数,但为了理解理论,这里你可以把它当做是一个数组好了。
- 当
wip指向Fiber(div),遍历所有子Fiber,收集有 subtreeFlags=[Placement, Deletion],继续冒泡。
- 当
wip指向Fiber(App),遍历所有子Fiber,因为Fiber(App),Fiber(button)没有flags和subtreeFlags,只有Fiber(div)有subtreeFlags,故收集有subtreeFlags=[Placement, Deletion],继续冒泡。
- 当
wip指向Fiber(HostRootFiber),收集有subtreeFlags=[Placement, Deletion]。
结合项目代码分析
项目代码的performUnitOfWork比较简化,没有明显区分初次挂载时和更新时两个流程,都按更新流程来写的,同时省略了completeWork回溯时该做的事(这个不影响功能,回溯收集subtreeFlags是为了跳过一些子Fiber的Diff,用于优化)。
// 执行「任务单元」,处理fiberNode(React中会把wip传给fiberNode)
// 比较wip的props和oldFiberNode的props,记录差异到effectTag
const performUnitOfWork = (fiberNode: FiberNode): FiberNode | null => {
const { type } = fiberNode;
switch (typeof type) {
// 1.处理函数组件和类组件
case 'function': {
wipFiber = fiberNode;
wipFiber.hooks = [];
hookIndex = 0;
let children: ReturnType<ComponentFunction>;
// 区分函数组件和类组件(实际React中是分成ClassComponent和FunctionComponent种类型)
// 这里通过REACT_COMPONENT(Component上的静态变量来区分)
if (Object.getPrototypeOf(type).REACT_COMPONENT) {
const C = type;
const component = new C(fiberNode.props);
const [state, setState] = useState(component.state);
component.props = fiberNode.props;
component.state = state;
component.setState = setState;
children = component.render.bind(component)(); //对于类组件,调用render方法获取children
} else {
children = type(fiberNode.props); //对于函数组件,直接调用函数组件并传入props获取children
}
reconcileChildren(fiberNode, [
isVirtualElement(children)
? children
: createTextElement(String(children)),
]);
break;
}
// 2.处理文本节点和Fragment
case 'number':
case 'string':
if (!fiberNode.dom) {
fiberNode.dom = createDOM(fiberNode);
}
reconcileChildren(fiberNode, fiberNode.props.children);
break;
case 'symbol':
if (type === Fragment) {
reconcileChildren(fiberNode, fiberNode.props.children);
}
break;
default:
if (typeof fiberNode.props !== 'undefined') {
reconcileChildren(fiberNode, fiberNode.props.children);
}
break;
}
// 处理完成当前节点(wip)后,返回下一个要处理的节点(nextUnitOfWork)
// 找下一个待处理节点nextUnitOfWork,遵循DFS的顺序
if (fiberNode.child) {
return fiberNode.child;
}
let nextFiberNode: FiberNode | undefined = fiberNode;
while (typeof nextFiberNode !== 'undefined') {
if (nextFiberNode.sibling) {
return nextFiberNode.sibling;
}
nextFiberNode = nextFiberNode.return;
}
return null; //null表示节点处理完毕
};
render阶段又叫reconcile阶段,原因就是因为这个阶段的核心在于reconcileChildren函数,即Diff过程。下面就开始介绍React的Diff算法。
c.调和/Diff算法
Diff算法原理介绍
在React中Fiber节点的比较只做同层级的比较,按FiberTree从上到下的顺序一级级比较。分为单节点和多节点比较。
- 单节点比较(ReactElement序列只有1个或0个)
- 多节点比较(ReactElement序列大于1个,构成数组)
单节点比较

多节点比较
初始的(新Fibers)ReactElement序列和 oldFibers序列如下:

第一次循环先遍历公共序列,即新旧Fiber是一一对应,key和type相同,一旦key或type不同就中断循环。前面这段公共序列的oldFibers是可以复用的(即复用dom)

第二次循环从第一次断开的地方开始。
1) 先设置一个lastPlaceIndex的索引,为什么叫lastPlaceIndex,因为它和最终的dom移动(是否打上Placement副作用标签相关)。 初始设置lastPlaceIndex=0 。
2) 把oldFibers剩余的节点放入Map,方便后续通过key找oldFiber。即Fiber(C),Fiber(D),Fiber(E)会被放入Map。
3) 遍历ReactElement序列的剩余序列
- 如果用当前ReactElement key能在Map中找到oldFiber,就复用Fiber(复用dom),oldFiber从Map中移除。
- 复用后,还要比较
lastPlaceIndex和oldFiber的index,如果index比lastPlaceIndex大or相等则只需要更新lastPlaceIndex=index,否则仅标记该新Fiber flags=Placement,lastPlaceIndex不动。
- 如果用当前ReactElement找不到oldFiber,则标记
flags=Placement (此时表示的是新增)
关于3)的第2点,以下图为例说明: key=e找到Fiber(E)(oldFiber E的索引为4),此时lastPlaceIndex是0,只需更新lastPlaceIndex=4;: key=c找到Fiber(C)(oldFiber C的索引为2),此时对新Fiber C标记Placement(表示移动)lastPlaceIndex不变。
关于3)的第3点,就是 key=x和key=y找不到oldFiber,然后标记为Placement(表示新增)。

Diff算法思维导图

结合项目代码分析
这个项目代码,没有考虑key的设计(简化了),主要考虑type的比较,也没有区分开「单节点比较」和「多节点比较」,仅仅是简单进行了「多节点比较」的粗略版比较。我们来一起看看吧,重点关注其中是如何打副作用标签的(项目里是effectTag字段,实际React中是flags字段)。
const reconcileChildren = (
fiberNode: FiberNode,
elements: VirtualElement[] = [],
) => {
let index = 0;
let oldFiberNode: FiberNode | undefined = void 0;
let prevSibling: FiberNode | undefined = void 0;
const virtualElements = elements.flat(Infinity);
//这里的fiberNode是内存中FiberTree的「父Fiber」 alternate指针指向它对应的old FiberNode(即离屏FiberTree上的)
if (fiberNode.alternate?.child) {
oldFiberNode = fiberNode.alternate.child; // oldFiberNode表示了oldFibers序列
}
while (
index < virtualElements.length ||
typeof oldFiberNode !== 'undefined'
) {
// ReactElement通过index自增移动来获取,而oldFiberNode通过sibling移动来获取
const virtualElement = virtualElements[index];
let newFiber: FiberNode | undefined = void 0;
const isSameType = Boolean(
oldFiberNode &&
virtualElement &&
oldFiberNode.type === virtualElement.type,
);
// 1.type相同,标记为UPDATE(复用真实dom,仅修改dom的属性)
if (isSameType && oldFiberNode) {
newFiber = {
type: oldFiberNode.type,
dom: oldFiberNode.dom,
alternate: oldFiberNode,
props: virtualElement.props,
return: fiberNode,
effectTag: 'UPDATE',
};
}
// 2.type不同并且有新的ReactElement,标记为REPLACEMENT,表示新增或移动dom
// 其实这里可分为两种情况:1)oldFiberNode不存在——新增,2)oldFiberNode存在但type不同——移动
if (!isSameType && Boolean(virtualElement)) {
newFiber = {
type: virtualElement.type,
dom: null,
alternate: null,
props: virtualElement.props,
return: fiberNode,
effectTag: 'REPLACEMENT',
};
}
// 3.type不同并且oldFiberNode存在(隐藏条件:ReactElement不存在),父FiberNode标记为DELETION,表示删除dom
// 除了标记为DELETION,还会把oldFiber放到deletions数组中,用于后续commitRoot时删除dom (实际React中这个deletions是挂在父fiberNode上的)
if (!isSameType && oldFiberNode) {
deletions.push(oldFiberNode);
}
if (oldFiberNode) { // oldFiberNode通过sibling移动来获取
oldFiberNode = oldFiberNode.sibling;
}
// 构建新的fiber树(即内存中的Fiber Tree)
if (index === 0) {
fiberNode.child = newFiber;
} else if (typeof prevSibling !== 'undefined') {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index += 1; // ReactElement通过index自增移动来获取
}
};
3、commitRoot对应 「commit阶段」
1.笼统的说,「commit阶段」要做两件事:
- 负责DOM节点的插入/移动、更新属性和删除,注意这里说的是DOM,是对真实DOM操作。
- 执行副作用,
useEffect/useLayoutEffect的 destory和create函数。
2.「commit阶段」的流程如下图所示
(图来自《React设计原理》)
这个“判断是否有副作用”指判断subtreeFlags是否有标记(不是noFlags)。有副作用标记,则DFS时对于每个Fiber节点,需要执行BeforeMutation、Mutation和Layout阶段。
-
BeforeMutation:执行ClassComponent的getSnapshotBeforeUpdate方法,异步调用flushPassiveEffects(和useEffect有关).
-
Mutation:插入/移动、更新属性或删除DOM。
-
Layout:执行componentDidMount/Update或useLayoutEffect钩子。
3.等上述的同步代码执行完成后,我们看到的页面就更新了! 到这里我相信你也理解了为什么 useLayout能获取到更新的dom并且能在页面绘制前操作dom了。
- 这里有个有意思的问题:大家普遍的理解是react(v18版本后)的setState是异步的。那么为什么
useLayoutEffect中setState可以在页面绘制前完成状态更新呢?
- 解释:如果 layout effect 里有
setState,React 立即标记这是一个 同步更新(SyncLane);并且立刻重新 render + commit;
useLayoutEffect(() => {
setXxState(); // 引起的更新优先级是同步的,优先级最高,更新在页面绘制前执行。
}, []);
其实这里会涉及到一个经典面试题“setState是同步还是异步?”,这个问题留到下一篇博客(React八股和场景题)讨论吧,欢迎大家关注和订阅专栏(doge)。
4.这里你是不是好奇useEffect的执行时机又是怎么样的呢? 别急,后面的「hooks原理」小节会讲到这个问题。
5.补充:
Fiber早期架构(v16)中还没有subtreeFlags,使用的是Effects List,如下图,HostRootFiber中保存了effectsList,通过遍历这个链表来更新Fiber,就不用重新DFS整棵Fiber Tree了。

最新的Fiber架构,则采用了subtreeFlags(v17过渡版本就开始有这个字段了,需要开启并发模式才会用到),大概原因是为了Suspense这个API的一些特性,采用了收集flags的方式。这样就需要DFS 整棵Fiber Tree,通过subtreeFlags判断是否需要继续向下DFS。
结合项目代码分析
本项目代码没有考虑副作用的处理了,重点关注DOM的更新。
// 根据Fiber节点的effectTag更新真实DOM
// 在commitRoot之前,已经完成了所有Fiber节点的比较
// 之前的Fiber比较流程是可以中断的,但commitRoot不能中断
const commitRoot = () => {
// 找到带dom的父Fiber
const findParentFiber = (fiberNode?: FiberNode) => {
if (fiberNode) {
let parentFiber = fiberNode.return;
while (parentFiber && !parentFiber.dom) {
parentFiber = parentFiber.return;
}
return parentFiber;
}
return null;
};
const commitDeletion = (
parentDOM: FiberNodeDOM,
DOM: NonNullable<FiberNodeDOM>,
) => {
if (isDef(parentDOM)) {
parentDOM.removeChild(DOM);
}
};
const commitReplacement = (
parentDOM: FiberNodeDOM,
DOM: NonNullable<FiberNodeDOM>,
) => {
if (isDef(parentDOM)) {
parentDOM.appendChild(DOM);
}
};
const commitWork = (fiberNode?: FiberNode) => {
if (fiberNode) {
if (fiberNode.dom) {
const parentFiber = findParentFiber(fiberNode);
const parentDOM = parentFiber?.dom;
//根据副作用标签,更新真实DOM(注意:这里的effectTag和实际React的flags有差异,表示方式不同罢了)
switch (fiberNode.effectTag) {
case 'REPLACEMENT':
commitReplacement(parentDOM, fiberNode.dom);
break;
case 'UPDATE':
updateDOM(
fiberNode.dom,
fiberNode.alternate ? fiberNode.alternate.props : {},
fiberNode.props,
);
break;
default:
break;
}
}
//递归,先第一个孩子,再处理兄弟节点
commitWork(fiberNode.child);
commitWork(fiberNode.sibling);
}
};
// 这里处理了所有的删除工作。(实际React中是在commitWork(fiber)DFS时,遍历父节点的deletions数组做删除的)
for (const deletion of deletions) {
if (deletion.dom) {
const parentFiber = findParentFiber(deletion);
commitDeletion(parentFiber?.dom, deletion.dom);
}
}
// 执行插入/移动、更新工作。
if (wipRoot !== null) {
commitWork(wipRoot.child);
currentRoot = wipRoot;
}
wipRoot = null;
};
更新DOM和创建DOM代码多一点,单独写成函数,如下:
// 更新DOM(属性)
// 简单起见,这里是删除之前所有属性,添加新属性
const updateDOM = (
DOM: NonNullable<FiberNodeDOM>,
prevProps: VirtualElementProps,
nextProps: VirtualElementProps,
) => {
const defaultPropKeys = 'children';
for (const [removePropKey, removePropValue] of Object.entries(prevProps)) {
if (removePropKey.startsWith('on')) {
DOM.removeEventListener(
removePropKey.slice(2).toLowerCase(),
removePropValue as EventListener,
);
} else if (removePropKey !== defaultPropKeys) {
// @ts-expect-error: Unreachable code error
DOM[removePropKey] = '';
}
}
for (const [addPropKey, addPropValue] of Object.entries(nextProps)) {
if (addPropKey.startsWith('on')) {
DOM.addEventListener(
addPropKey.slice(2).toLowerCase(),
addPropValue as EventListener,
);
} else if (addPropKey !== defaultPropKeys) {
// @ts-expect-error: Unreachable code error
DOM[addPropKey] = addPropValue;
}
}
};
// 基于Fiber的type创建 dom
const createDOM = (fiberNode: FiberNode): FiberNodeDOM => {
const { type, props } = fiberNode;
let DOM: FiberNodeDOM = null;
if (type === 'TEXT') {
DOM = document.createTextNode('');
} else if (typeof type === 'string') {
DOM = document.createElement(type);
}
// Update properties based on props after creation.
if (DOM !== null) {
updateDOM(DOM, {}, props);
}
return DOM;
};
4、Hooks原理
React官方将hooks分为两类,一类是状态hooks,另一类是副作用hooks。
- 状态hooks:
useState, useReducer, (广义上还有)useContext, useRef, useCallback, useMemo
- 副作用hooks:
useEffect, useLayoutEffect
a.状态hook
1.下面我们通过一个Fiber节点来看看hooks是如何工作的。
以Fiber(App)节点为例,对应的JSX代码如下:
function App() {
const [count, setCount] = useState(0);
useEffect(()=>{
console.log('didMount')
},[])
const [show, setShow] = useState(true)
useEffect(()=>{
console.log(show, count)
},[show, count])
return (
<div>
<p>You clicked {show ? count : '*'} times</p>
<button onClick={() => setCount(count + 1)}>increase</button>
<button onClick={() => setCount(count - 1)}>decrease</button>
</div>
);
}
Fiber(App)上用了4个hook,那么Fiber的结构如下,Fiber上的属性memoizedState保存了一个链表结构:

注意:你写的hooks顺序和memoizedState保存的顺序是一致的。当App Function的hooks按顺序执行的同时,会通过一个全局变量currentHook移动来指向当前的hook。如果hooks是条件里执行的话,那么hooks链表节点的查找实际是不可预测的,这也是为什么hooks不能条件里执行。
2.下面我们来分析下hook节点(链表节点)上的queue和memoizedState。
- 当我们调用
setState方法,实际会生成一个Update对象放入queue队列,如下图所示。
- 当到了「Render阶段」处理对应新Fiber时,会从旧Fiber把hooks链表copy一份过来,然后一个个执行hook,执行
currentHook时会从queue队列中遍历所有Update计算出最终的状态,这个状态是放在新Fiber的currentHook的memoizedState。
- 这个
memoizedState就是 const [state, setState] = useState()中的state。

结合项目代码分析
本项目中的变量命名和数据结构有些差异,下面我先说明“映射”关系。(本项目->实际React):
- Fiber的
hook数组 -> Fiber的memoizedState链表
- hook节点的
state -> hook节点的memoizedState
- hook节点的
queue数组 -> hook节点的queue链表
- 全局变量
hookIndex -> 全局变量currentHook指针
本项目代码如下:
// hooks: 找到当前hook节点,计算状态。
function useState<S>(initState: S): [S, (value: S) => void] {
const fiberNode: FiberNode<S> = wipFiber;
// 每次更新需要重新构建Fiber, 运行useState时需要从alternate(old Fiber)中获取上一次的hook
const hook: {
state: S;
queue: S[];
} = fiberNode?.alternate?.hooks
// 从hooks数组(实际是React是hooks链表)中获取当前的hook节点! 这解释了为什么要按顺序执行hooks
? fiberNode.alternate.hooks[hookIndex]
: {
state: initState,
queue: [],
};
// 从更新队列(实际React中更新队列是一个链表,这里简化为数组)中取出所有更新,合并到state中
while (hook.queue.length) {
let newState = hook.queue.shift();
if (isPlainObject(hook.state) && isPlainObject(newState)) {
newState = { ...hook.state, ...newState };
}
if (isDef(newState)) {
hook.state = newState; //这就是该hook的新状态,根据这个新状态渲染UI
}
}
if (typeof fiberNode.hooks === 'undefined') {
fiberNode.hooks = [];
}
// 组件内可能多次调用useState,每个useState对应一个hook节点(实际React中就是一个链表节点)
fiberNode.hooks.push(hook);
hookIndex += 1; //使用索引保证能按顺序处理hooks数组
// setState就是一个闭包,里面访问了当前hook节点。
const setState = (value: S) => {
hook.queue.push(value);
if (currentRoot) { //注意这个currentRoot 指向旧Fiber Tree的根节点(即实际React中的FiberRoot.current)
// 创建新Fiber Tree 的 HostRootFiber
wipRoot = {
type: currentRoot.type,
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
// Fiber工作指针有值了意味着新的render任务,requestIdleCallback会调用workLoop时会处理nextUnitOfWork
// 接下来就会进入performUnitOfWork,从根HostRootFiber往下DFS,构建新的Fiber Tree
nextUnitOfWork = wipRoot;
deletions = [];
currentRoot = null;
}
};
return [hook.state, setState];
}
b.副作用hook
我们先记住下面这个Effect对象定义
export type Effect = {
tag: HookFlags, // 副作用hook的类型
create: () => (() => void) | void, //useXxxEffect的创建,即第一个参数。
destroy: (() => void) | void, //useXxxEffect的销毁函数,第一个参数的返回函数
deps: Array<mixed> | null, //useXxxEffect的依赖
next: Effect, //下一个useXxxEffect保存的Effect对象
};
一图胜千言,对于副作用hook而言,hook节点上memoizedState保存的是Effect对象

初次调用
1.如上图所示,初次调用useEffect,会创建Effect对象并形成effects链表。Effect.tag标记了Effect是Layout还是Passive(对于useLayoutEffect的就标记Layout,对于useEffect的就标记Passive)
2.在Commit的「BeforeMutation子阶段」, 异步调用了flushPassiveEffects(宏任务)。在这期间带有Passive标记的effect已经被添加到全局数组中。
接下来flushPassiveEffects就可以脱离fiber节点,遍历全局数组,直接访问effect,先执行effect.destroy,后执行effect.create函数。
3.解释异步调用了flushPassiveEffects:这里的异步调用,是React调度时给了NormalSchedulerPriority优先级,此时flushPassiveEffects被当做一个宏任务来执行。(到这里咱就明白了useEffect和useLayoutEffect的区别:useEffect是一个宏任务,在页面绘制后执行;useLayoutEffect是微任务,在页面绘制前执行)
更新调用

1.当组件更新,就会重新执行useEffect/useLayoutEffect,创建新hook节点,然后对新hook会和旧hook的effect依赖deps比较:
- 如果有依赖项引用变化,创建新Effect并打上
tag |= HasEffect标记
- 如果没有变化,仅创建新Effect(没有
HasEffect标记)
2.新的hook以及新的effect创建完成之后, 余下逻辑与初次渲染完全一致。处理 Effect 回调时也会根据effect.tag进行判断: 只有effect.tag包含HookHasEffect时才会调用effect.destroy和effect.create()。
3.此时,你应该明白了如果useXxEffect没有正确依赖,会导致Effect回调不会被触发。
4.这部分,本项目没有代码哦~
5、总结
学数学我们讲究数形结合,那么学框架原理,我们也要「码形结合」,这个“码”就是指代码,本文很好的码形结合解释了React原理。
渲染阶段-思维导图

更新流程-思维导图


Diff算法-思维导图

参考
mini-react github仓库
图解React
《React设计原理》- big-react github仓库
react性能优化|bailout策略