# React 理念
# react 理念
React 被设计用于 JavaScript 构建快速响应的大型 Web 应用程序。
由此可见,
快速响应
是 react 框架的关键
# 难点限制
需要解决快速响应的难点就要解决以下两种限制:
CPU 的瓶颈
遇到大量计算操作时,页面掉帧导致卡顿
function App() { const len = 3000; return ( <ul> { new Map(len).fill(0).map((item, index) => <li>index</li>); } </ul> ) }
这样当页面执行 js 代码时,由于渲染线程和 js 脚本执行互斥,所以页面迟迟没有渲染,造成卡顿
解决方案:
在浏览器每一帧的时间中,预留一些时间给 JS 线程,React 利用这部分时间更新组件(预留的初始时间是 5ms)。
预留的时间不够用时,react 将线程控制权交还给浏览器,使其有时间渲染 UI 界面。
这种将长任务拆分到每一帧中,每次执行一小段任务的操作成为时间切片
// 通过使用ReactDOM.unstable_createRoot开启Concurrent Mode,启用时间切片 // ReactDOM.render(<App/>, rootEl); ReactDOM.unstable_createRoot(rootEl).render(<App />);
解决 CPU 瓶颈的关键在于实现时间切片,而时间切片的关键是:
将同步的更新变为可中断的异步更新
。
IO 的瓶颈
发送网络请求后,需要等待数据返回才能进一步操作
网络延迟是无法解决的,如何在网络延迟客观存在的情况下,减少用于对网络延迟的感知?
如果请求数据和显示页面并行执行,就会造成页面的卡顿,而一旦先显示页面,后面请求数据再更新就会流畅很多。
react 采用的方案是:将界面 UI 的同步更新变为可中断的异步更新
# React15 架构
React15 的架构分为协调器和渲染器
# 协调器
- Reconciler 协调器负责找出变化的组件。
- React 中通过
this.setState
、this.forceUpdate
和ReactDOM.render
等 API 进行更新。每当有更新发生,协调器就会做如下工作:- 调用函数组件或者类组件的
render
方法,将返回的 JSX 转化为虚拟 DOM - 将虚拟 DOM 和上次更新时的虚拟 DOM 比对
- 通过比对找出本次更新中变化的虚拟 DOM
- 通知渲染器将比那花的虚拟 DOM 渲染到页面上
- 调用函数组件或者类组件的
# 渲染器
- React 支持跨平台,所以不同平台有不同的 Renderer 渲染器,浏览器环境是
ReactDOM
- Renderer 渲染器负责将变化的组件渲染到页面上
# 缺点
mount 的组件会同步递归更新子组件,造成 UI 卡顿。
在协调器 Reconciler 中,mount 的组件会调用 mountComponent,update 的组件会调用 updateComponent 方法,这两个方法都会递归更新子组件,而递归更新子组件是非常消耗性能的。
同步更新 UI 视图,界面卡顿
视图同步更新,并且 React15 不会中断进行中的更新
# React16 架构
React16 架构可以分为三层:
- Scheduler 调度器:负责调度任务的优先级,高优先级的任务先进入 Reconciler 进行协调
- Reconciler 协调器:找出变化的组件
- Renderer 渲染器:将变化的组件渲染到页面上
React16 架构相比 React15 添加了 Scheduler 调度器
# 调度器
首先以浏览器是否有剩余时间作为任务终端的标准,那么就需要一种机制,当浏览器有剩余时间时通知我们。可以使用
requestIdleCallback
这个 API,但是因为下面的原因被弃用了:- 浏览器兼容性(跨平台所不能容忍的)
- 触发频率不稳定,会受很多因素影响
Scueduler 调度器库由此诞生,并且独立于 React
# 协调器
React15 中的 Reconciler 协调器是同步递归处理虚拟 DOM 的,这样会造成 UI 视图层的卡顿。在 React16 中将更新工作从递归变成了可以中断的循环过程,每次循环都会调用
shouldYield
判断当前是否有剩余时间React16 解决中断更新时,DOM 渲染不完全的问题
React16 中,调度器和协调器不再是交替工作,当调度器将任务交给协调器之后,协调器会为变化的虚拟 DOM 打上增 / 删 / 改 的标记
整个工作都在内存中进行,只有当所有组件都完成协调的工作,才会统一交给渲染器进行渲染。
# 渲染器
Renderer 渲染器根据 Reconciler 为虚拟 DOM 打的标记,同步执行对应的 DOM 操作。
# 流程
- 例如:点击 button 按钮,触发更新
- 调度器受到更新,判断是否有凄然优先级更高的任务需要执行,如果没有就将组件状态改变,交给协调器
- 协调器接收到更新,将变化的虚拟 DOM 打上标记,交给渲染器
- 渲染器对打标记的 DOM 元素执行对应的操作
中间两个任务(调度器和协调器)随时都可能由于以下原因被中断:
有其他更高优先级的任务需要执行
当前帧没有剩余时间
由于调度器和协调器的工作都在内存中进行,不会更新到页面上,所以即使反复中断,对用户来说也无感
# Fiber 介绍
Fiber 其实是一个数据结构,需要记录节点和节点之间的关系,在 Fiber 架构中的任务是可以中断执行的,继续执行时会丢弃原来完成的工作从头再来(数据不可变的原因),并且即使重新执行中断的任务,对用户侧来说也是无感的,因为 Fiber 架构的视图更新是后缓冲区视图 替换 前缓冲区视图的过程。
顺便理解一下 componentWillMount、componentWillReceiveProps、componentWillUpdate 这三个钩子被废弃的原因:因为 render 阶段是异步执行、可被中断的,再次回来的时候有可能会丢弃已经完成的工作从头再来,这样就可能会导致 Fiber 节点在 render 阶段重复调用componentWillMount
这几个钩子,导致不符合开发者预期,并且当这几个钩子中存在副作用时,就会出现问题。
// Fiber 中关键字段 | |
{ | |
type, //DOM 类型 | |
key, //reconciler 在协调过程中,决定该 fiber 是否要复用 | |
child, // 组件 render 函数返回值 | |
sibling, | |
return, //return 属性指向父节点 | |
pendingProps, | |
memoizedProps, | |
pendingWorkPriority, | |
alternate, | |
... | |
} |
# Fiber 架构
Fiber 架构出现的原因:
由于 JS 单线程执行,并且 React15 版本的 reconciler 协调阶段只能一次执行完,不能被中途打断,这就导致:当更新渲染的执行任务较多时,协调阶段一次执行的时间花费就很长,这时就会出现掉帧、卡顿的现象。由此推出 Fiber 架构
Fiber 架构实现逻辑:
Fiber 架构借鉴生成器 (或者说协程) 的概念,将任务分给一个个的迭代器执行,迭代器完成执行任务的过程发生在浏览器空闲阶段,这样就不会阻塞 UI 的渲染。(由于 RequestIdleCallback 存在兼容性问题、并且在大量用户操作的时候不够稳定,因此使用 RequestAnimationFrame 和 disPatchEvent 消息队列来完成)
# 双缓冲更新机制
双缓冲更新机制就是:在视图需要进行更新时,通过在后台更新新的 fiber 树,如果遇到优先级更高的任务,就暂停当前的执行 (conscrent 模型) 任务,此时,即使更新到一半,对于用户侧来说也是无影响的,当整个新的 fiber 树构建完成之后,才会去用新的 fiber 树替换掉旧的 fiber 树.
# Fiber 架构心智模型
# 代数效应
- 代数效应是函数式编程中的概念,用于
将副作用和函数相分离
。 - 代数效应能够将副作用从函数逻辑中分离,使函数关注点保持纯粹
- 代数效应最明显的应用就是 Hooks
- 代数效应和 Fiber 架构:
- Fiber(纤程)和 Generator(协程)可以理解为代数效应思想在 JS 中的体现。
# Fiber
- React Fiber 可以理解为:
- React 内部实现了一套状态更新机制,支持任务的不同优先级,可以中断和恢复,并且恢复后可以复用之前的中间状态。
- 其中每个任务更新单元就是 React Element 对应的 Fiber 节点。
# Fiber 原理
# 原因
- 在 React15 及以前,协调器采用递归的方式创建虚拟 DOM,递归过程是不能中断的,如果组件树嵌套很深就会占用线程很多时间,造成 UI 卡顿。
- React16 将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归虚拟 DOM 数据结构无法满足需要了,就有了全新的 Fiber 架构。
# 概念
- React16 之后,虚拟 DOM 在 React 中有个正式的称呼:Fiber
- Fiber 包含三层含义:
- Fiber 架构,React16 的 Reconciler 协调器基于 Fiber 节点实现,被称为 Fiber Reconciler
- Fiber 数据结构,作为静态的数据结构,每一个 Fiber 节点对应一个 React 组件,保存了该组件的类型、对应的 DOM 节点等信息
- Fiber 动态工作单元,每一个 Fiber 节点保存了本次更新中该组件改变的状态和要执行的工作
- Fiber 节点对应 DOM 节点、Fiber 节点构成的 Fiber 树对应 DOM 树。
- 组件
mount
时,Reconciler协调器
根据 JSX 描述的组件内容生成组件对应的Fiber节点
。 - 组件
update
时,Reconciler协调器
将JSX
与Fiber节点
保存的数据对比(diff 算法),生成组件对应的Fiber节点
,并根据对比结果为Fiber节点
打上标记
。
- 组件
# 原理
- 双缓存
- 在内存中构建并直接替换的技术叫双缓存,目的是不会出现白屏闪烁情况。
React
使用 “双缓存” 来完成Fiber树
的构建与替换 —— 对应着DOM树
的创建与更新- 在
React
中最多会同时存在两棵Fiber树
。当前屏幕上显示内容对应的Fiber树
称为current Fiber树
,正在内存中构建的Fiber树
称为workInProgress Fiber树
。
# diff 算法
由于 Diff
操作本身也会带来性能损耗,React 文档中提到,即使在最前沿的算法中,将前后两棵树完全比对的算法的复杂程度为 O (n 3),其中 n
是树中元素的数量。
如果在 React
中使用了该算法,那么展示 1000 个元素所需要执行的计算量将在十亿的量级范围。这个开销实在是太过高昂。
为了降低算法复杂度, React
的 diff
会预设三个限制:
只对同级元素进行
Diff
。如果一个DOM节点
在前后两次更新中跨越了层级,那么React
不会尝试复用他。两个不同类型的元素会产生出不同的树。如果元素由
div
变为p
,React 会销毁div
及其子孙节点,并新建p
及其子孙节点。开发者可以通过
key prop
来暗示哪些子元素在不同的渲染下能保持稳定。考虑如下例子:
# 单节点 diff
- 当
key相同
且type不同
时,代表我们已经找到本次更新的p
对应的上次的fiber
,但是p
与li
type
不同,不能复用。既然唯一的可能性已经不能复用,则剩下的fiber
都没有机会了,所以都需要标记删除。 - 当
key不同
时只代表遍历到的该fiber
不能被p
复用,后面还有兄弟fiber
还没有遍历到。所以仅仅标记该fiber
删除。
# 多节点 diff
Diff算法
的整体逻辑会经历两轮遍历:(对 Children 和 Fiber 进行遍历)
第一轮遍历:处理
更新
的节点。- 如果 DOM 节点可以复用,比较下一个节点
- 不可复用分两种情况
key
不同导致不可复用,立即跳出整个遍历,第一轮遍历结束。key
相同type
不同导致不可复用,会将oldFiber
标记为DELETION
,并继续遍历
- 遍历完,跳出第一轮遍历。
第二轮遍历:处理剩下的不属于
更新
的节点,需要对第一轮遍历的结果分别讨论:newChildren
与oldFiber
同时遍历完那就是最理想的情况:只需在第一轮遍历进行组件
更新
。此时Diff
结束。#
newChildren
没遍历完,oldFiber
遍历完已有的
DOM节点
都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的newChildren
为生成的workInProgress fiber
依次标记Placement
。#
newChildren
遍历完,oldFiber
没遍历完意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的
oldFiber
,依次标记Deletion
。#
newChildren
与oldFiber
都没遍历完这意味着有节点在这次更新中改变了位置。接下来需要处理移动的节点并标记节点的移动进行处理。
#
# 实现 useState
let workInProgressHook; | |
let isMount = true; | |
const fiber = { | |
memoizedState: null, | |
stateNode: App, | |
}; | |
function schedule() { | |
workInProgressHook = fiber.memoizedState; | |
const app = fiber.stateNode(); | |
isMount = false; | |
return app; | |
} | |
function dispatchAction(queue, action) { | |
const update = { | |
action, | |
next: null, | |
}; | |
if (queue.pending === null) { | |
update.next = update; | |
} else { | |
update.next = queue.pending.next; | |
queue.pending.next = update; | |
} | |
queue.pending = update; | |
schedule(); | |
} | |
function useState(initialState) { | |
let hook; | |
if (isMount) { | |
hook = { | |
queue: { | |
pending: null, | |
}, | |
memoizedState: initialState, | |
next: null, | |
}; | |
if (!fiber.memoizedState) { | |
fiber.memoizedState = hook; | |
} else { | |
workInProgressHook.next = hook; | |
} | |
workInProgressHook = hook; | |
} else { | |
hook = workInProgressHook; | |
workInProgressHook = workInProgressHook.next; | |
} | |
let baseState = hook.memoizedState; | |
if (hook.queue.pending) { | |
let firstUpdate = hook.queue.pending.next; | |
do { | |
const action = firstUpdate.action; | |
baseState = action(baseState); | |
firstUpdate = firstUpdate.next; | |
} while (firstUpdate !== hook.queue.pending); | |
hook.queue.pending = null; | |
} | |
hook.memoizedState = baseState; | |
return [baseState, dispatchAction.bind(null, hook.queue)]; | |
} | |
function App() { | |
const [num, updateNum] = useState(0); | |
console.log(`${isMount ? "mount" : "update"} num: `, num); | |
return { | |
click() { | |
updateNum((num) => num + 1); | |
}, | |
}; | |
} | |
window.app = schedule(); |
React 技术揭秘