React 设计原理【送书】
文末送书
大家好,我是童欧巴。喜迎新年,给堂友们搞几波送书福利,在此感谢博文视点大力支持,具体送书规则请移步文末。
React 从 v15 升级到 v16 后重构了整个架构,v16 及以上版本一直沿用新架构,重构的主要原因在于:旧架构无法实现 Time Slice。
01
新旧架构介绍
React15 架构可以分为两部分:
Reconciler(协调器)——VDOM 的实现,负责根据自变量变化计算出 UI 变化。
Renderer(渲染器)——负责将 UI 变化渲染到宿主环境中。
在 Reconciler 中,mount 的组件会调用 mountComponent,update 的组件会调用updateComponent,这两个方法都会递归更新子组件,更新流程一旦开始,中途无法中断。
基于这个原因,React16 重构了架构。重构后的架构一直沿用至今,可以分为 3 部分:
Scheduler(调度器)——调度任务的优先级,高优先级任务优先进入 Reconciler。
Reconciler(协调器)——VDOM 的实现,负责根据自变量变化计算出 UI 变化。
Renderer(渲染器)——负责将 UI 变化渲染到宿主环境中。
在新架构中,Reconciler 中的更新流程从递归变成了“可中断的循环过程”。每次循环都会调用 shouldYield 判断当前 Time Slice 是否有剩余时间,没有剩余时间则暂停更新流程,将主线程交给渲染流水线,等待下一个宏任务再继续执行,这就是 Time Slice的实现原理:
function workLoopConcurrent() {
// 一直执行任务,直到任务执行完或中断
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
shouldYield 方法如下:
function shouldYield() {
// 当前时间是否大于过期时间
// 其中 deadline = getCurrentTime() + yieldInterval
// yieldInterval 为调度器预设的时间间隔,默认为 5ms
return getCurrentTime() >= deadline;
}
过期时间 deadline 在任务执行时被更新为“当前时间+时间间隔”,时间间隔默认为5ms,这也是图 2-3 中每个 Time Slice 宏任务的时间长度是 5ms 左右的原因。
当 Scheduler 将调度后的任务交给 Reconciler 后,Reconciler 最终会为 VDOM 元素标记各种副作用 flags,比如:
// 代表插入或移动元素
export const Placement = 0b00000000000000000000000010;
// 代表更新元素
export const Update = 0b00000000000000000000000100;
// 代表删除元素
export const Deletion = 0b00000000000000000000001000;
Scheduler 与 Reconciler 的工作都在内存中进行。只有当 Reconciler 完成工作后,工作流程才会进入 Renderer。
Renderer 根据“Reconciler 为 VDOM 元素标记的各种 flags”执行对应操作,比如,如上三个 flags 在浏览器宿主环境中对应三种 DOM 操作。
下面的示例1演示了上述三个模块如何配合工作:count 默认值为 0,每次点击按钮执行 count++,UL 中三个 LI 的内容分别为“1、2、3 乘以 count 的结果”。
示例1:
export default () => {
const [count, updateCount] = useState(0);
return (
<ul>
<button onClick={() => updateCount(count + 1)}>乘以{count}</button>
<li>{1 * count}</li>
<li>{2 * count}</li>
<li>{3 * count}</li>
</ul>
)
}
对应工作流程如图1所示。
虚线框中的工作流程随时可能由于以下原因被中断:
有其他更高优先级任务需要先执行;
当前 Time Slice 没有剩余时间;
发生错误。
图 1 新 React 架构工作流程示例
由于虚线框内的工作都在内存中进行,不会更新宿主环境 UI,因此即使工作流程反复中断,用户也不会看到“更新不完全的 UI”。
02
主打特性的迭代
随着 React 架构的重构,上层主打特性也随之迭代。按照“主打特性”划分,React大体经历了四个发展时期:
(1)Sync(同步);
(2)Async Mode(异步模式);
(3)Concurrent Mode(并发模式);
(4)Concurrent Feature(并发特性)
其中,旧架构对应同步时期。异步模式、并发模式、并发特性三个时期与新架构相关。本节主要讲解异步模式、并发模式、并发特性的演进过程。
之前曾提到“CPU 瓶颈”与“I/O 瓶颈”,React 并不是同时解决这两个问题的。首先解决的是“CPU 瓶颈”,解决方式是“架构重构”。重构后Reconciler 的工作流程从“同步”变为“异步、可中断”。正因如此,这一时期的 React被称为 Async Mode。
单一更新的工作流程变为“异步、可中断”并不能完全突破“I/O 瓶颈”,解决问题的关键在于“使多个更新的工作流程并发执行”。所以,React 继续迭代为 Concurrent Mode(并发模式)。在 React 中,Concurrent(并发)概念的意义是“使多个更新的工作流程可以并发执行”。
以上便是从 Sync 到 Async Mode 再到 Concurrent Mode 的演进过程。下一节将讲解从 Concurrent Mode 到 Concurrent Feature 的演进过程。
03
渐进升级策略的迭代
从最初的版本到 v18 版本,React 有多少个版本?从架构角度进行概括,所有 React版本一定属于如下四种情况之一。
情况 1:旧架构(v15 及之前版本属于这种情况)。
情况 2:新架构,未开启并发更新,与情况 1 行为一致(v16、v17 默认属于这种情况)。
情况 3:新架构,未开启并发更新,但是启用了一些新功能(比如 AutomaticBatching)。
情况 4:新架构,已开启并发更新。
React 团队希望:使用旧版本的开发者可以逐步升级到新版本,即从情况 1、2、3向情况 4 升级。但是升级过程中存在较大阻力,因为在情况 4 下,React 的一些行为与情况 1、2、3 不同。比如以下三个生命周期函数在情况 4 的 React 下是“不安全的”:
componentWillMount
componentWillReceiveProps
componentWillUpdate
强制升级可能造成代码不兼容。为了使 React 的新旧版本之间实现平滑过渡,React团队采用了“渐进升级”方案。该方案的第一步是规范代码。v16.3 新增了 StrictMode,针对开发者编写的“不符合并发更新规范的代码”给出提示,逐步引导开发者编写规范代码。比如,使用上述“不安全的”生命周期函数时会产生如图2所示的报错信息。
图2 StrictMode 下使用不安全生命周期函数报错
下一步,React 团队允许“不同情况的 React”在同一个页面共存,借此使“情况 4的 React”逐步渗透至原有项目中。具体做法是提供了以下三种开发模式:
Legacy 模式,通过 ReactDOM.render(<App />, rootNode)创建的应用遵循该模式。默认关闭 StrictMode,表现同情况 2。
Blocking模式 通过 ReactDOM.createBlockingRoot(rootNode).render(<App />)创建的应用遵循该模式,作为从 Legacy 向 Concurrent 过渡的中间模式,默认开启StrictMode,表现同情况 3。
Concurrent 模式,通过 ReactDOM.createRoot(rootNode).render(<App />)创建的应用遵循该模式,默认开启 StrictMode,表现同情况 4。
三种开发模式支持特性对比如图3所示
图3 三种开发模式支持特性对比
为了使不同模式的应用可以在同一个页面内工作,需要对一些底层实现进行调整。比如:调整之前,大多数事件会统一冒泡到 HTML 元素,调整后则冒泡到“应用所在根元素”。这些调整工作发生在 v17,所以v17 也被称作“为开启并发更新做铺垫”的“垫脚石”版本。
2021 年 6 月 8 日,v18 工作组成立。在与社区进行大量沟通后,React 团队意识到当前的“渐进升级”策略存在两方面问题。首先,由于模式影响的是整个应用,因此无法在同一个应用中完成渐进升级。举例说明,开发者将应用中 ReactDOM.render 改为ReactDOM.createBlockingRoot,从 Legacy 模式切换到 Blocking 模式,会自动开启StrictMode。此时,整个应用的“并发不兼容警告”都会上报,开发者需要修复整个应用中的不兼容代码。从这个角度看,“渐进升级”的目的并没有达到。
其次,React 团队发现:开发者从新架构中获益,主要是由于使用了并发特性,并发特性指“开启并发更新后才能使用的那些 React 为了解决 CPU 瓶颈、I/O 瓶颈而设计的特性”,比如:
useDeferredValue
useTransition
所以,React 团队提出新的渐进升级策略——开发者仍可以在默认情况下使用同步更新,在使用并发特性后再开启并发更新。
在 v18 中运行示例2所示代码,由于 updateCount 在 startTransition 的回调函数中执行(使用了并发特性),因此 updateCount 会触发并发更新。如果 updateCount 没有在startTransition 的回调函数中执行,那么 updateCount 将触发默认的同步更新。
示例2:
const App = () => {
const [count, updateCount] = useState(0);
const [isPending, startTransition] = useTransition();
const onClick = () => {
// 使用了并发特性 useTransition
startTransition(() => {
// 本次更新是并发更新
updateCount((count) => count + 1);
});
};
return <h3 onClick={onClick}>{count}</h3>;
};
读者可以调试在线示例中这两种情况的调用栈火焰图,根据火焰图中观察到的 “是否开启 Time Slice”来区分“是否是并发更新”。
所以,React 在 v18 中不再提供三种开发模式,而是以“是否使用并发特性”作为“是否开启并发更新”的依据。
具体来说,开发者在 v18 中统一使用 ReactDOM.createRoot 创建应用。当不使用并发特性时,表现如情况 3。使用并发特性后,表现如情况 4。
本文节选自卡颂的新书《React设计原理》,基于React18,从理念、架构、实现三个层面解构React
。
这本书存在两条脉络:
抽象层级逐渐降低
实现越来越复杂的模块
对于前者,本书的抽象层级会逐渐从理念到架构,最后到实现,每一层都屏蔽前一层的影响。
这也是为什么ReactDOM.createRoot
这个初始化API
会放到第六章再讲解 —— 在这个具体API
的背后,是他的理念与架构。
对于后者,本书会从0实现与react
相关的6个模块,最后我们会一起在React
源码内实现一个新的原生Hook
。
参与送书活动
堂友们,送书活动的参与规则很简单,在本文留言区与大家分享你最近吃过的美食,即可参与抽奖。
抽奖规则:随机抽 2 名堂友,获得本书的实体书,包邮到家。
截止时间:2023 年 1 月 19 日(周四) 中午 12 点,可能延后,以置顶留言为准,置顶留言即为中奖者。
如何兑奖:中奖的堂友我会主动联系你兑奖。如果 24h 内联系不上视为作废。也欢迎堂友们提前扫码添加我的微信 hungryturbo 以防失联。
▼点击阅读原文,了解本书详情~