结合400行mini-react代码,图文解说React原理

引言: 在我学习React原理的时候,一上来看的非常全而细节的书/博客(头大),或者是看的教你实现一个简单mini-react(还是一知半解),最终学的痛苦又效果不好。所以,写了这篇博客,希望能帮助您入门React原理。此外,在我看来,这篇文章帮助你应付面试完全足够了。

说明:

  1. 本文章主要围绕Zachary Lee的 400行实现mini-react 项目进行分析,结合图文详细分析,带你弄懂React原理。
  2. 这个项目的一些API命名会和React有些出入,我会尽量对齐这些命名概念,同时本项目为了减少代码量会弱化很多细节,我根据实际React的实现做补充。
  3. 本文很多图都出自7kms大佬的 图解React,对理解React非常有帮助,强烈推荐大家学习。(P.S. 7kms大佬是基于React17进行分析的,有些地方(比如EffectList)和最新的React 18/19是有出入的,所以另外再推荐一本书:卡颂的《React设计原理》)

通过本文你能收获什么:

  1. 理解并实现Zachary Lee的mini-react。
  2. 更深刻理解React的原理,包括渲染流程、Diff算法、bailout策略和hooks等。

1、React基本概念

a.常用对象:ReactElement,FiberNode,虚拟DOM

1.JSX编译:

我们都知道React支持JSX语法,类似html标签的写法,如下:

jsx 复制代码
<div id="root">
  <div>
	  <h1>hello</h1>
	  world
  </div>
</div>

那么实际上它会被转换为JS代码来创建ReactElement,每一个标签和文本都可以视为ReactElement

当我们import React from 'react'时就引入了React.createElementReact.render这些API,然后代码会被babel编译如下:

ts 复制代码
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,可认为是同一个东西)

ts 复制代码
// 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;
}

简单来看,VirtualElementReactElement)他包含了

  • type :实际React中type指ClassComponent/FunctionComponent/HostComponent(div/span/a这些原生标签)/HostText/HostRoot(FiberTree根节点)等,本项目代码的type做了简化,并把ClassComponentFunctionComponent定义在一起了成ComponentFunction,然后按REACT_COMPONENT来区分。
  • props :就是使用React时传入的props,包括children.

P.S.下文我提到ReactElement,你可以认为就是VirtualElement

3.介绍FiberNode:

ts 复制代码
// 真实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

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

c.宏观理解React 运行原理

(图片来自图解React

1.这个workLoop就是一个函数,会被反复执行的一个函数,在React渲染流程中「render阶段」和「commit阶段」会做不同的事情。

2.当一次渲染任务开始(由renderRootSyncrenderRootConcurrent触发):

  • 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这个函数

ts 复制代码
void (function main() { 
  window.requestIdleCallback(workLoop);
})();

workLoop内,通过deadline.timeRemaining()判断剩余时间来决定是否继续执行「任务单元」。 一个任务单元(unitOfWork)是一个执行时间比较短的JS任务,一次requestIdleCallback给的空闲时间内一般能执行多个「任务单元」,即使超过时间了也影响不大,因为单个「任务单元」很短。这就是时间分片。

ts 复制代码
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有三种发起主动更新的方式:

  1. Class组件中调用setState.
  2. Function组件中调用hook对象暴露出的dispatchAction(就是setState).
  3. container节点上重复调用render

更新渲染初次挂载的区别:

  1. 有一个预处理的步骤------markUpdateLaneFromFiberToRoot(fiber, lane),标记优先级,等进入「render」阶段DFS时通过bailout策略可能跳过一些子树的协调。
  2. 更新渲染的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并入childLanesmarkUpdateLaneFromFiberToRoot结束后再安排一个调度任务(就是进入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工作量)。如何命中:

  1. oldProps全等于newProps(但对于Memo纯组件,条件会变宽松,只需要浅比较oldProps和newprops)
  2. legacy context不变(对于新版Context,只要所处的context的value变化就意味着更新,那么就不会命中bailout)
  3. 没有更新操作 (哪怕状态不变,但只要做了更新动作,比如state:{}->setState({})也不会命中bailout)
  4. FiberNode.type不变

这里必须贴一下React的源码帮我们理解这个逻辑:

ts 复制代码
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后的优化程度。

ts 复制代码
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的顺序和「初次渲染」流程一样,不过不同的是:

  1. 做新旧Fiber的比较,打flags
  2. 会遇到bailout策略,可能跳过子树协调/Diff。

回溯执行completeWork的顺序和「初次渲染」流程也一样,不同的是:

  1. 收集子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,一些细节就没画出来,重点关注subtreeFlagsdeletions数组(希望我的画功没让你失望~)

这个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,用于优化)。

ts 复制代码
// 执行「任务单元」,处理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=PlacementlastPlaceIndex不动。
  • 如果用当前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=xkey=y找不到oldFiber,然后标记为Placement(表示新增)。

Diff算法思维导图
结合项目代码分析

这个项目代码,没有考虑key的设计(简化了),主要考虑type的比较,也没有区分开「单节点比较」和「多节点比较」,仅仅是简单进行了「多节点比较」的粗略版比较。我们来一起看看吧,重点关注其中是如何打副作用标签的(项目里是effectTag字段,实际React中是flags字段)。

ts 复制代码
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节点,需要执行BeforeMutationMutationLayout阶段。

  • BeforeMutation:执行ClassComponent的getSnapshotBeforeUpdate方法,异步调用flushPassiveEffects(和useEffect有关).
  • Mutation:插入/移动、更新属性或删除DOM。
  • Layout:执行componentDidMount/UpdateuseLayoutEffect钩子。

3.等上述的同步代码执行完成后,我们看到的页面就更新了! 到这里我相信你也理解了为什么 useLayout能获取到更新的dom并且能在页面绘制前操作dom了。

  • 这里有个有意思的问题:大家普遍的理解是react(v18版本后)的setState是异步的。那么为什么useLayoutEffect中setState可以在页面绘制前完成状态更新呢?
  • 解释:如果 layout effect 里有 setState,React 立即标记这是一个 同步更新(SyncLane) ;并且立刻重新 render + commit
ts 复制代码
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的更新。

ts 复制代码
// 根据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代码多一点,单独写成函数,如下:

ts 复制代码
// 更新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;
    }
  }
};
ts 复制代码
// 基于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。

  1. 状态hooks: useState, useReducer, (广义上还有)useContext, useRef, useCallback, useMemo
  2. 副作用hooks: useEffect, useLayoutEffect

a.状态hook

1.下面我们通过一个Fiber节点来看看hooks是如何工作的。 以Fiber(App)节点为例,对应的JSX代码如下:

tsx 复制代码
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节点(链表节点)上的queuememoizedState

  • 当我们调用setState方法,实际会生成一个Update对象放入queue队列,如下图所示。
  • 当到了「Render阶段」处理对应新Fiber时,会从旧Fiber把hooks链表copy一份过来,然后一个个执行hook,执行currentHook时会从queue队列中遍历所有Update计算出最终的状态,这个状态是放在新Fiber的currentHookmemoizedState
  • 这个memoizedState就是 const [state, setState] = useState()中的state。
结合项目代码分析

本项目中的变量命名和数据结构有些差异,下面我先说明"映射"关系。(本项目->实际React):

  • Fiber的hook数组 -> Fiber的memoizedState链表
  • hook节点的state -> hook节点的memoizedState
  • hook节点的queue数组 -> hook节点的queue链表
  • 全局变量hookIndex -> 全局变量currentHook指针

本项目代码如下:

ts 复制代码
// 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对象定义

ts 复制代码
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.destroyeffect.create()

3.此时,你应该明白了如果useXxEffect没有正确依赖,会导致Effect回调不会被触发。

4.这部分,本项目没有代码哦~

5、总结

学数学我们讲究数形结合,那么学框架原理,我们也要「码形结合」,这个"码"就是指代码,本文很好的码形结合解释了React原理。

渲染阶段-思维导图
更新流程-思维导图

Diff算法-思维导图

参考

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

相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60616 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了6 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅7 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment7 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼8 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax