结合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策略

相关推荐
Mintopia2 小时前
🚀 共绩算力:3分钟拥有自己的文生图AI服务-容器化部署 StableDiffusion1.5-WebUI 应用
前端·人工智能·aigc
街尾杂货店&2 小时前
CSS - transition 过渡属性及使用方法(示例代码)
前端·css
CH_X_M2 小时前
为什么在AI对话中选择用sse而不是web socket?
前端
Mintopia2 小时前
🧠 量子计算对AIGC的潜在影响:Web技术的未来可能性
前端·javascript·aigc
街尾杂货店&2 小时前
css - word-spacing 属性(指定段字之间的间距大小)属性定义及使用说明
前端·css
忧郁的蛋~2 小时前
.NET异步编程中内存泄漏的终极解决方案
开发语言·前端·javascript·.net
水月wwww3 小时前
vue学习之组件与标签
前端·javascript·vue.js·学习·vue
合作小小程序员小小店3 小时前
web网页开发,在线%商城,电商,商品购买%系统demo,基于vscode,apache,html,css,jquery,php,mysql数据库
开发语言·前端·数据库·mysql·html·php·电商
顾安r3 小时前
11.8 脚本网页 塔防游戏
服务器·前端·javascript·游戏·html