React 核心技术浅析
1. JSX 与虚拟 DOM
我们从 React 官方文档开头最基本的一段 Hello World 代码入手:
这段代码的意思是通过 ReactDOM.render()
方法将 h1
包裹的 JSX 元素渲染到 id 为“root”的 HTML 元素上. 除了在 JS 中早已熟知的 document.getElementById()
方法外, 这段代码中还包含两个知识点:
以
h1
标签包裹的 JSX 元素ReactDOM.render()
方法
而这两个知识点则对应着 React 中要解决的核心问题:
为何以及如何使用(JSX 表示的)虚拟 DOM?
如何对虚拟 DOM 进行处理, 使其高效地渲染出来?
1.1 虚拟 DOM 是什么? 为何要使用虚拟 DOM?
虚拟 DOM 其实就是用 JavaScript 对象表示的一个 DOM 节点, 内部包含了节点的 tag
, props
和 children
.
为何使用虚拟 DOM? 因为直接操作真实 DOM 繁琐且低效, 通过虚拟 DOM, 将一部分昂贵的浏览器重绘工作转移到相对廉价的存储和计算资源上.
1.2 如何将 JSX 转换成虚拟 DOM?
通过 babel 可以将 JSX 编译为特定的 JavaScript 对象, 示例代码如下:
1.3 如何将虚拟 DOM 渲染出来?
从上一节 babel 的编译结果可以看出, 虚拟 DOM 中包含了创建 DOM 所需的各种信息, 对于首次渲染, 直接依照这些信息创建 DOM 节点即可.
但虚拟 DOM 的真正价值在于“更新”: 当一个 list 中的某些项发生了变化, 或删除或增加了若干项, 如何通过对比前后的虚拟 DOM 树, 最小化地更新真实 DOM? 这就是 React 的核心目标.
2. React Diffing
"Diffing"即“找不同”, 就是解决上文引出的 React 的核心目标——如何通过对比新旧虚拟 DOM 树, 以在最小的操作次数下将旧 DOM 树转换为新 DOM 树.
在算法领域中, 两棵树的转换目前最优的算法复杂度为 O(n**3)
, n 为节点个数. 这意味着当树上有 1000 个元素时, 需要 10 亿次比较, 显然远远不够高效.
React 在基于以下两个假设的基础上, 提出了一套复杂度为 O(n)
的启发式算法
不同类型(即标签名、组件名)的元素会产生不同的树;
通过设置
key
属性来标识一组同级子元素在渲染前后是否保持不变.
在实践中, 以上两个假设在绝大多数场景下都成立
2.1 Diffling 算法描述
不同类型的元素/组件
当元素的标签或组件名发生变化, 直接卸载并替换以此元素作为根节点的整个子树.
同一类型的元素
当元素的标签相同时, React 保留此 DOM 节点, 仅对比和更新有改变的属性, 如 className、title 等, 然后递归对比其子节点.
对于 style
属性, React 会继续深入对比, 仅更新有改变的属性, 如 color、fontSize 等.
参考 React 实战视频讲解:进入学习
同一类型的组件
当组件的 props 更新时, 组件实例保持不变, React 调用组件的 componentWillReceiveProps()
componentWillUpdate()
和 componentDidUpdate()
生命周期方法, 并执行 render()
方法.
Diffing 算法会递归比对新旧 render()
执行的结果.
对子节点的递归
当一组同级子节点(列表)的末尾添加了新的子节点时, 上述 Diffing 算法的开销较小; 但当新元素被插入到列表开头时, Diffing 算法只能按顺序依次比对并重建从新元素开始的后续所有子节点, 造成极大的开销浪费.
解决方案是为一组列表项添加 key
属性, 这样 React 就可以方便地比对出插入或删除项了.
关于 key
属性, 应稳定、可预测且在列表内唯一(无需全局唯一), 如果数据有 ID 的话直接使用此 ID 作为 key
, 或者利用数据中的一部分字段哈希出一个 key 值.
避免使用数组索引值作为 key
, 因为当插入或删除元素后, 之后的元素和索引值的对应关系都会发生错乱, 导致错误的比对结果.
避免使用不稳定的 key(如随机数), 因为每次渲染都会发生改变, 从而导致列表项被不必要地重建.
2.2 递归的 Diffing
在 1.2 节中的虚拟 DOM 对象中可以得知: 虚拟 DOM 树的每个节点通过 children
属性构成了一个嵌套的树结构, 这意味着要以递归的形式遍历和比较新旧虚拟 DOM 树.
2.1 节的策略解决了 Diffing 算法的时间复杂度的问题, 但我们还面临着另外一个重大的性能问题——浏览器的渲染线程和 JS 的执行线程是互斥的, 这意味着 DOM 节点过多时, 虚拟 DOM 树的构建和处理会长时间占用主线程, 使得一些需要高优先级处理的操作如用户输入、平滑动画等被阻塞, 严重影响使用体验.
时间切片(Time Slice)
为了解决浏览器主线程的阻塞问题, 引出 时间切片 的策略——将整个工作流程分解成小的工作单元, 并在浏览器空闲时交由浏览器执行这些工作单元, 每个执行单元执行完毕后, 浏览器都可以选择中断渲染并处理其他需要更高优先级处理的工作.
浏览器中提供了 requestIdleCallback
方法实现此功能, 将待调用的函数加入执行队列, 浏览器将在不影响关键事件处理的情况下逐个调用.
考虑到浏览器的兼容性以及 requestIdleCallback
方法的不稳定性, React 自己实现了专用于 React 的类似 requestIdleCallback
且功能更完备的 Scheduler
来实现空闲时触发回调, 并提供了多种优先级供任务设置.
递归与时间切片
时间切片策略要求我们将虚拟 DOM 的更新操作分解为小的工作单元, 同时具备以下特性:
可暂停、可恢复的更新;
可跳过的重复性、覆盖性更新;
具备优先级的更新.
对于递归形式的程序来说, 这些是难以实现的. 于是就需要一个处于递归形式的虚拟 DOM 树上层的数据结构, 来辅助完成这些特性.
这就是 React16 引入的重构后的算法核心——Fiber.
3. Fiber
从概念上来说, Fiber 就是重构后的虚拟 DOM 节点, 一个 Fiber 就是一个 JS 对象.
Fiber 节点之间构成 单向链表 结构, 以实现前文提到的几个特性: 更新可暂停/恢复、可跳过、可设优先级.
3.1 Fiber 节点
一个 Fiber 节点就是一个 JS 对象, 其中的关键属性可分类列举如下:
结构信息(构成链表的指针属性)
return: 父节点
child: 第一个子节点
sibling: 右侧第一个兄弟节点
alternate: 本节点在相邻更新时的状态, 用于比较节点前后的变化, 3.3 节详述
组件信息
tag: 组件创建类型, 如 FunctionComponent、ClassComponent、HostComponent 等
key: 即 key 属性
type: 组件类型, Function/Class 组件的 type 就是对应的 Function/Class 本身, Host 组件的 type 就是对应元素的 TagName
stateNode: 对应的真实 DOM 节点
本次更新的 props 和 state 相关信息
pendingProps、memoizedProps
memoizedState
dependencies
updateQueue
更新标记
effectTag: 节点更新类型, 如替换、更新、删除等
nextEffect、firstEffect、lastEffect
优先级相关: lanes、childrenLanes
3.2 Fiber 树
前文说到, Fiber 节点通过 return
, child
和 slibling
属性构成了单向链表结构, 为了与 DOM 树对应, 习惯上仍称其为“树”.
如一棵 DOM 树:
其 section
节点的 Fiber 可表示为:
整体的 Fiber 结构:
3.3 Fiber 架构
基于 Fiber 构成的虚拟 DOM 树就是 Fiber 架构.
在 3.1 节中我们介绍过, 在 Fiber 节点中有一个重要属性 alternate
, 单词意为“备用”.
实际上, 在 React 中最多会同时存在两棵 Fiber 树:
当前显示在屏幕上、已经构建完成的 Fiber 树称为“Current Fiber Tree”, 我们将其中的 Fiber 节点简写为
currFiber
;当前正在构建的 Fiber 树称为“WorkInProgress Fiber Tree”, 我们将其 Fiber 节点节点简写为
wipFiber
.
而这两棵树中节点的 alternate
属性互相指向对方树中的对应节点, 即: currFiber.alternate === wipFiber; wipFiber.alternate === currFber;
他们用于对比更新前后的节点以决定如何更新此节点.
在 React 中, 整个应用的根节点为 fiberRoot
, 当 wipFiber 树构建完成后, fiberRoot.current
将从 currFiber 树的根节点切换为 wipFiber 的根节点, 以完成更新操作.
3.1 基于 Fiber 的调度——时间切片
在 2.2 节我们讨论了采用拆分工作单元并以时间切片的方式执行, 以避免阻塞主线程. 在 Fiber 架构下, 每个 Fiber 节点就是一个工作单元.
在以下示例代码中, 我们使用浏览器提供的 requestIdleCallback
方法演示这个过程, 它会在浏览器空闲时执行一个 workLoop、处理一个 Fiber 节点, 然后可以根据实际情况继续执行或暂停等待执行下一个 workLoop.
3.2 对 Fiber 节点的处理顺序——DFS
由前文我们可知, Fiber 节点通过 return
child
sibling
三个属性相互连接, 整体构成一个单向链表结构,其调度方式就是 深度优先遍历 :
以 wipFiber 树的 Root 节点作为第一个执行单元;
若当前执行单元存在 child 节点, 则将 child 节点作为下一个执行单元;
重复 2, 直至当前执行单元无 child;
若当前执行单元存在 sibling 节点, 则将 sibling 节点作为下一个执行单元, 并回到 2;
若当前执行单元无 child 且无 sibling, 返回到父节点, 并回到 4;
重复 5; 直至回到 Root 节点, 执行完毕, 将
fiberRoot.current
只为 wipFiber 树的根节点.
以上步骤说明, Fiber 节点通过 child
→ sibling
→ return
的顺序进行深度优先遍历“处理”, 而后更新 Fiber 树. 那么如何“处理”Fiber 节点呢?
3.3 对 Fiber 节点的处理过程
对 Fiber 节点的处理就是执行一个 performUnitOfWork
方法, 它接收一个将要处理的 Fiber 节点, 然后完成以下工作:
完善构建 Fiber 节点: 创建 DOM 并获取
children
对于 HostComponent 和 ClassComponent, 根据 Fiber 中的相关属性, 创建 DOM 节点并赋给
Fiber.stateNode
属性;对于 FunctionComponent, 直接通过函数调用获取其 children:
Fiber.type(Fiber.props)
通过
Fiber.alternate
获取oldFiber
, 即上一次更新后的 Fiber 值, 然后在下一步中构建和 Diff 当前 Fiber 的children
.构建
children
Fibers, 对于每个子 Fiber, 同步地完成以下工作:构建 Fiber 链表: 为每个子元素创建 Fiber, 并将父 Fiber 的
child
属性指向第一个子 Fiber, 然后按顺序将子 Fiber 的sibling
属性指向下一个子 Fiber;对比(Diffing)新旧 Fiber 节点的
type
props
key
等属性, 确定节点是可以直接复用、替换、更新还是删除, 需要更新的 Fiber 节点在其effectTag
属性中打上Update
Placement
PlacementAndUpdate
Deletion
等标记, 以在提交更新阶段进行处理.按 DFS 顺序返回下一个工作单元, 示例代码如下:
当 DFS 过程回到根节点时, 表明本次更新的 wipFiber 树 构建完成, 进入下一步的提交更新阶段.
3.4 提交更新阶段
在进入本阶段时, 新的 Fiber 树已构建完成, 需要进行替换、更新或删除的 Fiber 节点也在其 effectTag
中进行了标记, 所以本阶段第一个工作就是根据 effectTag
操作真实 DOM.
为了避免从头再遍历 Fiber 树寻找具有 effectTag
属性的 Fiber, 在上一步 Fiber 树的构建过程中保存了一条需要更新的 Fiber 节点的单向链表 effectList
, 并将此链表的头节点存储在 Fiber 树根节点的 firstEffect
属性中, 同时这些 Fiber 节点的 updateQueue
属性中也保存了需要更新的 props
.
除了更新真实 DOM 外, 在提交更新阶段还需要在特定阶段调用和处理生命周期方法、执行 Hooks 操作, 本文不再详述.
在此参考了 pomb.us/build-your-… 中提供的 useState
Hook 的实现代码, 有助于理解在执行 setState
方法后都发生了什么:
评论