React 理念
我们认为,React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。它在 Facebook 和 Instagram 上表现优秀。
作为一个构建用户页面的JS库,React最近的几次大版本更新都围绕着快速 快速响应 这个目标, 不断优化用户的使用体验。 以下面例子为例,我们渲染 4500 个 span
标签。
class App extends React.Component {
state = { number: 0 }
render(){
const { number } = this.state;
return (
<div>
{
Array.from(new Array(4500)).map((item, index) => {
return <span id={index}>{number}</span>
})
}
</div>
)
}
}
(旧版本渲染 4500 个span )
(新版本渲染 4500 个 span )
可以看到使用旧版本的React在渲染 4500个span 时 JS 连续执行了 140ms 才完成。 而我们都知道 JS 是单线程的,执行大量运算的占用 JS 的同时,也必然会造成页面的卡顿。也就 违背了 React 快速响应 的理念。
而新版本 React 渲染大量的 span 标签时,将一个巨大的渲染任务拆成了许多 微型个task(5ms 左右)。React 会在浏览器的每一帧(16ms)中执行一个 task, 随后将剩余时间留出完成页面的其他操作。这样就解决了浏览器页面的卡顿问题。
React 是如何实现上面的功能的呢? 我想主要是以下几点:
- 将之前的 Virtual DOM 替换成 全新的 Fiber 结构
- 基于 Fiber架构 和 Scheduel 实现了异步可中断的更新
- React 内部的 优先级及Lane 机制出色的完成了 React 中各种任务的调度。
本文是我 学习React源码 后的第一篇文章。我会以尽量易懂的例子为大家介绍上面几点的具体含义。并在后面的文章中进行源码级别的讲解。
传统的 VDOM
先简单介绍一下VDOM。vdom是虚拟DOM(Virtual DOM)的简称,指的是用JS模拟的DOM结构,将DOM变化的对比放在JS层来做。VDOM的优势在于将 DOM 的 diff 操作放到 JS 内存中,同时可以做到 DOM 的复用。减少不必要的重绘从而提高效率。
我们来简单思考一个将 VDOM 渲染成 DOM 的 diff 流程。
React 会从根节点开始不断的 diff 新旧 VDOM,如果节点相同,则继续 递归 diff 子节点。如果不相同,将旧 DOM 的旧 DOM 全部删除,并渲染出新的 DOM 。这样只需要对树进行一次遍历,便能完成整个 DOM 树的比较。
使用 Virtual DOM 的缺点在于使用了深度优先的 递归 方式来比较 DOM 与其对应的子节点。一旦进入递归,便无法暂停。如果遇到了 VDOM 树十分庞大( 如上文 4500 个 span ) 的情况,浏览器线程便一直被 diff 函数所阻塞住。
最后简单总结一下 React15 使用的 VDOM 更新流程。 VDOM 的更新流程可以理解如下 :从根节点开始以深度优先的方式 DIFF 新老节点, 找到差异后立刻在对应的 DOM 上进行更新。
全新的 Fiber 架构
关于 Fiber 架构,后续会产出 3-5 篇文章进行讲解。
为了解决 VDOM 不可中断的问题, React 使用了全新的 Fiber 架构, 用 Fiber 代替 Vnode 。为了实现 异步可中断 的更新使用了双缓存机制。
Fiber 是什么
Fiber 也是一种 DOM 结构的 JS 实现。你可以将其理解为 React 使用 Fiber 机构后对 VDOM 的一种全新定义。 Fiber 其实也是一种 VDOM。举个例子, 组件 APP 的定义如下。
function App() {
return (
<div>
i am
<span>KaSong</span>
</div>
)
}
在 Fiber 中,使用 child 属性代表 该节点的字节点。 Sibling 表示兄弟节点, return 表示父节点。这样的结构有利于实现 Fiber 结构中可中断的遍历。
Fiber 架构中的更新流程
对比 React15 中 VDOM 的 更新流程, 为了解决 diff 流程不可中断的问题,Fiber 架构中将整个更新流程分为了两个阶段。
- Render阶段: 异步可中断的 diff 新老 DOM,找到差异后并不及时立刻更新。而是对该 Fiber 节点打上一个
tag(Update, Placement, Delete)
。 - Commit阶段: 遍历存在 tag 的 Fiber ,根据 tag 的类型执行对应的 DOM 更新。
新架构的优势在于将 diff 和 渲染的流程分开。并基于 Schedule 实现异步可中断,解决了 复杂运算 大量占用 JS线程 的问题。
双缓存机制
双缓冲机制是React管理更新工作的一种手段,也是提升用户体验的重要机制。双缓存机制的主要概念为当React开始更新工作之后,会在 JS内存 中保存两棵 JS树。 分别为 current 树和 workInProgress 树。
- current树: 为当前页面渲染的DOM 结构对应的 DOM 树。
- workInProgress树: 由 current 树复制出来的树。diff 过程中对 fiber 的操作主要在 workInProgress 树中进行。
Commit 阶段开始时,
fiberRootNode
节点current
指针指向左边的的Root
树,此时左边的Root
树为current
树。current
树alternate
指向的为workInProgress
树。 示意如下
当 commit 阶段即将结束时, React 会通过 WorkInProgress 树重新渲染一次 DOM。 并将 fiberRootNode
的 current
节点指向右边的树,这样就切换了双缓存机制中的 current
树和 workInProgress
树。
function commitRootImpl(root, renderPriorityLevel) {
root.current = finishedWork;
}
同时,双缓存机制的一个重要作用为将 DOM 的计算放在了 workInProgress
树中。在current
树保存了浏览器当前渲染的 VDOM 。 可以做到新老 DOM 的无缝切换。
基于 Schedule 实现的异步可中断
这里只是一个 Schedule 的简单原理分析, 后续会输出一片专门的文章进行讲解。
从上面的介绍可知, Fiber 结构的核心在于实现了commit阶段的异步可中断。那什么是异步可中断呢?这里以一个简单的例子进行解释。
假设想使用 JS 计算从 1 - 3000 的相加结果。 但是在 JS 运算中, 相加是一个非常 "耗时" 的操作,每次执行相加操作都需要使用 1ms 的时间。造成在计算过程中 JS 进程被占用,没法响应其他的事件造成了卡顿。
const add = (a, b) => {
sleep(1);
return
}
let count = 0;
for(let i = 1; 1 <= 100; i++){
count = add(count, i);
}
React 内部实现了一个 Schedule, 在一个浏览器渲染帧中使用 5ms 进行运算,其余时间用于进行 点击时间处理等其他操作。 这里参考 React Schedule 思路实现了add 函数
const syncSleep = (time: number) => {
const start = new Date().valueOf();
while (new Date().valueOf() - start < time) {}
}
// 简易实现的 add
const add = (a: number, b: number) => {
syncSleep(1);
return a + b;
}
// 实现一个 累加函数 ,当计算未达到边界状态时, 返回下一个计算函数。 否则返回 null
const Accumulate = () => {
let count = 0;
let i = 1;
let ac = () => {
if(i <= 200){
console.log(i)
count = add(count, i);
i++;
return ac
}else{
// 任务结束
console.log("count:", count)
}
return null
}
return ac;
}
// 利用 MessageChannel 的 onMessage 触发 JS 的宏任务
// 为什么使用 利用 MessageChannel 而不使用 setTimeout 是因为 setTimeout 的 4ms bug
// https://juejin.cn/post/6846687590616137742 , 而 4ms 在一个 JS 浏览器渲染帧中
// 影响是比较大的
const channel = new MessageChannel();
// 当前 JS 浏览器渲染帧中任务的过期时间
let expireTime!: number;
// 累加函数的下一个任务
let nextTask!: Function; = Accumulate()
const workLoop = (task: Function) => {
let taskForNextTime = task;
// 如果当前时间大于过期时间,结束 while 循环
while(new Date().valueOf() < expireTime && task){
taskForNextTime = task();
}
return taskForNextTime;
}
// handleWorkStart 会在一个新的 JS 浏览器渲染帧 触发
const handleWorkStart = () => {
console.log("一个新的 JS 浏览器渲染帧")
// 设置一个过期时间, 过期时间为当前时间 +5 ,
// new Date().valueOf() 的表现不好,实际应该使用 performance
expireTime = new Date().valueOf() + 5;
// 执行 workLoop,如果任务完成,nextTask 为 null ,否则为一个可执行的函数
nextTask = workLoop(nextTask);
// 如果还有任务未完成,使用 postMessage,
// 会在下一个 JS 浏览器渲染帧继续触发 handleWorkStart
if(nextTask){
channel.port2.postMessage(null)
}
}
// 收到 postMessage 时触发 handleWorkStart
channel.port1.onmessage = handleWorkStart
// 模拟触发一次 累加任务
channel.port2.postMessage(null)
如果你把 Accumulate
函数替换成 React 中处理 Fiber节点的对应函数,那么这就变成了一个 React Schedule 的简单实现。
Schedule
是一个 React 中相对独立的一个模块。与 React 无关,是一种通用的设计思想。如果遇到了利用JS
进行大规模运算阻塞浏览器线程的时候,可以考虑一下Schedule
的实现并加以改造。
React 内部的优先级
(React 优先级的比较的流程图)
在 React 里,如 ClassComponent
中的setState/forceUpdate
。functionComponent
中的 useState/useReducer
都可以触发一次组件的 render。React 使用了 Update
的数据结构兼容这些情况,上方提到的方法内部都创建一个 Update
对象。
export type Update<State> = {
eventTime: number,
lane: Lane,
tag: 0 | 1 | 2 | 3,
payload: any,
callback: (() => mixed) | null,
next: Update<State> | null,
};
Update 中在 lane 存储当前更新的优先级。Update 利用二进制将优先级分为以下 31 种。从优先级的名字以及对应的位来看,越靠右优先级越高。且高优先级的 Update 会打断低优先级的 Update。
export const NoLanes: Lanes = /* */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /* */ 0b0000000000000000000000000000000;
export const SyncLane: Lane = /* */ 0b0000000000000000000000000000001;
export const SyncBatchedLane: Lane = /* */ 0b0000000000000000000000000000010;
export const InputDiscreteHydrationLane: Lane = /* */ 0b0000000000000000000000000000100;
const InputDiscreteLanes: Lanes = /* */ 0b0000000000000000000000000011000;
const InputContinuousHydrationLane: Lane = /* */ 0b0000000000000000000000000100000;
const InputContinuousLanes: Lanes = /* */ 0b0000000000000000000000011000000;
export const DefaultHydrationLane: Lane = /* */ 0b0000000000000000000000100000000;
export const DefaultLanes: Lanes = /* */ 0b0000000000000000000111000000000;
const TransitionHydrationLane: Lane = /* */ 0b0000000000000000001000000000000;
const TransitionLanes: Lanes = /* */ 0b0000000001111111110000000000000;
const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000;
export const SomeRetryLane: Lanes = /* */ 0b0000010000000000000000000000000;
export const SelectiveHydrationLane: Lane = /* */ 0b0000100000000000000000000000000;
const NonIdleLanes = /* */ 0b0000111111111111111111111111111;
export const IdleHydrationLane: Lane = /* */ 0b0001000000000000000000000000000;
const IdleLanes: Lanes = /* */ 0b0110000000000000000000000000000;
export const OffscreenLane: Lane = /* */ 0b1000000000000000000000000000000;
举个例子,有以下 demo 。在componentDidMount
1000ms 时触发 this.setState({ number: 1 })
, 后在 1040 ms
后触发一次 btn 的 click 事件。
class App extends React.Component {
state = { number: 0 }
btnRef = null;
handleClick = () => {
this.setState((prev) => ({ number: prev.number + 2 }))
}
componentDidMount(){
setTimeout(() => {
this.setState({ number: 1 })
},1000)
setTimeout(() => {
console.log("btn click")
this.btnRef.click();
}, 1040 )
}
render(){
const { number } = this.state;
return (
<div>
<button
ref={(ref) => { this.btnRef = ref }}
onClick={this.handleClick}
>
add 2
</button>
<div>
{
Array.from(new Array(4500)).map((item, index) => {
return <span id={index}>{number}</span>
})
}
</div>
</div>
)
}
}
流程如下:
- 1000 ms 时触发 setState , 创建一个
lane
为DefaultLanes: 二进制值为 0b0000000000000000000111000000000
的Update1
。 - 浏览器
Update1
对应的更新。 - 1040 ms 触发 onclick 事件,创建一个
lane
为InputDiscreteLanes: 二进制值为0b0000000000000000000000000011000
的Update2
。 - 此时 1000ms 时创建的
Update
还没有处理完。比较优先级发现Update2
优先级高于Update1
。放弃Update1
,优先先处理 1040ms 触发的Update2
- 完成
Update2
后, 浏览器调度剩余的任务, 处理Update1
。
React 内部的优先级让 React 能优先处理更为紧急的事情,如点击事件/输入时间就应该优先与普通的时间处理 。 这非常符合 React 快速响应的目标!关于内部优先级的实际实现会在后面详细讲解。
参考链接
- React技术揭秘: react.iamkasong.com/
- React原理解析系列文章: juejin.cn/post/691707…
- React Scheduler 为什么使用 MessageChannel 实现: juejin.cn/post/695380…