Skip to main content

React 源码: 主要内部结构和运行流程

免责声明

本文出现的部分代码和解释可能与 React 源代码有明显出入,这些功能实现在 React 源码中所处的位置和情形都比本文的代码示例复杂很多。为了降低理解难度,几乎所有代码都被简化,并附带 pseudo 标示,但我会让伪代码尽量贴近源代码。

本文所述版本 & 分支

  • React Repo:v18.2.0(9e3b772b8cabbd8cadc7522ebe3dde3279e79d9e)

时间分片

一个使用组件库的前端项目,可以渲染出大量的 DOM Node。作为包揽页面逻辑、节点管理、样式管理、动画效果等功能的UI框架,React 需要具备调度大量运算资源的能力。

React 使用时间分片来管理运算资源,它的主要作用是及时让出资源给浏览器。

JavaScript引擎一般是单线程执行,以事件循环形式接收外部消息,所以我们一般的JavaScript代码是线程安全的。浏览器运行期间,引擎会循环做这些事情:

  1. 宏任务(如:用户代码,setTimeout 回调)
  2. 微任务(如:Promise 回调)
  3. HTML DOM Node 渲染,处理用户输入,处理网络响应

如果宏任务、微任务非常庞大,运算用时非常多,那么就会延后步骤3页面渲染,用户可能看到浏览器界面卡住了,体验不好。

因此需要在步骤1,2做优化,暂停耗时任务,让引擎往下执行,让出渲染资源。剩余的耗时任务在下一次循环继续。所以 React 的时间分片并不是硬件级的分片,它主要针对递归函数(Function)做优化,而 React 内部的很多方法就有很强的递归性质(如:从父组件到子组件)。

对于 for、while 这类的硬循环,时间分片无能为力,该卡住还是会卡住。

// pseudo
// 假设这个方法从父节点渲染到子节点
// 它返回一个方法,我们可以暂存执行,也可以立即全部执行
function renderConcurrent(Component, props, node) {
  node.rendered = Component(props)
  return node.child && render.bind(null, node.Component, props, node.child)
}

// 同样的动作,使用 while 就只能全量执行
function renderSync(Component, props, node) {
  while (node !== null) {
    node.rendered = Component(props)
    node = node.child
  }
}

调度任务的方法

调度是把按照资源需求,选择任务暂存起来,等待空闲之行、或立即执行的过程。React 时间分片得到一个未完成的任务时,需要把这个任务暂存到下一个事件循环的宏任务执行,以释放计算资源。常用的调度宏任务的方法有这些:

  • setTimeout(callback, ms)

    经常用来延后执行任务的计时方法。其中的回调会暂存到下一个事件循环执行。

    在嵌套调度的场景,setTimeout 存在最低 4ms 延迟问题;而时间分片不等于所有任务都要延后做,有紧急任务必须立即做,一般情况下时间分片调度的任务都要求 0ms(立即执行,但先让出资源给渲染)。

  • setImmediate(callback)

    这个方法用来暂存 callback 直到下一个事件循环,它很好地解决了时间分片的需求。

    但这个接口只出现在 IE、Node.js

  • Message Channel

    消息通道是浏览器 JavaScript 的接口,用来和 iframe 通讯。但它可以把消息回调调度到下一个事件循环,而且没有延时(0ms),因此满足时间分片的需求。

    下面的例子可以在浏览器模拟出类似 setImmediate 的功能。

    function setImmediateLike(callback) {
      let chan = new MessageChannel(); 
      chan.port1.onmessage = callback;
      chan.port2.postMessage(null)
    }
    

严格来讲,React 内部很多代码都是平台无关的(跨平台),所以时间分片可以根据不同的引擎宿主选择最好的实现。

任务和队列

React 时间分片定义的计算任务有这些字段

// 优先级、回调、调度开始时间、调度截止时间
class Task { priority, callback, startTime, expirationTime }

任务的优先级决定任务的调度截止时间,如果超出截止时间,这个任务就被视为紧急任务。

React 使用最小堆队列表示任务列表,以任务的剩余可调度时间排序,队头的剩余时间最小,为最紧急的任务。

调度模块有2个最小堆:TaskQueueTimerQueue。前者代表立即调度执行的任务,后者表示延后执行的任务(相当于setTimeout任务,但统一管理)。有些任务会有“未执行完”的状态,比如渲染,函数执行完会得到下一步的函数,此时这个任务就继续待在队列。当调度器开始一个宏任务时:

  • 把过期的延后任务移到立即调度任务
  • 在本次宏任务的时间片内,尽可能执行所有的立即任务
  • 调度下一次宏任务

时间片限制

一次宏任务允许的时间片是一个时间范围,如果执行任务耗时超出了这个范围,那么页面可能会出现卡顿。假设页面渲染要求 60 帧,那么一次宏任务最大用时为 1000 / 60 ~= 16ms,超过 16 ms 的任务,会导致页面卡顿。

// pseudo
// shouldYield 是否超过了当前时间片
let shouldYield = () => now() - startTime > 16ms
// haveTimedoutInstantTask 是否已经有任务过期了
let haveTimedoutInstantTask = () => {/* ... */}
while (haveTimedoutInstantTask() || !shouldYield()) {
  flushTask()
}

虚拟节点 Fiber

Fiber 是 React 框架内部表示类似 DOM Node 的结构。Fiber 比 DOM Node 更轻量,会携带每个阶段的上下文数据。Fiber 一般是组合起来成为树形结构,参与 React 渲染和提交(Commit)的过程。

React Element

React 的项目代码中普遍存在 React Element,它用来描述 DOM Node 的结构。可以用以下方法创建:

import React from 'react'
// 代表 DOM Node: <div id="app"></div>
let header = React.createElement('div', {id: "app"}, null)

一个复杂 React 组件会创建大量的 React Element。通常用户代码会引入转译器(如:Babel,TypeScript、SWC),通过特定领域语言(JSX、TSX)的描述,能显著减少代码量:

let header
// 转译前
header = <div id="app" />
// 转译后
header = React.createElement('div', {id: "app"}, null)

React Element 只带 tag、attributes、children,会在用户代码中频繁创建。

在 React 内部,进入渲染阶段时,React Element 会被转换成 Fiber 对象,Fiber 除了包含它,还携带优先级、更新队列等内部调度的属性。

暂存环境(Pending Work)

从 React Element 创建的 Fiber 对象由于时间分片不会被立即处理,所以它会有 “调度前”、“调度后” 的状态,有时出现击中缓存的情况,Fiber 甚至不会进入调度。

比如 Props,所有全新创建的 Fiber,Props 都从 pendingProps 开始,经过计算变成 memoizedProps。

// 当前挂载的 Props
prevProps = fiber.memoizedProps
// 新的 Props,未经过渲染计算
nextProps = fiber.pendingProps

车道 Lane & Lanes

Lanes 表示 Fiber 的优先级,有些 Fiber 会渲染出 HTML input,当响应用户输入时,这些 Fiber 会被赋上更高的优先级,让 React 优先调度、更新。

Lane 是单个优先级的表示,它以一个位元表示;Lanes 以多个位元表示。他们都是以整数的形式表现出来,通过位运算,或合并,或分开。

// SyncLane 的值最小,代表优先级最高;IdleLane 与之相对
const SyncLane  = 0b0000000000000000000000000000001
const RetryLane = 0b0000000010000000000000000000000
const IdleLane  = 0b0100000000000000000000000000000

// myLanes 包含了 RetryLane、SyncLane
let myLanes =     0b0000000010000000000000000000001
// 等同于两条 Lane 或运算
myLanes = SyncLane | RetryLane
// 与运算+取反操作,删除 SyncLane,剩下 RetryLane
myLanes &= ~SyncLane
// 与运算,增加 SyncLane
myLanes |= SyncLane

如果此时有一个 Fiber 出错了正在重试,React 会给它分配一个 RetryLane1 进入调度;若 Fiber 仍然出错,React 给它一个 RetryLane2 进入调度;如此往下,当出现第5次错误时,这个 Fiber 携带 RetryLane5,它的调度优先级最低,那么几乎要等 React 把其他的调度任务执行完后,才会处理这个 Fiber。

const RetryLane1 = 0b0000000010000000000000000000000
const RetryLane2 = 0b0000000100000000000000000000000
const RetryLane3 = 0b0000001000000000000000000000000
const RetryLane4 = 0b0000010000000000000000000000000
const RetryLane5 = 0b0000100000000000000000000000000

标识 Flag & Flags

Fiber 持有的 Flags 标记了 Fiber 当前的状态,在提交阶段(Commit Phase)会用到,描述了 Fiber 将如何部署到 DOM。

Flags 和 Lanes 都用位元表示,多个 Flags 以整数形式存在。

// 这个 Fiber 已经完成了渲染工作
const PerformedWork = 0b00000000000000000000000001
// 提交阶段,这个 Fiber 需要添加到 DOM (insertBefore, appendChild)
const Placement     = 0b00000000000000000000000010
// 提交阶段,这个 Fiber 需要从 DOM 删除
const Deletion      = 0b00000000000000000000001000

// 这个 Fiber 渲染完了,需要添加到 DOM
let AFiberFlags     = 0b00000000000000000000000011

钩子 Hooks

钩子是 React 项目中很常用的工具。以函数式编程的角度,它们给纯函数提供了副作用。它们可以持久化组件产生的数据,部署组件的回调。当然这些钩子只能在渲染期间才有效果。

得益于 JavaScript 的单线程特性,包括 React、MobX、Vue 的很多响应式框架都可以实现无参数取钩子,原因是在取钩子之前,框架就已经知道哪个地方需要取钩子,并提前准备好环境。缺点是取钩子的那段代码必须遵循框架的生命周期,过了生命周期,那个环境就是为其他代码块准备的了。

在组件渲染期间,每取一个钩子,React 就为当前渲染的组件 Fiber 添加一个 Hook 对象,多个钩子以链表的形式存在。

A.next = B
B.next = C
C.next = null
// [A] → [B] -> [C] -> null

Hook 保存了组件需要的数据,比如 useState(1),React 将生成一个 Hook 对象,它保存了一个 1,这个钩子即时返回 1 和一个更新函数;第二次渲染时,React 从 Fiber 钩子表中按顺序取出一个 Hook 对象,此时它的值为 1

如果钩子没有按顺序取用,React 会打印出一个钩子不完整(缺了钩子或者多出钩子)的警告。钩子作为链表,相当于按顺序存储了组件的值,所以取值也要按顺序。

// 第1次渲染:
let [a, _] = useState(1) // a = 1
let [b, _] = useState(2) // b = 2
let [c, _] = useState(3) // c = 3

// 第2次渲染:
let [a, _] = useState(1) // a = 1
// let [b, _] = useState(2)
let [c, _] = useState(3) // c = 2,c 拿了 b 的值

组件首次渲染时,所属 Fiber 的钩子表是空的,所以此时不应该打印不完整警告;为了处理这个特殊情况,钩子的取用分了2中情况:

  • 挂载时,取钩子给 Fiber 添加 Hook 对象
  • 重新渲染时,取钩子先验证前一次 Hook 完整性,再读取 Hook 的值

他们的具体实现分别是 mountXXXuseXXX,React 隐藏了这个细节,在用户代码中,取用钩子始终是 useXXX

副作用 Effects

Effect 是带响应式的回调,Effect 可以带有依赖,在提交阶段,若观察到依赖变化,Effect 会执行回调。用户代码中一般用 Effect 设定组件挂载和卸载的事件回调。Effect 的依赖存储在所属钩子的值,通过遍历元素地址进行依赖对比。

Effect 有加载和卸载函数,当 Effect 加载时,可以从加载的返回值获得卸载函数,类似 Rx 里面的一次性订阅(Disposable)。

// 用户代码
useEffect(function creationFn() {
  return function destroyFn() {}
}, [props])

// 内部 Effect 加载时
effect.destroy = effect.create()
effect.deps = [props]
// 内部 Effect 卸载时
effect.destroy()

多个 Effect 对象会组合成循环链表,循环链表可以提供更低的内存占用。可以通过判断指针是否指向头部获知循环链表遍历结束。

A.next = B
B.next = C
C.next = A
// [A] → [B]
//   ↖︎   ↙︎
//    [C]
function iterate (first, callback) {
  item = first
  do {
    callback(item)
    item = item.next
  } while (item !== first)
}

更新队列 Update Queue

更新队列是在 React 多个不连续执行的更新中确保计算结果完整性的重要功能,每个 Fiber 维护一个更新队列,调用更新方法(Dispatch)会给该队列新增一个 Update 对象,更新方法随着钩子分发,比如 [a, setState] = setState() 的 setState。

更新队列用于批量更新的暂存,比如这个例子:

// 4 个 setState 分别给上一个结果增加一个字符
let [state, setState] = useState({ str: "" })
setState(({str}) => ({ str: str + "A" })) // Operation A
setState(({str}) => ({ str: str + "B" })) // Operation B
setState(({str}) => ({ str: str + "C" })) // Operation C
setState(({str}) => ({ str: str + "D" })) // Operation D

setState 在 React 内部是一个异步方法,更新触发的任务可能会被安排到下一个时间片,所以同时 setState 并不会保证按顺序更新,最终结果可能也不是有序的。但实际上,上方的例子始终是 “ABCD”,其中更新队列保证了正确的结果。

像很多文章所说,这种机制会让部分 setState 重复执行。确实是这样,假设上方的例子中,C 被跳过了:

小写 s 代表这个更新被跳过了

第一次更新:

UpdateQueue = [ A, B, Cs, D]
NewState = ""
BaseState = ""
// 执行了 A、B 的更新
// C 跳过了,但是归入下一个时间片的更新队列
// 执行了 D 更新,但是 D 前面有更新被跳过了,所以 D 也跟着归入下一个时间片的更新队列
Result NewState = "ABD"
Result BaseState = "AB"
Next UpdateQueue = [ C, D ]

注意 BaseState,虽然执行了 D 导致结果不正确,但自从 C 跳过后,BaseState 就像检查点那样不再更新。而继续下一个时间片:

UpdateQueue = [ C, D ]
NewState = "ABD"
BaseState = "AB"
// 执行了 C、D 更新
Result NewState = "ABCD"
Result BaseState = "ABCD"

在第二次更新中,基于 BaseState 再次执行更新。在这个过程中,可以注意到 D 被执行了 2 次。此时 BaseState 计算出了正确结果 “ABCD”,于是应用到新的 State。

Fiber 树和双缓冲

继续上一个更新队列的例子,其中第一次更新时,界面没有显示错误的结果(ABD),是因为在知道第二次更新之前,React 不会把计算结果提交部署(Commit Phase)。

整个 React 内部运作,React 不会在完成任一组件的更新时立即把结果刷新到界面。对于 DOM 界面一个根元素(如:div#app),React 为它维护了 2 棵 Fiber 树:

  • 当前 Fiber 树(展示到界面上)
  • 后台 Fiber 树(在后台执行计算任务)

当 React 从一棵 Fiber 树收到更新时,所有的更新任务会交给后台 Fiber 树去处理,直到后台 Fiber 树的任务完成,React 把它们交换一次,保持同步。

root.current = finishedWork

最终 DOM 界面看到的是当前 Fiber 树,它的每一个节点都可以映射到具体的 DOM 节点。这种树交换的方法可以参考计算机图形中的双缓冲。

渲染阶段

调度器是 React 的具体引擎实现,日常项目中引用的 React 包仅仅用来定义 React Element。

以下情况会触发调度器的调度工作

  • 挂载根元素 (mount("#app")
  • 接收状态更新 (setState()

每棵 Fiber 树有一个根 Fiber(Fiber Root)。接收更新时,调度器需要当前更新的 Fiber,以及所处的根,根主要用来帮助调度器:

  • 定位错误产生时,所在的 Fiber 树
  • 在提交阶段后,与当前 Fiber 树交换
  • 刷新 Effect

调度器的一次更新任务分成2个阶段,注意渲染阶段受时间分片影响,是断续执行的:

  • 渲染阶段(Unit Of Work Render)
  • 提交阶段(Commit FinishedWork)

渲染事务

Unit Of Work(工作单元),代表事务。意思是渲染工作会从每一个组件开始,直到所有组件渲染完毕,这是一个断续执行的任务,所以基于 JavaScript 单线程特性,在渲染阶段,React 直接在模块中主要声明几个全局变量:

  • workInProgress 当前处理的 Fiber,它始终是请求更新的 Fiber 或它的 Child
  • workInProgressRoot 当前处理的 Fiber 所属的根

假设这个阶段所有的任务都是断续执行的,以突出时间分片的功能。渲染阶段主要有这些

  • 为 Fiber 调度更新

    为 Fiber 安排一次更新,此时事务开始,调度器会设置全局变量,更新会被安排到下一个时间片。

    // pseudo 入口
    function scheduleUpdateOnFiber(root, fiber) {
      workInProgress = fiber
      workInProgressRoot = root
      ensureRootIsScheduled(root)
    }
    // 安排到下一个时间片
    function ensureRootIsScheduled(root) {
      let callback = performConcurrentWorkOnRoot.bind(null, root)
      postMacroTask(callback)
    }
    
  • 事务的开始和结束标志

    由于组件渲染存在递归性质,事务会从 WorkInProgress Fiber 一直递归到所有的 Children,再从 Children 一路回来,回到 WorkInProgress Fiber 即表示渲染事务结束。

    // pseudo 时间片触发
    function performConcurrentWorkOnRoot(root) {
      renderRootConcurrent()
      if (exitStatus === Completed)
        commitRoot(); // 渲染结束,提交
      else
        ensureRootIsScheduled() // 继续下一个 Fiber 的渲染
    }
    
    function renderRootConcurrent() {
      workLoopConcurrent()
    }
    
    // 断续执行,在时间片结束以前,执行工作单元(Fiber)的渲染
    function workLoopConcurrent() {
      let workLoopDone = () => workInProgress === null
      let sliceTimeout = () => Scheduler.shouldYield()
      while (!workLoopDone && !sliceTimeout) {
        performUnitOfWork() // unit of work
      }
    }
    
  • 具体到每一个 Fiber 的工作单元

    在可用的时间片内,调度器为每一个 Fiber 当作一个工作单元执行。工作单元又分为 beginWork, completeUnitOfWork,前者处理渲染和调度,后者负责 Flags 和指针收尾工作。

    BeginWork:断续执行,从 Fiber 到最深的 Child,执行组件的渲染方法,更新到对应的 Fiber,并执行调度。每次只为一个 Fiber 服务,返回 Child。当 Child 不存在时,表示到达最深迭代。

    // pseudo
    function beginWork() {
        let FC = workInProgress.type
        let props = workInProgress.pendingProps
        let newChildren = FC(props)
        reconcileChildren(workInProgress, newChildren)
        return workInProgress.next
    }
    // 若 A 有 B,B 有 C,则
    // wip=A, beginWork(), next=B
    // wip=B, beginWork(), next=C
    // wip=C, beginWork(), next=null, 到达最深
    

    BeginWork 只会迭代并返回 Fiber 的第一个 Child,如果有一个 Child 存在并排关系(Sibling),BeginWork 会无视掉。下方的例子中,beginwork 表示被 BeginWork 处理过了:

    // 注意 HipHop 作为 Rap 的 Sibling,没有被 BeginWork 处理
    function MyComponent() {
      return <Track beginwork>
        <Genre beginwork>
          <Rap beginwork />
          <HipHop />
        </Genre>
      </Track>
    }
    

    CompleteUnitOfWork(Complete):直到 BeginWork 到达最深的 Child 以后,它返回了 null。接下来 Complete 会检查最后一个可达的 Child 是否真正完成了渲染,然后尝试把指针移动到 Sibling,如果没有 Sibling,则指针往外移(Parent)。

    // pseudo
    function completeUnitOfWork(completedFiber) {
      let sibling = completedFiber.sibling
      let parent = completedFiber.return
      if (sibling) {
        workInProgress = sibling
      } else {
        workInProgress = parent
      }
    }
    

    简单来说,BeginWork 一直往 Child 迭代(深度优先遍历),而 Complete 往 Sibling 和 Parent 往外迭代(广度优先遍历)。

    直到 Complete 让指针移回最初的 WorkInProgress,则说明事务已经结束。

    // pseudo
    function performUnitOfWork() {
      let nextChildFiber = beginWork()
      if (nextChildFiber === null) 
        completeWork(nextChildFiber);
      else
        workInProgress = nextChildFiber
    }
    

钩子环境

在上文已经有提到,钩子取用分了2种情况:首次渲染、之后的渲染。

在 React 内部,调用钩子最终指向了一个变量:ReactCurrentDispatcher,并有不同的宿主提供钩子实现。

根据阶段不同,宿主会时刻变化,取用钩子会有不同的反馈:

  • ContextOnlyDispatcher

    只能使用 useContext,使用其他钩子会直接报错。没有组件渲染时指向这个宿主

  • HooksDispatcherOnMount

    当组件首次渲染时指向这个宿主,取用的钩子只会给当前渲染的 Fiber 添加 Hook 对象

  • HooksDispatcherOnUpdate

    当组件再次渲染时指向这个宿主,取用的钩子会验证当前渲染的 Fiber 持有的 Hook

ReactCurrentDispatcher 会在渲染开始时更换宿主,在渲染结束时重置。

function renderWithHooks(current, workInProgress, Component, props) {
  currentRenderingFiber = workInProgress
  let isFirstMount = current === null
  // 准备钩子宿主
  if (isFirstMount)
    ReactCurrentDispatcher.current = HooksDispatcherOnMount
  else
    ReactCurrentDispatcher.current = HooksDispatcherOnUpdate
  // 渲染
  let children = Component(props)
  // 重置宿主
  ReactCurrentDispatcher.current = ContextOnlyDispatcher
}

调度

调度是定位渲染前后的每个元素的位置变化,每个组件状态更新时,渲染出来的 React Element 可能与上一次的渲染结果不一样。React 不能直接把现有的元素全部卸载又全部挂载,这样非常浪费性能,所以调度器需要对组件渲染前后的元素做对比,找出变化的元素,执行局部更新。

组件每次渲染的结果都是全新的 React Element,所以调度器只能为元素的属性做对比。

const MyComponent = () => <div />
// 1st render
let first = MyComponent()
// 2nd render
let second = MyComponent()
first === second
// false

判断单个元素是否有变化,调度器先后检查 keyType。key 是渲染列表中经常用来标记子项的字段;Type 则表示 FC 本身,比如 function Swiper()function Collapse()。就是说,即使元素 key 相等,Type 不相等时,调度器也会强制更新这个元素,重新渲染。所以 key 一般用在子元素渲染,是因为一般用户代码中的列表元素,它们的 Type 都一样的,如 <ListItem />,这时候就需要用 key 去区分。

调度器能调度单元素的机会很少,很多时候都在调度多个元素,项目中的 Fragment、List 在 React 内部都视为列表,而 List 更突出子元素 Type 相同的性质,React 会重点检查列表中缺 key、重复 key 的情况。

// Fragment
const myFragment = <>
  <Header />
  <Container />
  <Footer />
</>

// List
const myList = [
  <ListItem key="item1" />
  <ListItem key="item2" />
  <ListItem key="item3" />
]

判断列表中的元素比判断单元素复杂得多,调度器需要找出 Fiber 前后的变更位置。它先假设旧 Fiber 全部被扔到垃圾桶,然后通过下标和 key 匹配来找回旧的 Fiber 复用,在源码中这个过程称为恢复(restore)。

下方的例子是调度器经常遇见的新旧列表对比情形:现有已提交的一个 Fiber,它更新后,有一组新元素需要与 Fiber Children 做对比。

// 现有的 Children
declare const OldList: ArrayLike<OldFiber>
// 渲染后得到的 React Element 列表
declare const NewList: ArrayLike<ReactElement>
// 现在需要给 2 个列表做对比,尽量找出新元素对应的旧 Fiber
// 匹配出旧的 Fiber 做复用,避免产生大量 Fiber 增删的开销

调度器有一系列的步骤处理这个用例,成功恢复的 Fiber 会与它的元素做单元素对比(key、type 对比):

  • 遍历 OldList,尝试对比每个 OldFiber 与对应下标的 NewElement。这是常用的恢复方法。

    // pseudo
    const restoredFibers = new LinkedList<OldFiber>()
    for (let newIdx; newIdx < NewList.length; newIdx++) {
      const OldFiber = OldList.next()
      const NewElem = NewList[newIdx]
      const restoredFiber = updateSlot(OldFiber, NewElem)
      restoredFibers.add(restoredFiber)
    }
    

    遍历结束后产生3个结果:NewList 全匹配;OldList 全匹配;OldList、NewList 都没完全匹配

  • 结果1:NewList 全匹配

    OldList 没匹配完的时候,NewList 已经匹配完了。这个结果表示调度完成了。OldList 中未被匹配的 Fiber 会被卸载、删除。

    调度器不会马上删除 Fiber,而是给它赋一个 Deletion Flag,待到提交阶段,React 连着 DOM 节点一起从整棵树上移除。

    // pseudo
    if (newIdx === NewList.length) {
      deleteRemain(OldList) // 删掉剩余的 Fiber
      return newFiberChildren // 结束调度过程
    }
    
  • 结果2:OldList 全匹配

    和结果1反过来,NewList 还没匹配完时,OldList 已有的 Fiber 已经恢复完了。这个结果也表示调度完成了,调度结果 = 已匹配到的 Fiber + 剩余 NewList。

    // pseudo
    if (OldList.next() === null) {
      // 剩余的新元素转换成 Fiber 后添加到调度结果
      NewList.foreachRemain(elem => {
        let newFiber = createNewFiberFrom(elem)
        newFiberChildren.add(newFiber)
      })
      return newFiberChildren
    }
    
  • 结果3:OldList、NewList 都没匹配完

    出现这种情况是因为下标遍历中,有些新元素从中间插入。就像下方的组件中插入的 Container。

    function MyComponent() {
      let [i, setI] = useState(0)
      return <>
        <Header />
        {i % 2 && <Container /> || null}
        <Footer />
      </>
    }
    

    这个例子中,Header Fiber 能够恢复,而匹配 Footer 时,得到的可能是 Container。所以到达这个结果,调度器只能通过剩余的下标和 key 匹配方式,尽力尝试恢复旧 Fiber。

    调度器为剩余的 OldList 建立一个 Map,接下来的匹配以 key、index 查找(优先匹配 key,这个情况下 index 很难匹配到对应的 OldFiber),匹配不到的,直接创建新 Fiber。

    // pseudo
    remainMap = new Map<Key|Index, OldFiber>()
    // 建立 Map,这个操作在源码中称为 fastpath
    OldList.foreachRemain((OldFiber) => {
      remainMap.put(oldFiber.key ?? oldFiber.index, oldFiber)
    })
    // 剩余的新元素随缘匹配,匹配不到就创建
    NewList.foreachRemain((NewElem, remainIdx)=> {
      const mappedFiber = remainMap.get(NewElem.key ?? remainIdx)
      if (mappedFiber) {
        // 尝试恢复,恢复不到就创建一个新的
        const newFiber = updateXXX(newFiber, NewElem, remainIdx)
        newFiberChildren.add(newFiber)
      }
    })
    return newFiberChildren
    

Bailout (Cache)

Bailout 类似缓存复用,在渲染过程中,React 会跳过一些不需要更新的 Fiber。

如何找出不需要更新的 Fiber?在 BeginWork 给每个组件渲染前,会用三等判断新旧 Props,新旧 Props 相等的组件会被标记成 Bailout,React 会跳过这个组件的渲染。

// pseudo
function beginWork(current, workInProgress) {
  let oldProps = current.memoizedProps
  let newProps = workInProgress.pendingProps
  if (oldProps === newProps) {
    attemptEarlyBailoutIfNoScheduledUpdate(current, workInProgress)
    // -> bailoutOnAlreadyFinishedWork(current, workInProgress)
    // -> return null
    // 返回 null 跳过这个 Fiber 的渲染
  }
}

在用户代码中,通过 Props 传递的元素,在子组件更新时就会被 Bailout。因此对于一些不常更新的组件,可以通过模块内常量,或者 useMemo 减少新建 React Element 的次数。

// header, footer 第1次渲染后
// 就一直保持 Bailout 状态,Effect 只会触发1次
// 相当于 props = {}; 
//  1st: return props; 
//  2nd: return props;
const header = <Header />
const footer = <Footer />
function MyComponent() {
  // body 在每次组件更新都会重新渲染,触发 Effect
  // 相当于 
  //  1st: return {}; 
  //  2nd: return {};
  const body = <Body />
  return <>
      {header}
      {body}
      {footer}
  </>
}

提交阶段(Commit Phase)

渲染阶段结束后,需要更新的 Fiber,它们的 Flags 会带上一些属性,如:Update、Placement、Deletion。接下来的提交阶段会用到这些 Flag。

刷新 Effect(Commit Effect)

Effect 是监听到依赖变化时触发回调的观察者,在提交阶段时,要为有变化的 Fiber 刷新 Effect。

不是所有的 Fiber 都需要刷新一遍,React 检查 Fiber subtreeFlags 是否 而且一般也只针对某些 Effect 刷新。subtreeFlags 代表 Fiber 整棵树的 Flags,由 completeWork 计算得到。

刷新 Effect 又分成几个子任务:

  • Commit Layout Effects

    刷新 Layout Effect。从最外围的 Fiber 开始,迭代到所有带 PassiveMask 的 Children。

    加载 LayoutEffect

    // pseudo
    if (fiber.subtreeFlags & PassiveMask !== NoFlags) {
      commitLayoutEffectMount(fiber)
    }
    
  • Commit Deletion Effects

    刷新 Deletion Effect。从最外围的 Fiber 开始,迭代到所有 Children。

    卸载 InsertionEffectLayoutEffect

    // pseudo
    commitInsertionEffectUnmount(fiber)
    commitLayoutEffectUnmount(fiber)
    
  • Commit Reconciliation Effects

    刷新调度 Effect。从最外围的 Fiber 开始,迭代到所有带 Placement 的 Children。

    刷新调度 Effect 会执行 DOM 修改操作,关联外部接口 insertBeforeappendChild,React 有专门的包实现 DOM 操作。

  • Commit Mutation Effect

    刷新 Mutation Effect,会在 Fiber 树有增删时执行,这个操作是 Deletion、Reconciliation、Insertion、Layout 的组合形式。从最外围的 Fiber 开始,迭代到所有带 Mutation 的 Children。

    // pseudo
    // Deletion
    fiber.deletions.forEach(commitDeletionEffects)
    // Reconciliation
    commitReconciliationEffects(fiber)
    if (fiber.flags & Update) {
      // Insertion
      commitInsertionEffectUnmount(fiber)
      commitInsertionEffectMmount(fiber)
    }
    // Layout
    commitLayoutEffectUnmount(fiber)
    commitLayoutEffectMmount(fiber)
    

上方的 Effect 操作可以解释第一次渲染会出现 2 次组件 Layout 卸载加载,分别是 Layout Effect 和部署到 DOM 触发的 Mutation Effect。并且在多次渲染时,组件虽然触发了 Layout Effect,但对应的 DOM 节点不一定执行了真正的卸载加载,如果要观察 DOM 节点的卸载加载,应使用 useInsertionEffect

平台特定的 Framework 实现

软件包 react 是面向用户代码的 React Element 标准包,它给用户提供了创建 React Element 的方式。

软件包 react-reconciler 是脱离了 DOM 的调度器实现。

因此 React 可以是跨平台的框架,它的调度器模块要求的适配器为 HostConfig,只要实现 HostConfig 要求的方法即可与调度器集成,实现自定义的跨平台 React 引擎。

DOM Renderer

软件包 react-dom 是 React web 项目专用的 React 引擎,它的适配器依赖 HTML DOM 接口,提供 SPA、SSR。

import ReactDOM from 'react-dom'
// Bootstrap the world

React Native Renderer

软件包 react-native-renderer 是 React Native(RN)的渲染引擎,适用移动 App 开发。

实际项目开发中,移动 App 有多个分发渠道(如:Android flavor),为了适配多个渠道,RN 提供的 CLI 工具隐藏了渲染入口的细节,通过约定项目的入口文件渲染根组件。

import ReactNativeRenderer from 'react-native-renderer'
// Bootstrap the mobile world

附录

Github React Repository v18.2.0