要使用 React 渲染 UI,您应该首先执行以下步骤:
-
使用 createRoot 创建根对象。
-
调用 root.render(ui) 函数。
如下代码所示:
jsx
import * as React from "react";
import * as ReactDOM from "react-dom/client";
import reportWebVitals from "./reportWebVitals";
import App from "./App";
const root = ReactDOM.createRoot(document.getElementById("root"), {
unstable_concurrentUpdatesByDefault: true,
});
root.render(<App />);
reportWebVitals();
1;
Root 节点是 ReactDOM 树中的基本组成部分,对于每个 Root 节点,有 render()
方法使其渲染或更新,以及 unmount()
方法将该节点从 ReactDOM 树上卸载。
节点 render()
的渲染更新的具体步骤则是由 react-Reconciliation 来协调完成的。因此在 ReactDOM 中,只定义了一个 Root 节点在调用被 render()
时,需要作什么样的检查以及相应的错误处理与提示。随后再调用 react-Reconciliation 完成具体的渲染更新操作。
而 unmount()
则是将 Root 节点从 ReactDOM 树中卸载出去,同样需要作出相关的检查、更新节点状态,并最后在 ReactDOM 树中取消标记其为 Root 节点。
理解 render 函数
在 React 内部实现中,我们可以将 render 函数分为两个阶段:
-
渲染阶段
-
提交阶段
其中渲染阶段可以分为 beginWork 和 completeWork 两个阶段,而提交阶段对应着 commitWork。
渲染阶段
beginWork 是 Fiber 树的创建过程的开始阶段。在这个阶段,React 会遍历组件树,从根节点开始,逐层向下处理每个节点。这个阶段的主要任务是:
-
比较新旧 Fiber 树(即 Reconciliation),找出变化的部分。
比较新旧 Fiber 树(即 Reconciliation)是 React 中虚拟 DOM 处理的关键阶段。Reconciliation 的主要任务是找出新旧虚拟 DOM 树的差异,并将这些差异最小化地更新到实际的 DOM 树中。这个过程发生在 beginWork 和 completeWork 阶段。
Reconciliation 过程主要发生在 beginWork 阶段。具体步骤如下:
-
创建新 Fiber 对象:React 从根节点开始,遍历新的虚拟 DOM 树,为每个节点创建对应的 Fiber 对象。
-
比较新旧 Fiber 树:对于每个节点,React 比较新旧 Fiber 对象,以确定节点是否发生了变化。这一过程称为
diffing
。 -
生成变更列表:在比较过程中,React 会生成一份变更列表,记录需要对实际 DOM 进行的插入、更新和删除操作。
-
-
为每个组件创建或更新 Fiber 对象。
-
根据组件的类型(函数组件、类组件、原生组件等)执行相应的渲染逻辑。
-
对于类组件,调用 render 方法获取子元素;对于函数组件,调用函数得到子元素。
-
为每个节点生成子 Fiber 对象,并将它们连接到当前 Fiber 树中。
completeWork 是 Fiber 树的创建过程的完成阶段。在这个阶段,React 会从叶子节点开始,逐层向上处理每个节点。这个阶段的主要任务是:
-
确定每个节点及其子节点的最终属性和状态。
-
为原生组件生成 DOM 节点。
-
将生成的 DOM 节点插入到正确的位置。
-
完成对节点的任何必要的后处理工作。
通过将更新过程分为多个小步骤(即 beginWork 和 completeWork),React 可以在每一帧渲染时处理一小部分更新,从而避免长时间的阻塞。
提交阶段
commitWork 是 Fiber 树更新过程的提交阶段。在这个阶段,React 会将已完成的 Fiber 树变更应用到实际的 DOM 树中。这个阶段的主要任务是:
-
将更新后的 DOM 节点插入、更新或删除,确保浏览器中的 DOM 树与最新的 React 状态同步。
-
触发组件的生命周期方法(如 componentDidMount、componentDidUpdate)。
-
执行任何需要的副作用(如使用 useEffect 注册的副作用)。
render()
render 是一个方法,它是 ReactDOMRoot 的原型方法,它的主要代码如下所示:
ts
ReactDOMHydrationRoot.prototype.render = ReactDOMRoot.prototype.render =
function (children: ReactNodeList): void {
debugger;
const root = this._internalRoot;
if (root === null) {
throw new Error("Cannot update an unmounted root.");
}
updateContainer(children, root, null, null);
};
在这个方法里面主要是将 _internalRoot
传递给了 updateContainer 进行函数调用。
updateContainer
updateContainer 是在 React 代码库中从多个地方调用的一个函数,你可能会想为什么它被称为 update(更新)而不是 render(渲染)或 mount(挂载)?这是因为 React 始终将树视为正在更新。React 可以知道树的哪一部分是第一次挂载,并会在每次执行必要的代码。
它的代码如下:
ts
// src/react/packages/react-reconciler/src/ReactFiberReconciler.old.js
export function updateContainer(
element: ReactNodeList,
container: OpaqueRoot,
parentComponent: ?React$Component<any, any>,
callback: ?Function
): Lane {
// 获取当前的rootFiber对象
const current = container.current;
// 获取程序运行到目前为止的时间,用于进行优先级排序
const eventTime = requestEventTime();
// 同步直接返回 `SyncLane` = 1。以后开启并发和异步等返回的值就不一样了,目前只有同步这个模式
const lane = requestUpdateLane(current);
if (enableSchedulingProfiler) {
// 在Performance接口中标记--schedule-render-{当前lane}用于性能分析
markRenderScheduled(lane);
}
// 获取当前节点和子节点的上下文
const context = getContextForSubtree(parentComponent);
if (container.context === null) {
container.context = context;
} else {
container.pendingContext = context;
}
// 创建一个 update 对象
const update = createUpdate(eventTime, lane);
// 记录update的载荷信息
update.payload = { element };
// 如果有回调信息,保存
callback = callback === undefined ? null : callback;
if (callback !== null) {
update.callback = callback;
}
// 将新建的update入队
const root = enqueueUpdate(current, update, lane);
if (root !== null) {
scheduleUpdateOnFiber(root, current, lane, eventTime);
entangleTransitions(root, current, lane);
}
return lane;
}
这个函数做了很多事情,在首次挂载树以及后续更新时都会使用它。从 root.render
调用时,最后两个参数传递为 null,这意味着它们未被使用。
这个函数接收的容器并不是你传递给 createRoot 的 DOM 元素。这个容器是 root._internalRoot
,它是一个 FiberRootNode。
如果你还记得之前的文章,container.current 属性的类型是 FiberNode,这是我们目前应用程序中创建的唯一 Fiber,而 element 是 React 节点列表或者说组件树。
ts
const current = container.current;
requestUpdateLane
下一步要做的事情就是请求当前 Fiber 更新车道(lane),用于确定更新的优先级。对于同步模式,返回 SyncLane(值为 1)。
js
const lane = requestUpdateLane(current);
requestUpdateLane 函数如下代码所示:
ts
export function requestUpdateLane(fiber: Fiber): Lane {
const mode = fiber.mode;
if ((mode & ConcurrentMode) === NoMode) {
// concurrent 模式
return (SyncLane: Lane);
} else if (
!deferRenderPhaseUpdateToNextBatch &&
(executionContext & RenderContext) !== NoContext &&
workInProgressRootRenderLanes !== NoLanes
) {
/**
* 当新的更新任务产生时,workInProgressRootRenderLanes不为空,则表示有任务正在执行
* 那么则直接返回这个正在执行的任务的lane,那么当前新的任务则会和现有的任务进行一次批量更新
*/
return pickArbitraryLane(workInProgressRootRenderLanes);
}
/**
* 检查当前事件是否为过渡优先级,例如使用了 Suspense 或者 useTransition
* 每次调用优先级都会降低
* 过渡优先级共有16位:当所有位都使用完后,则又从第一位开始赋予事件过渡优先级
*/
const isTransition = requestCurrentTransition() !== NoTransition;
if (isTransition) {
if (currentEventTransitionLane === NoLane) {
currentEventTransitionLane = claimNextTransitionLane();
}
console.log('当前优先级',currentEventTransitionLane);
return currentEventTransitionLane;
}
/**
* 返回当前任务优先级
* updateLane 优先级为 0
*/
const updateLane: Lane = (getCurrentUpdatePriority(): any);
if (updateLane !== NoLane) {
return updateLane;
}
/**
* 返回当前事件的优先级
* 如果没有事件返回,则返回 DefaultEventPriority
* 如果有,根据事件类型返回优先级
* eventLane 优先级为16
*/
const eventLane: Lane = (getCurrentEventPriority(): any);
return eventLane;
}
该函数最终返回的 eventLane 的值为 16 表示 DefaultLane 也就是默认优先级。
这个函数的主要作用是根据当前的运行环境、模式和事件,确定并返回适当的优先级车道(Lane)。优先级的确定涉及到并发模式、当前正在执行的任务、过渡优先级和事件优先级等多个因素。
React 使用过渡优先级来处理一些非紧急的任务,例如使用 Suspense
或者 useTransition
触发的任务。这些任务通常不会立即影响用户体验,所以它们可以被推迟到浏览器空闲时执行。
React 的过渡优先级共有 16 个(用 16 位二进制表示),每次调用时,React 会选择一个未被使用的位作为当前任务的优先级。一旦所有 16 个优先级位都被使用过,React 会重新从第一位开始循环使用。这就意味着随着每次调用,分配给任务的优先级会逐渐降低,直到循环使用的位被重新分配。
createUpdate
接下来代码继续往下执行,它会使用 createUpdate 函数创建一个 update 对象
js
const update = createUpdate(eventTime, lane);
update.payload = { element };
在 React 中,update 对象表示一次更新操作,包含 payload 属性用于存储更新的数据,这些 update 对象被添加到更新队列中,React 会遍历这个队列应用更新,从而生成新的组件状态。
如下代码所示:
js
function createUpdate(eventTime, lane) {
return {
eventTime,
lane,
payload: null,
};
}
const update = createUpdate(Date.now(), someLane);
update.payload = { element: <div>Moment</div> };
调用 createUpdate 函数创建了一个新的 update 对象。update.payload 被设置为 { element: <div>Moment</div> }
,这意味着这次更新将会把 element 更新为 <div>Moment</div>
。
enqueueUpdate
update 对象创建完成之后,接着是将新建的 update 入队:
js
// 将新建的update入队
const root = enqueueUpdate(current, update, lane);
enqueueUpdate 函数如下定义:
js
export function enqueueUpdate<State>(
fiber: Fiber,
update: Update<State>,
lane: Lane
): FiberRoot | null {
const updateQueue = fiber.updateQueue;
if (updateQueue === null) {
// fiber 被卸载时
return null;
}
// 返回一个对象 {interleaved:null, lanes:0, pending:null}
const sharedQueue: SharedQueue<State> = (updateQueue: any).shared;
// pending 永远指向最后一个更新
if (isUnsafeClassRenderPhaseUpdate(fiber)) {
const pending = sharedQueue.pending;
if (pending === null) {
// 第一次更新创建一个循环单链表
update.next = update;
} else {
// 如果更新队列不为空,取出第一个更新
update.next = pending.next;
pending.next = update;
}
sharedQueue.pending = update;
return unsafe_markUpdateLaneFromFiberToRoot(fiber, lane);
} else {
return enqueueConcurrentClassUpdate(fiber, sharedQueue, update, lane);
}
}
在 enqueueUpdate 函数中,首先从 fiber 对象中获取 updateQueue,如果 updateQueue 为空,说明该 fiber 已经被卸载,直接返回 null。
此时 updateQueue 里面的属性基本显示全为空的状态。
接下来是获取共享队列:
js
const sharedQueue: SharedQueue<State> = (updateQueue: any).shared;
从 updateQueue 中获取共享队列 sharedQueue。sharedQueue 是一个对象,包含三个属性:interleaved、lanes 和 pending。
此时代码进入到最后阶段:
js
if (isUnsafeClassRenderPhaseUpdate(fiber)) {
const pending = sharedQueue.pending;
if (pending === null) {
// 第一次更新创建一个循环单链表
update.next = update;
} else {
// 如果更新队列不为空,取出第一个更新
update.next = pending.next;
pending.next = update;
}
sharedQueue.pending = update;
return unsafe_markUpdateLaneFromFiberToRoot(fiber, lane);
} else {
return enqueueConcurrentClassUpdate(fiber, sharedQueue, update, lane);
}
-
如果 fiber 处于不安全的类渲染阶段,进入这个分支。
-
sharedQueue.pending 永远指向最后一个更新。
-
如果 pending 为空,说明这是第一个更新,需要创建一个循环单链表,将 update.next 指向 update 自己。
-
如果 pending 不为空,取出第一个更新并插入新的更新,使其成为循环单链表的一部分。
-
更新 sharedQueue.pending 指向新的 update。
-
调用 unsafe_markUpdateLaneFromFiberToRoot 标记更新从 fiber 到根 Fiber,并返回根 Fiber。
在初始的状态,sharedQueue.pending
为 null。第一次更新的时候 sharedQueue.pending
指向 update1,并且 update1.next 指向 update1 自己,形成一个循环链表。
第二次更新的时候 sharedQueue.pending
更新为指向 update2,update2.next 指向 update1,update1.next 指向 update2,形成新的循环链表。
enqueueConcurrentClassUpdate
这段代码定义了一个函数 enqueueConcurrentClassUpdate,用于将类组件的更新加入到并发更新队列中。
js
export function enqueueConcurrentClassUpdate<State>(
fiber: Fiber,
queue: ClassQueue<State>,
update: ClassUpdate<State>,
lane: Lane
) {
const interleaved = queue.interleaved;
if (interleaved === null) {
update.next = update;
pushConcurrentUpdateQueue(queue);
} else {
update.next = interleaved.next;
interleaved.next = update;
}
queue.interleaved = update;
return markUpdateLaneFromFiberToRoot(fiber, lane);
}
interleaved 是指向当前队列中的交错更新链表。如果 interleaved 为 null,表示这是第一个更新。
-
将 update.next 指向自身,形成一个循环链表。
-
调用 pushConcurrentUpdateQueue(queue),将此队列的交错更新推到全局的并发更新队列中,以便在当前渲染结束时处理这些更新。
如果 interleaved 不是 null,则表示已经有更新存在队列中:
-
将新更新的 next 指向当前队列的下一个更新。
-
将当前更新的 next 设置为新更新,使其插入到链表中。
最后,将 queue.interleaved 指向新更新,使其成为链表中的最新节点。
你看,全都是一模一样的,那么为什么有了 update,还要 queue 呢?
为什么有了 update,还要 queue 呢
在 React 的更新机制中,update 和 queue 都是管理组件状态更新的关键概念,但它们的职责和作用有所不同:
-
update 是一个表示单个状态更新的对象。它包含了具体的更新内容,比如新的状态值或更新函数。每次调用 setState 或 forceUpdate,都会创建一个新的 update 对象。
-
queue 是一个状态更新队列,用于管理多个 update 对象。在类组件中,queue 通常包含一个链表,用于按顺序存储多个 update 对象。queue 负责协调和管理这些更新,确保它们按正确的顺序和时间被处理。
queue 可以将多个 update 对象链接起来,形成一个更新链表。这样可以一次性处理多个更新,提高性能。它负责协调这些更新的执行顺序,确保它们按正确的顺序被处理。例如,如果有多个 setState 调用,它们会被依次添加到 queue 中,并按顺序处理。
并且 queue 允许 React 在必要时进行批量更新。这意味着在某些情况下,多个状态更新可以被合并为一次更新,减少不必要的重新渲染。
假设我们有一个计数器组件,在按钮点击事件中,我们会多次更新状态,但是我们希望这些状态更新能够批量处理,只触发一次重新渲染。
jsx
import React, { useState, useEffect } from "react";
const Counter = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
// 多次调用 setCount 更新状态
setCount((prevCount) => {
console.log("Update 1:", prevCount + 1);
return prevCount + 1;
});
setCount((prevCount) => {
console.log("Update 2:", prevCount + 1);
return prevCount + 1;
});
setCount((prevCount) => {
console.log("Update 3:", prevCount + 1);
return prevCount + 1;
});
};
useEffect(() => {
console.log("Effect:", count);
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</div>
);
};
export default Counter;
最终结果如下图所示:
这就是我们经常所谈及的批处理,多次同时调用,它会在一个事件循环中将多次状态更新合并成一次更新,以提高性能。在上面的代码示例中,我们调用了三次 setCount,每次都增加 1。由于这些调用是在同一个事件处理函数中发生的,React 会将它们合并成一次更新。因此,count 将从 0 增加到 3,而不是依次更新每次增加 1。
所以 useEffect 这个 hook 只执行了一次。
这个时候我们的代码要回到 enqueueConcurrentClassUpdate 中了,它调用 markUpdateLaneFromFiberToRoot(fiber, lane) 来标记从当前 Fiber 节点到根节点的更新优先级,并返回根节点。这确保了在整个更新过程中,所有相关节点都标记了正确的优先级,从而能在适当的时机进行更新。
最后又退回到 enqueueUpdate 函数中,该函数最终返回 enqueueConcurrentClassUpdate 函数的返回,将更新任务添加到并发类组件的更新队列中,并返回包含此更新的 Fiber 树的根。
在后续的代码中:
js
if (root !== null) {
scheduleUpdateOnFiber(root, current, lane, eventTime);
// 如果fiberRoot存在,则纠缠车道队列
entangleTransitions(root, current, lane);
}
就暂时不讲了,因为这已经要开始进入到 schedule ��度了,将会在后面的内容中讲解。
总的来说,updateContainer 函数负责将新的 React 元素树添加到更新队列中,并根据当前的上下文和优先级进行调度。它首先获取当前的 rootFiber 对象和事件时间,然后创建一个新的 update 对象,将新的 React 元素树记录到 update 中,并将其入队到 fiber 节点的更新队列中。最后,调度更新任务,并纠缠车道队列以确保更新能够正确地进行。
总结
ReactDOMRoot.prototype.render 是 React 的核心方法之一,它负责将 React 元素渲染到 DOM 中。在 React 18 及更高版本中,使用 createRoot API 创建的根节点上调用该方法,以启动和更新 React 应用。其主要任务是协调渲染过程,并触发必要的更新逻辑。
updateContainer 是 render 方法中的关键步骤,它执行以下任务:
-
获取当前 Fiber 节点:从容器中获取当前的 rootFiber 对象。
-
确定优先级:使用 requestUpdateLane 确定当前更新的优先级。
-
标记性能分析:如果启用了调度分析器(Scheduling Profiler),则进行标记。
-
获取上下文:获取当前节点和子节点的上下文。
-
创建更新对象:创建一个新的更新对象,包含更新的数据(新的 React 元素树)。
-
将更新入队:将新创建的更新对象入队,确保它被正确处理。
-
调度更新:调用 scheduleUpdateOnFiber 函数调度更新任务。
ReactDOMRoot.prototype.render 是 React 渲染过程中的关键方法。它通过调用 updateContainer 函数,协调组件树的更新,并确保更新任务被正确调度。