跳到主要内容

认识

2024年03月04日
柏拉文
越努力,越幸运

一、认识


React 渲染更新工作流 主要有初始化、Scheduler 调度Render 调和Commit 提交 阶段。

二、初始化


一、创建 Virtual DOM: 在 WebpackRsPack 或者 Vite 等构建编译下, 通过显示引入 createElement 或者隐式引入 jsxJSX 语法转换为 ReactElementReactElement 就是 React 中的 Virtual DOM。通过 ReactElement 来创建 FiberReactElementFiber 一一对应。

  • *ReactElement 创建原理: 在 React.js 17 之前, 应用程序通过 @babel/preset-react JSX 语法转换为 React.createElementjs 代码,因此需要显式将 React 引入,才能正常调用 createElementReact.js 17 版本之后,官方与 babel 进行了合作,直接通过将 react/jsx-runtimejsx 语法进行了新的转换而不依赖 React.createElement, 称为 Runtime Automatic(自动运行时)。在自动运行时模式下,JSX会被转换成新的入口函数,import {jsx as _jsx} from 'react/jsx-runtime';import {jsxs as _jsxs} from 'react/jsx-runtime';React 中的 createElement 以及 JSX 都会返回一个 ReactElement 对象, 用于后续创建 Fiber 对象。由上所述, React 是纯运行时前端框架, 在运行前, 已经将 JSX 全部转换为 ReactElement 虚拟 DOM, 在运行中没有机会进行编译优化。

  • ReactElement 数据结构:

    const ReactElement = function (type, key, ref, props) {
    const element = {
    $$typeof: REACT_ELEMENT_TYPE,
    type,
    key,
    ref,
    props
    };
    return element;
    };

二、React 应用初始化: 1. 为传入的 DOM 容器创建一个 FiberRootNode 对象, 这个对象作为整个应用的根容器; 2. 在 FiberRootNode 的基础上,创建一个特殊的 Fiber 节点——HostRoot Fiber。它代表整个应用的根 Fiber 节点,是连接虚拟 DOM 与实际渲染的桥梁。 3. 将 FiberRootNodeHostRoot Fiber 关联起来,形成完整的 Fiber 树结构。4. 初始化更新队列, 在 HostRoot Fiber 上,React 会初始化一个更新队列(UpdateQueue),用于存储各种待处理的更新任务, 个更新队列采用环形链表的形式存储 Update 节点,不仅便于插入和遍历,还能有效管理任务的优先级和调度顺序; 4. 绑定全局事件代理, 在创建根容器的过程中,React 会将所有需要的事件(如点击、输入等)绑定到传入的 DOM 容器上,实现事件委托机制。5. 为新根容器启用并发模式, React 会启动时间切片(Time Slicing), 调度器(Scheduler)会根据任务的优先级对更新进行细粒度的调度,必要时允许任务中断与恢复。

  • FiberRootNode: 是 React 应用的根节点, 它的作用是负责应用加载相关的内容,比如应用加载模式mode,存储本次应用更新的回调任务以及优先级,存储创建完成的FiberTree等。

  • HostRootFiber: 是虚拟DOM树的根节点,类型是FiberNode, 针对普通 DOM 元素或者组件创建的 Fiber 对象,是虚拟DOM的真实体现

三、Mount Scheduler 调度


Scheduler 调度执行: Schedule 中分别提供了 unstable_runWithPriorityunstable_scheduleCallback 方法, 用于支持调度。在 Mount 初始挂载阶段, 调用 unstable_runWithPriority 以同步的优先级, 调用执行初始渲染回调。在回调中, 将 HostRoot FiberUpdateQueue 加入到更新队列, 调用 scheduleUpdateOnFiber 开始进行调度更新。在 Mount 初始挂载阶段 , 从当前 HostRoot Fiber 向上遍历, 更新每个祖先节点的 childLanes 属性,表明该分支中存在待调和的更新任务, 然后返回 rootFiber。由于 HostRoot Fiber 是根节点, 直接返回自己, 进行渲染流程。unstable_runWithPriority: 接受一个优先级与一个回调函数。在函数中, 执行回调函数之前, 记录当前优先级为传入的优先级, 开始执行回调函数并返回函数执行结果, 最后记录当前优先级为之前的优先级。

四、Mount Render 调和阶段


Render 调和阶段 的主要目标是生成一棵新的 Fiber 树,并为后续的 DOM 更新准备好 副作用列表(effect list。这个阶段不直接操作 DOM,而是进行计算和比较。render 阶段以 DFS 深度优先 的顺序遍历 ReactElement, 如果有子节点, 遍历子节点, 如果没有子节点遍历兄弟节点。Render 阶段 可以中断和恢复, 简单来说,由于 Render 阶段的操作对用户来说其实是不可见的,所以就算打断再重启,对用户来说也是零感知。而 commit 阶段的操作则涉及真实 DOM 的渲染,所以这个过程必须用同步渲染来求稳。Render 调和阶段 是一个递归的过程, 存在 两个阶段。

BeginWork 递阶段: 从根 Fiber 开始, 对每个 Fiber 节点,调用对应的处理函数。处理逻辑如下(主要以函数组件为例): 遇到函数组件, 调用 renderWithHooks 执行函数组件。在执行过程中, React 按照 Hook 调用的顺序遍历组件内部所有 Hook, 在 Mount 阶段的每一个 Hook 会调用 MountHookXX 来为每一个 Hook 创建一个 Hook 对象, 存储在 当前处理的 fiber.memoizedState 中(注意, 如果 Hook 不在函数组件中调用, 此时获取不到当前正在处理的 Fiber, 无法进行后续流程) 。 Hook 对象中也有一个 memoizedState, 另外还有 nextbaseQueuebaseStateupdateQueue。此时的 Hook.memoizedState 为传入的初始值。最终, 组件函数最终返回一个新的 ReactElement, 拿到对应的 ReactElement 之后, 调用 reconcileChildren, 在 Mount 挂载阶段, 会直接生成新的 Fiber

  • Hook.memoizedState:

    • useRef Hook.memoizedState: 保存 {current: xxx}

    • useState Hook.memoizedState: 保存 state 的值

    • useEffect Hook.memoizedState: 保存包含 useEffect 回调函数、依赖项等的链表数据结构 effect

    • useReducer Hook.memoizedState: 保存 state 的值

    • useMemo Hook.memoizedStateuseCallback Hook.memoizedState: useCallback 保存的是 callback 函数本身,而 useMemo 保存的是callback 函数的执行结果

  • Fiber.memoizedState: 存储 Hooks 链表

CompleteWork 归阶段: 当某个 Fiber 节点没有子节点或者子节点已处理完成后,开始从叶子节点向上回溯。对于 HostComponentDOM 元素节点),在这一步骤会创建真实 DOM 元素的描述(还未实际挂载), 对于文本节点、Portal 等, 也会进行相应处理。将当前节点产生的副作用(比如需要插入、更新或删除 DOM 节点的操作)沿着树向上冒泡到父节点。构建一份完整的副作用列表,供 Commit 阶段统一处理。将每个节点的副作用合并到父节点上,最终从根节点可以获得整个更新所需的所有副作用。

五、Mount Commit 提交阶段


Commit 提交阶段React 更新流程中唯一会直接操作 DOM 的阶段,它根据 Render 阶段生成的副作用列表执行实际的更新操作。这个阶段是同步执行的,确保所有副作用在一次提交中完成, 这个阶段是同步且不可中断的,确保 UI 的最终状态与最新的 React 元素描述完全一致。简单来说,由于 Render 阶段的操作对用户来说其实是不可见的,所以就算打断再重启,对用户来说也是零感知。而 commit 阶段的操作则涉及真实 DOM 的渲染,所以这个过程必须用同步渲染来求稳。通常分为以下几个子阶段:

beforeMutation 变更前提交阶段: 对于需要捕获 DOM 状态的组件,会在实际 DOM 变更前调用 getSnapshotBeforeUpdate, 记录必要的信息,为后续可能的布局修正提供数据。

mutation 变更提交阶段: 根据 effect list 中标记的副作用,对 DOM 进行实际的操作:创建新的 DOM 节点、更新现有节点、删除不再需要的节点等。在这一阶段,React 同时会处理事件绑定的更新以及 ref 的挂载或卸载,确保新的 DOM 状态与组件状态一致。对于需要删除的节点,执行相应的卸载操作,如移除事件监听器、释放内存资源等。

layout 布局提交阶段: 真实 DOM 挂载完成后,会依次调用诸如 componentDidMountcomponentDidUpdate 等生命周期方法。在函数组件中,相关的 layout effectuseLayoutEffect)也会在这一阶段执行。这一阶段确保所有 DOM 更新完成后,再进行可能依赖最新 DOM 布局信息的操作,比如同步测量、滚动调整等。

问题:

  • commit 阶段执行的生命周期或者 effect 钩子是 先子后父? 为什么呢? 本质上 commit 阶段处理的事情和 dom 元素有关系,commit 阶段生命周期是可以改变真实 dom 元素的状态的,所以如果在子组件生命周期内改变 dom 状态,并且想要在父组件的生命周期中同步状态,就需要确保父组件的生命周期执行时机要晚于子组件。

六、Update Scheduler 调度


调用 setStateuseStateuseReducerforceUpdate 之后,其实都会走入 React 的更新流程。

  • setState: 当在类组件中调用 this.setState(partialState, [callback]) 时,会进入组件实例的 updater 对象。updater 内部会调用类似 enqueueSetState 的方法,将本次更新的信息封装成一个 Update 对象。Update 对象包含了传入的部分状态(可能是对象或函数)、更新优先级(Lane)、以及可能的回调函数。将该 Update 插入到对应的 fiber.updateQueue.shared.pending 更新队列中。调用 scheduleUpdateOnFiber,标记当前 Fiber 需要更新,并由调度器安排后续渲染任务。

  • forceUpdate: 当调用组件实例的 forceUpdate() 时,会通过组件内部的 updater 调用类似 enqueueForceUpdate 的方法。

  • useState dispatchSetState: 确定该更新所属的优先级(即 Lane), 并生成一个 Update 对象, 其中包含了此次更新的 action(可能是新状态值或用于计算状态的函数)。将该 Update 插入到对应 Hook 的更新队列中 Hook.UpdateQueue 中。调用 scheduleUpdateOnFiber,标记当前 Fiber 需要更新,并由调度器安排后续渲染任务。在将更新添加到更新队列之前,基于 eagerState 优化策略, 尝试立即计算出新状态, 如果计算结果与当前状态完全一致,就可以跳过后续的调度和渲染工作,从而避免不必要的组件更新,提升性能。React 会同步计算最新状态, React 会立即以当前组件的 memoizedState 为基础,调用更新函数或直接合并传入的部分状态,从而得到一个 eagerState。对于类组件,这通常是在 setState 调用中直接合并对象或执行 updater 函数。对于函数组件,虽然 Hook 更新也经过类似处理,但内部逻辑会在 Hooks 链表中记录更新队列。计算出 eagerState 后, React 会将其与当前的状态通过 Object.is 进行比较, 如果 eagerState 与当前状态完全一致,说明此次更新不会引起 UI 变化。此时,React 可以选择跳过调度,避免无谓的渲染过程。如果 eagerState 与当前状态有差异, 加入更新队列。调用 scheduleUpdateOnFiber,标记当前 Fiber 需要更新,并由调度器安排后续渲染任务。

  • useReducer dispatchSetState: 确定该更新所属的优先级(即 Lane), 并生成一个 Update 对象, 其中包含了此次更新的 action(可能是新状态值或用于计算状态的函数)。将该 Update 插入到对应 Hook 的更新队列中 Hook.UpdateQueue 中。在将更新添加到更新队列之前,基于 eagerState 优化策略, 尝试立即计算出新状态, 如果计算结果与当前状态完全一致,就可以跳过后续的调度和渲染工作,从而避免不必要的组件更新,提升性能。React 会同步计算最新状态, React 会立即以当前组件的 memoizedState 为基础,调用更新函数或直接合并传入的部分状态,从而得到一个 eagerState。对于类组件,这通常是在 setState 调用中直接合并对象或执行 updater 函数。对于函数组件,虽然 Hook 更新也经过类似处理,但内部逻辑会在 Hooks 链表中记录更新队列。计算出 eagerState 后, React 会将其与当前的状态通过 Object.is 进行比较, 如果 eagerState 与当前状态完全一致,说明此次更新不会引起 UI 变化。此时,React 可以选择跳过调度,避免无谓的渲染过程。如果 eagerState 与当前状态有差异, 加入更新队列。调用 scheduleUpdateOnFiber,标记当前 Fiber 需要更新,并由调度器安排后续渲染任务。

因此, 无论是调用 setStateuseState/useReducer 还是 forceUpdate,其本质都是通过创建一个 Update 对象并将其插入到对应的更新队列中, 在插入期间, 需要进行对应的优化策略判断, 比如 eagerState 策略。而且, 无论是类组件将 Update 插入 Fiber.UpdateQueue 也好, 函数组件将 Update 插入 Hook.UpdateQueue 也好, 都会形成一个单向环形链表。之所以设计为单向环形链表是因为: 1. 做成环形链表可以只需要利用一个指针,便能找到头节点与尾节点; 2. 更加方便地找到最后一个 Update 对象,同时插入新的 Update 对象也非常方便; 3. 如果使用普通的线性链表,就需要同时记录第一个和最后一个节点的位置,维护成本相对较高。 当然, Update 加入更新队列还需要进行

在没有命中对应的性能优化策略之后, 调用 scheduleUpdateOnFiber 进入调度阶段。scheduleUpdateOnFiber 冒泡标记更新, 从当前 Fiber 向上遍历,更新每个祖先节点的 childLanes 属性,表明该分支中存在待调和的更新任务。在冒泡更新标记的过程中,最终会找到对应的根节点(通常为 FiberRoot),这个根节点是整个更新流程的入口, 并返回 FiberRoot, 并调用 unstable_scheduleCallback 将更新任务以对应的优先级加入任务队列。在函数中, 不同的优先级意味着不同时长的任务过期时间。同时会比较 startTimecurrentTime , 如果 startTime > currentTime, 表示当前任务未就绪, 存入 timerQueue。并根据开始时间重新排列 timerQueue 中任务的顺序。当 timerQueue 中有任务就绪,即startTime <= currentTime,我们将其取出并加入taskQueuetimerQueuetaskQueue 两个队列为了能在 O(1) 的时间复杂度里找到两个队列中时间最早的那个任务, 采用的是最小堆的数据结构, 每次存入任务的过程就是最小堆自动调整的过程, 时间最小的, 也就是最早的任务Scheduler 调度更新任务时的表现为: 1. 优先级高的任务会打断低优先级任务的执行, 每次执行的任务是所有任务中优先级最高的; 2. 如果两个任务的优先级相同, 不会开启新的调度; 3. 低优先级的任务存在过期时间, 等到失效后, 以同步任务高优执行。防止低优先级任务一直得不到执行, 导致饥饿问题的出现

  • 通过 RootFiber 是如何找到需要更新的组件的: Root Fiber 是通过对比每一级的 childLanes 与当前更新优先级, 逐渐向下对比调和找到需要更新的组件的。

  • 为什么要从触发更新的 Fiber 递归向上形成一个 childLanes 链, 而不可以从从触发的 Fiber 直接向下调和呢?: React 并不知道一次 state 变化, 影响的范围, 比如一次更新发生, 父子 Fiber 上都有更新, 如果直接向下调和, 那么是不合理的, 所以采用了从 rootFiber 统一向下的方式。

七、Update Render 调和


通过 Scheduler unstable_shouldYield 判断当前浏览器是否处于空闲状态, 如果处于空闲状态。开始进入 Render 调和 阶段。此时, React 中存在两颗树。双缓存 Fiber: React 构建了两棵 Fiber 树——当前树(current fiber tree)和工作中树(work-in-progress fiber tree),以实现高效的更新和必要时的回滚。这种双缓冲设计使得在进行并发更新时,能够分阶段地进行调度与中断,同时保证界面的一致性。

BeginWork 递阶段, 同 Mount 阶段, 从根 Fiber 开始, 对每个 Fiber 节点,调用对应的处理函数。处理逻辑如下(主要以函数组件为例): 遇到函数组件, 调用 renderWithHooks 执行函数组件。在 Update 阶段, 从 当前处理的 Fiber.memoizedState 中获取 Hook 链表, React 按照 Hook 调用的顺序遍历组件内部所有 Hook, 调用 updateHookXX 检查其更新队列, 对于每个 Hook, 如果队列中有更新, 从 Hook.baseState 触发, 遍历并处理 UpdateQueue 中的所有更新,根据优先级选择性地应用更新,并合并计算出最终的新状态,同时保留未处理的低优先级更新作为下次更新的基础。最终的计算结果会赋值给组件 FibermemoizedState。 最后, renderWithHooks 会返回新的 ReactElement, 这时, 新生成的 ReactElement 就已经包含了更新后的状态信息。拿到最新的 ReactElement 之后, 首先判断是否命中 bailOut 优化策略, bailout 性能优化策略, 通过对比current树与 workInProgressFiberpropscontextstate 等来命中 bailout 性能优化策略的组件可以不通过 reconcile 生成 workInProgress.child, 而是复用上次更新生成的 workInProgress.child, 减少不必要的子组件 render。如果未命中, 调用 reconcileChildren, 在 Update 更新阶段, 通过 Diff 算法来对比 当前的旧 Fiber 和最新的 ReactElement。根据新旧差异决定是复用已有的 Fiber 还是创建新的 Fiber 节点, 在对比过程中,为需要更新的节点打上相应的 flag(如 PlacementUpdateDeletion)。同时创建或更新当前节点的子 Fiber 链,为后续 CompleteWork 阶段做准备。

CompleteWork 归阶段: 当某个 Fiber 节点没有子节点或者子节点已处理完成后,开始从叶子节点向上回溯。对于 HostComponentDOM 元素节点),在这一步骤会创建真实 DOM 元素的描述(还未实际挂载), 对于文本节点、Portal 等, 也会进行相应处理。将当前节点产生的副作用(比如需要插入、更新或删除 DOM 节点的操作)沿着树向上冒泡到父节点。构建一份完整的副作用列表,供 Commit 阶段统一处理。将每个节点的副作用合并到父节点上,最终从根节点可以获得整个更新所需的所有副作用。

八、Update Commit 提交


Commit 提交阶段React 更新流程中唯一会直接操作 DOM 的阶段,它根据 Render 阶段生成的副作用列表执行实际的更新操作。这个阶段是同步执行的,确保所有副作用在一次提交中完成, 这个阶段是同步且不可中断的,确保 UI 的最终状态与最新的 React 元素描述完全一致。通常分为以下几个子阶段:

beforeMutation 变更前提交阶段: 对于需要捕获 DOM 状态的组件,会在实际 DOM 变更前调用 getSnapshotBeforeUpdate, 记录必要的信息,为后续可能的布局修正提供数据。

mutation 变更提交阶段: 根据 effect list 中标记的副作用,对 DOM 进行实际的操作:创建新的 DOM 节点、更新现有节点、删除不再需要的节点等。在这一阶段,React 同时会处理事件绑定的更新以及 ref 的挂载或卸载,确保新的 DOM 状态与组件状态一致。对于需要删除的节点,执行相应的卸载操作,如移除事件监听器、释放内存资源等。

layout 布局提交阶段: 真实 DOM 挂载完成后,会依次调用诸如 componentDidMountcomponentDidUpdate 等生命周期方法。在函数组件中,相关的 layout effectuseLayoutEffect)也会在这一阶段执行。这一阶段确保所有 DOM 更新完成后,再进行可能依赖最新 DOM 布局信息的操作,比如同步测量、滚动调整等。

问题:

  • commit 阶段执行的生命周期或者 effect 钩子是 先子后父? 为什么呢? 本质上 commit 阶段处理的事情和 dom 元素有关系,commit 阶段生命周期是可以改变真实 dom 元素的状态的,所以如果在子组件生命周期内改变 dom 状态,并且想要在父组件的生命周期中同步状态,就需要确保父组件的生命周期执行时机要晚于子组件。

九、问题


9.1 说说 React JSX 转换成真实 DOM 过程?

一、JSX 转换为虚拟 DOM, 在 WebpackRsPack 或者 Vite 等构建编译下, 通过显示引入 createElement 或者隐式引入 jsxJSX 语法转换为 ReactElementReactElement 就是 React 中的 Virtual DOM。通过 ReactElement 来创建 FiberReactElementFiber 一一对应。

二、初始化渲染或者状态更新触发调度器调度, 创建一个 Update 对象并将其插入到对应的更新队列中, 在插入期间, 需要进行对应的优化策略判断, 比如 eagerState 策略, 在没有命中对应的性能优化策略之后, 调用 scheduleUpdateOnFiber 进入调度阶段。scheduleUpdateOnFiber 冒泡标记更新, 从当前 Fiber 向上遍历, 更新每个祖先节点的 childLanes 属性,表明该分支中存在待调和的更新任务。在冒泡更新标记的过程中,最终会找到对应的根节点(通常为 FiberRoot),这个根节点是整个更新流程的入口, 并返回 FiberRoot, 并调用调度器的调度方法 unstable_scheduleCallback 将更新任务以对应的优先级加入任务队列。

三、进入 Render 调和阶段, 生成 Fiber, 通过 Scheduler unstable_shouldYield 判断当前浏览器是否处于空闲状态, 如果处于空闲状态。开始进入 Render 调和 阶段。Render 调和阶段 的主要目标是生成一棵新的 Fiber 树,并为后续的 DOM 更新准备好 副作用列表(effect list。这个阶段不直接操作 DOM,而是进行计算和比较。render 阶段以 DFS 深度优先 的顺序遍历 ReactElement, 如果有子节点, 遍历子节点, 如果没有子节点遍历兄弟节点。Render 阶段 可以中断和恢复, 简单来说,由于 Render 阶段的操作对用户来说其实是不可见的,所以就算打断再重启,对用户来说也是零感知。而 commit 阶段的操作则涉及真实 DOM 的渲染,所以这个过程必须用同步渲染来求稳。Render 调和阶段 是一个递归的过程, 存在 两个阶段。

  • BeginWork 递阶段, 从根 Fiber 开始, 对每个 Fiber 节点,调用对应的处理函数, 处理完成后会返回新的 ReactElement 虚拟 DOM。获取新的 ReactElement 后会进入 Reconcile 协调 进行 Diff 最小差异比对, 根据差异决定是否复用旧 Fiber, 最后返回 Fiber

  • CompleteWork 归阶段: 当某个 Fiber 节点没有子节点或者子节点已处理完成后,开始从叶子节点向上回溯。对于 HostComponentDOM 元素节点),在这一步骤会创建真实 DOM 元素的描述(还未实际挂载)

四、Commit 提交阶段React 更新流程中唯一会直接操作 DOM 的阶段,它根据 Render 阶段生成的副作用列表执行实际的更新操作。这个阶段是同步执行的,确保所有副作用在一次提交中完成, 这个阶段是同步且不可中断的,确保 UI 的最终状态与最新的 React 元素描述完全一致。通常分为以下几个子阶段:

beforeMutation 变更前提交阶段: 对于需要捕获 DOM 状态的组件,会在实际 DOM 变更前调用 getSnapshotBeforeUpdate, 记录必要的信息,为后续可能的布局修正提供数据。

mutation 变更提交阶段: 根据 effect list 中标记的副作用,对 DOM 进行实际的操作:创建新的 DOM 节点、更新现有节点、删除不再需要的节点等。在这一阶段,React 同时会处理事件绑定的更新以及 ref 的挂载或卸载,确保新的 DOM 状态与组件状态一致。对于需要删除的节点,执行相应的卸载操作,如移除事件监听器、释放内存资源等。

layout 布局提交阶段: 真实 DOM 挂载完成后,会依次调用诸如 componentDidMountcomponentDidUpdate 等生命周期方法。在函数组件中,相关的 layout effectuseLayoutEffect)也会在这一阶段执行。这一阶段确保所有 DOM 更新完成后,再进行可能依赖最新 DOM 布局信息的操作,比如同步测量、滚动调整等。

9.2 说说 React render 方法的原理 ? 在什么时候会被触发 ?

初始化渲染、调用 setState() 更新状态、父组件重新渲染导致 props 更新、上下文 Context 发生变化时, 都会进入组件实例的 updater 对象。updater 内部会调用类似 enqueueSetState 的方法,将本次更新的信息封装成一个 Update 对象。Update 对象包含了传入的部分状态(可能是对象或函数)、更新优先级(Lane)、以及可能的回调函数。将该 Update 插入到对应的 fiber.updateQueue.shared.pending 更新队列中。调用 scheduleUpdateOnFiber,标记当前 Fiber 需要更新,并由调度器安排后续渲染任务。

进入 Render 阶段, 它是一个递归的过程, 存在 两个阶段。在 BeginWork 递阶段, 从根 Fiber 开始, 对每个 Fiber 节点,调用对应的处理函数。处理逻辑如下, 遇到类组件时, 实例化类组件, 调用组件的构造函数 constructor, 初始化组件的 staterefs 等。React 执行 getDerivedStateFromProps 静态方法来处理 propsstate。如果为更新阶段, 接下来会调用 shouldComponentUpdate(nextProps, nextState) 判断组件是否需要更新。 如果是初始化渲染或者通过 shouldComponentUpdate 判断需要更新时, 调用组件实例的 render() 方法, render 方法应该是一个纯函数, 不允许有副作用, 用于生成新的虚拟 DOM ReactElement。获取最新的 ReactElement 之后, 根据 Reconcile 协调 Diff, 寻求最小差异更新。然后继续下一个 Fiber 的处理。当某个 Fiber 节点没有子节点或者子节点已处理完成后,开始从叶子节点向上回溯进入 CompleteWork 阶段。

9.3 React Schedule 调度器中, 为什么不能直接用 requestIdleCallback ? 那么如何替代原生 requestIdleCallback 呢?

浏览器的原生 API requestIdleCallback 用于在浏览器空闲时执行一些低优先级任务。但是由于以下原因, React 放弃使用: 1. 浏览器兼容性; 2. 触发频率不稳定, 受很多因素影响, 比如当我们的浏览器切换 tab 后, 之前 tab 注册的 requestIdleCallback 触发的频率会变得很低。3. requestIdleCallback 没有提供足够的控制粒度,例如无法定义任务优先级、不能随时中断、重启、继续等。基于以上原因, React 实现了功能更完备的requestIdleCallbackPolyfill ,这就是 Scheduler。除了在空闲时触发回调的功能外, Scheduler 还提供了多种调度优先级供任务设置, 精确控制渲染的优先级和时间片。

React Scheduler 替代原生 requestIdleCallback 的实现: React 模拟 requestIdleCallback 的策略为: setImmediateMessageChannelsetTimeout。 由于setTimeout嵌套过深会有>= 4ms的延迟, 所以如果当前宿主环境不支持 setImmediateMessageChannel, React 会优雅降级,选择setTimeout 来模拟, 这个调度器支持: 1. 根据任务优先级来灵活调度渲染任务; 2. 在每一帧内精确地控制任务执行时长(通常控制在 ~5ms 左右,避免掉帧); 3. 可中断、重启渲染任务,实现了精确的 Time Slicing(时间切片)特性; 4. 避免了浏览器兼容性和原生 API 饥饿问题。

MessageChannel 模拟实现 requestIdleCallback

function requestIdleCallbackOfMessageChannel() {
return (cb, { timeout } = {}) => {
const start = performance.now();
const channel = new MessageChannel();
channel.port2.onmessage = () => {
const now = performance.now();
const didTimeout = timeout && now - start >= timeout;
if (didTimeout || now - start < 50) {
cb({
didTimeout,
timeRemaining() { return Math.max(0, 50 - (now - start)); },
});
} else {
channel.port1.postMessage(undefined);
}
};
channel.port1.postMessage(undefined);
};
}

setTimeout 模拟实现 requestIdleCallback

function requestIdleCallbackOfSetTimeout() {
return (cb, { timeout } = {}) => {
const start = performance.now();

const callbackWrapper = () => {
const now = performance.now();
const didTimeout = timeout && now - start >= timeout;
if (didTimeout || now - start < 50) {
cb({
didTimeout,
timeRemaining() { return Math.max(0, 50 - (now - start)); },
});
} else {
window.setTimeout(callbackWrapper);
}
};

window.setTimeout(callbackWrapper);
};
}