09 | 真正理解虚拟 DOM:React 选它,真的是为了性能吗?

DOM 操作历史进程

  • 原生 JS 支配下的“人肉 DOM” 时期
  • 解放生产力的先导阶段:jQuery 时期
  • 民智初启:早期模板引擎方案
    • 早期模板引擎将整个DOM注销,然后更新为新的DOM,DOM操作范围过大

image.png

  • 虚拟 DOM

image.png
image.png

当 DOM 操作(渲染更新)比较频繁时,它会先将前后两次的虚拟 DOM 树进行对比(diff),定位出具体需要更新的部分,生成一个“补丁集”,最后只把“补丁”打在需要更新的那部分真实 DOM 上(patch),实现精准的“差量更新

差量更新(patch): 只更新变化的部分 patch n.色斑, 斑点, (与周围不同的)小块,小片, 补丁, 补块, 眼罩 v.打补丁, 缝补, 修补

批量更新(batch): 缓存每次更新的补丁集,将多次更新放入队列,统一执行一次更新(集中化的DOM批量更新) batch n.一批, (食物、药物等)一批生产的量, 批 v.分批处理

React 选用虚拟 DOM,真的是为了更好的性能吗?

image.png
image.png

模板渲染 虚拟DOM
阶段1 阶段2 阶段1 阶段2 阶段3
JS阶段 模板渲染时间短,性能好
DOM阶段 如果大范围的DOM被更新,两者几乎相等, 否则虚拟DOM好
总结
- JS 计算耗时远低于DOM操作,所以少量的DOM操作即可支撑较大的JS计算
- 实际业务开发,一般都是页面中少量DOM发生变化
- 虚拟DOM的核心不在性能,对比性能需要在某些场景下
  • JS阶段
    • 模板渲染的阶段1 VS 虚拟DOM阶段1、2 ==> 模板渲染时间短,性能好
  • DOM阶段
    • 模板渲染的阶段2 VS 虚拟DOM阶段3 ==> 如果大范围的DOM被更新,两者几乎相等, 否则虚拟DOM好
  • 总结

    • JS 计算耗时远低于DOM操作,所以少量的DOM操作即可支撑较大的JS计算
    • 实际业务开发,一般都是页面中少量DOM发生变化

      虚拟 DOM 的核心价值

  • 研发体验/研发效率的问题

    • 为“数据驱动视图”这一思想提供了载体
    • 使得前端开发能够基于函数式UI编程方式实现高效的声明式编程
  • 跨平台的问题
    • 虚拟DOM是对真实渲染内容的一层抽象,有了这层抽象,可以和渲染平台解耦合,这为“一次开发,多端运行”的跨端开发提供了可能

image.png

10 | React 中的“栈调和”(Stack Reconciler)过程是怎样的?

调和(Reconciliation)过程与 Diff 算法

  • 调和:通过如 ReactDOM 等类库使虚拟 DOM 与“真实的” DOM 同步,这一过程叫作协调(调和)—将虚拟 DOM映射到真实 DOM 的过程,调和 !== Diff

    Diff 策略的设计思想

  • 若两个组件属于同一个类型,那么它们将拥有相同的 DOM 树形结构;

  • 处于同一层级的一组子节点,可用通过设置 key 作为唯一标识,从而维持各个节点在不同渲染过程中的稳定性。
  • DOM 节点之间的跨层级操作并不多,同层级操作是主流

    把握三个“要点”,图解 Diff 逻辑

  • Diff 算法性能突破的关键点在于“分层对比”

    • Diff通过分层对比降低两个DOM树对比的时间复杂度(深度优先遍历

image.png

  • 如果有跨层级的操作,直接销毁重建

image.png

  • 减少递归的“一刀切”策略:类型的一致性决定递归的必要性
    • 只有同类型的组件才有Diff的必要,如果两个组件的类型不同,直接用新的替换旧的

image.png

  • 重用节点的好帮手:key 属性帮 React “记住”节点
    • 如果未给同一层的节点设置key, 当插入C节点时,DE都无法复用,需要销毁新建

image.png

  • 当为同一层级的节点设置key,通过ID可以发现,DE都可以复用,只是位置发生了变化,并完成C的插入

image.png


  1. 新旧两棵虚拟DOM树采用分层对比、如果有跨层级的操作,直接销毁重建
  2. 如果两个树的类型不同,直接放弃对比,销毁重建
  3. 对于同一层级的子节点,通过设置 key 充分利用子节点

11 | setState 到底是同步的,还是异步的?

异步的动机和原理——批量更新的艺术

在实际的 React 运行时中,setState 异步的实现方式有点类似于 Vue 的 $nextTick 和浏览器里的 Event-Loop:每来一个 setState,就把它塞进一个队列里“攒起来”。等时机成熟,再把“攒起来”的 state 结果做合并,最后只针对最新的 state 值走一次更新流程。这个过程,叫作“批量更新”

  1. this.setState({
  2. count: this.state.count + 1 ===> 入队,[count+1的任务]
  3. });
  4. this.setState({
  5. count: this.state.count + 1 ===> 入队,[count+1的任务,count+1的任务]
  6. });
  7. this.setState({
  8. count: this.state.count + 1 ===> 入队, [count+1的任务,count+1的任务, count+1的任务]
  9. });
  10. 合并 state,[count+1的任务]
  11. 执行 count+1的任务

在同一个方法中多次调用setState, 只会保留最后一次的更新

解读 setState 工作流

image.png
在 isBatchingUpdates 的约束下,setState 只能是异步的(batching: 分批, 配料, 成批, 批次處理)

  1. increment = () => {
  2. // 进来先锁上
  3. isBatchingUpdates = true
  4. console.log('increment setState前的count', this.state.count)
  5. this.setState({
  6. count: this.state.count + 1
  7. });
  8. console.log('increment setState后的count', this.state.count)
  9. // 执行完函数再放开
  10. isBatchingUpdates = false
  11. }

因为setTImeout 是异步的,当isBatchingUpdates变为false时,才执行setTimeout内部的逻辑,所以setState 并不是具备同步这种特性,只是在特定的情境下,它会从 React 的异步管控中“逃脱”

(在浏览器中有消息队列和延迟队列【setTimeout 就是在延迟队列中的】当消息队列执行完一个任务后,回去计算时候需要执行延迟队列中的任务,如果需要执行延迟队列中的任务)

  1. reduce = () => {
  2. // 进来先锁上
  3. isBatchingUpdates = true
  4. setTimeout(() => {
  5. console.log('reduce setState前的count', this.state.count)
  6. this.setState({
  7. count: this.state.count - 1
  8. });
  9. console.log('reduce setState后的count', this.state.count)
  10. },0);
  11. // 执行完函数再放开
  12. isBatchingUpdates = false
  13. }

setState 到底是同步的,还是异步的?

setState 并不是单纯同步/异步的,它的表现会因调用场景的不同而不同:在 React 钩子函数合成事件中,它表现为异步;而在 setTimeout、setInterval 等函数中,包括在 DOM 原生事件中,它都表现为同步。这种差异,本质上是由 React 事务机制和批量更新机制(isBatchingUpdate锁)的工作方式来决定的。

? 为什么原生事件中是同步的

12 | 如何理解 Fiber 架构的迭代动机与设计思想?

前置知识:单线程的 JavaScript 与多线程的浏览器

JS是单线程的,JS线程和渲染线程是互斥的,当其中一个线程执行时,另一个只能处于挂起状态,所以会阻塞DOM渲染,若 JavaScript 线程长时间地占用了主线程,那么渲染层面的更新就不得不长时间地等待,界面长时间不更新,带给用户的体验就是所谓的“卡顿”,事件线程也在等待 JavaScript,这就导致你触发的事件也将是难以被响应的

栈调(Stack Reconciliation)和的困境

image.png
因为栈调和是对两个树的同步递归遍历,这个过程一旦执行,就无法中止,Stack Reconciler 需要的调和时间会很长,这就意味着 JavaScript 线程将长时间地霸占主线程,进而导致我们上文中所描述的渲染卡顿/卡死、交互长时间无响应等问题

Fiber 调和的破局

纤程
比线程还小的过程,可以对渲染任务进行更细粒度的控制,React通过引入纤程,可以将一个任务拆分为多个子任务,然后分散到多个帧里面。

增量渲染
实现增量渲染的目的,是为了实现任务的可中断、可恢复,并给不同的任务赋予不同的优先级,最终达成更加顺滑的用户体验

可中断、可恢复、优先级
在Fiber架构下,通过引入Scheduler调度器将任务划分为不同的优先级,比如有一个A任务进入,被Scheduler调度到Reconciler层,若此时有一个优先级更高的B任务进入,那么在Reconciler层中的A任务会被中断,优先执行优先级更高的B任务,当B任务执行完毕后,A会重新恢复,继续执行。

image.pngimage.png

Fiber 架构对生命周期的影响

image.png
组件的生命周期可划分为以下三个阶段:
Render阶段:纯净并且没有副作用的,可以被中断、暂停、重启
Pre-commit阶段:能够读取DOM
Commit阶段:能够操作DOM、执行副作用、安排更新

特点:
render阶段主要是在内存中计算对比需要更新的点
commit阶段则是把render阶段生成的更新真实的执行掉

ReactV15 和ReactV16的对比
reactV16将渲染任务拆分为一个个小的工作单元,这些小的工作单元可根据优先级优先执行、可中断、可恢复,意味着Render阶段的生命周期可能会被多次执行,如在V15里的componentWillMount componentWillReceiveProps、 componentWillUpdate、shouldComponentUpdate(判断渲染的必要性,一般不会执行副作用) 为了防止其他三个生命周期被滥用,所以V16对生命周期进行了更改,引入了 getDerivedStateFromProps(替代componentWillReceiveProps) getStnapShotBeforeUpdate (替代componentWillUpdata)

image.pngimage.png


Fiber架构

因为JS是单线程,JS执行时会抢占主线程,影响渲染线程,造成页面的卡顿和用户操作事件无法得到响应,V15的栈调和过程涉及到两个虚拟DOM树的递归和遍历,这个过程一旦开始就无法停止,很有可能造成页面长时间失去响应,影响用户的体验。
V16通过引入Fiber架构,

  • 引入了纤程,可以对渲染任务更细粒度的控制,将一个任务拆分为多个任务分配到不同的帧中
  • 在调和之前增加了任务调度Scheduler(任务可中断、可恢复、可分配优先级执行)

因为Fiber的引入可能会导致某些生命周期的多次运行,如componentWillMount、componentWillReceiveProps、ComponentWillUpate,所以React16对生命周期做了更改

13 | ReactDOM.render 是如何串联渲染链路的?(上、中、下)

ReactDOM.render 调用栈的逻辑分层

image.png
scheduleUpdateOnFibercommitRoot 为分界点,将ReactDOM划分为以下三个阶段:

  1. 初始化阶段
  2. render阶段
  3. commit阶段

    1、拆解 ReactDOM.render 调用栈——初始化阶段

    完成 Fiber 树中基本实体的创建

image.png

首次渲染过程中 legacyRenderSubtreeIntoContainer 方法的主要逻辑链路

image.png

同步的 ReactDOM.render,异步的 ReactDOM.createRoot

React 的3种启动方式:

  1. legacy 模式(同步渲染):
    1. ReactDOM.render(<App />, rootNode)
    2. 当前React APP使用的模式,不支持新功能
  2. blocking 模式:
    1. ReactDOM.createBlockingRoot(rootNode).render(<App />)
    2. 实验功能,到concurrent 模式的过渡模式
  3. concurrent 模式(异步渲染):
    1. ReactDOM.createRoot(rootNode).render(<App />)
    2. 实验期,未来React的默认模式,开启全部新功能

      Fiber 架构一定是异步渲染吗?

      React 16 如果没有开启 concurrent模式,还是同步渲染的机制,只有开启了concurrent模式的才是异步渲染的,所以Fiber架构不能完全的和异步渲染划等号,他是同时兼容了同步和异步渲染的

2、拆解 ReactDOM.render 调用栈——render 阶段

Fiber虽然不是利用递归来实现的,但是在ReactDOM.render()触发的同步模式下,仍是是深度优先搜索的过程,在这个过程中,beginWork负责创建新的节点,complateWork负责将Fiber节点映射为DOM节点

image.png

Fiber 树的构建过程

基于workLoopSync循环创建的Fiber节点,在不同的 Fiber 节点之间,将通过 child、return、sibling 这 3 个属性建立关系,其中 child、return 记录的是父子节点关系,而 sibling 记录的则是兄弟节点关系。按照以上规则创建的就是“Fiber树”(它的数据结构本质其实已经从树变成了链表)

image.png

3、拆解 ReactDOM.render 调用栈——commit 阶段

它是一个绝对同步的过程

commit 共分为 3 个阶段:

  • before mutation
    • 这个阶段 DOM 节点还没有被渲染到界面上去,过程中会触发 getSnapshotBeforeUpdate,也会处理 useEffect 钩子相关的调度逻辑
  • mutation
    • 这个阶段负责 DOM 节点的渲染。在渲染过程中,会遍历 effectList,根据 flags(effectTag)的不同,执行不同的 DOM 操作
  • layout
    • 这个阶段处理 DOM 渲染完毕之后的收尾逻辑。比如调用 componentDidMount/componentDidUpdate,调用 useLayoutEffect 钩子函数的回调等。除了这些之外,它还会把 fiberRoot 的 current 指针指向 workInProgress Fiber 树

16 | 剖析 Fiber 架构下 Concurrent( 并行, 并发) 模式的实现原理

current 树 与 workInProgress 树:“双缓冲”模式在 Fiber 架构下的实现

什么是“双缓冲”模式

在计算机图形领域,通过让图形硬件交替读取两套缓冲数据,可以实现画面的无缝切换,减少视觉效果上的抖动甚至卡顿。而在 React 中,双缓冲模式的主要利好,则是能够帮我们较大限度地实现 Fiber 节点的复用,从而减少性能方面的开销。

Scheduler——“时间切片”与“优先级”的幕后推手

时间切片: 将一个大的Task切分为多个小的子Task

  • 同步模式:是一个不可中断的长时间Task

image.png

  • 异步模式:多个小的子Task

image.png

时间切片是如何实现的?

React 会根据浏览器的帧率,计算出时间切片的大小,并结合当前时间计算出每一个切片的到期时间。在 workLoopConcurrent 中,while 循环每次执行前,会调用 shouldYield 函数来询问当前时间切片是否到期,若已到期,则结束循环、出让主线程的控制权。

优先级调度是如何实现的

image.png

17 | 特别的事件系统:React 事件与 DOM 事件有何不同?

回顾原生 DOM 下的事件流

W3C 标准约定了一个事件的传播过程要经过以下 3 个阶段:

  1. 事件捕获阶段
  2. 目标阶段
  3. 事件冒泡阶段

image.png

当事件被触发时,首先经历的是一个捕获过程:事件会从最外层的元素开始“穿梭”,逐层“穿梭”到最内层元素,这个过程会持续到事件抵达它目标的元素(也就是真正触发这个事件的元素)为止;此时事件流就切换到了“目标阶段”——事件被目标元素所接收;然后事件会被“回弹”,进入到冒泡阶段——它会沿着来时的路“逆流而上”,一层一层再走回去。

DOM 事件流下的性能优化思路:事件委托(事件代理)

利用事件的冒泡特性,把多个子元素的同一类型的监听逻辑,合并到父元素上通过一个监听函数来管理的行为,就是事件委托。通过事件委托,我们可以减少内存开销、简化注册步骤,大大提高开发效率。

React 事件系统是如何工作的

在React中,绝大部分的事件都不会被绑定在具体的元素上,而是统一被绑定在页面的 document 上。当事件在具体的 DOM 节点上被触发后,最终都会冒泡到 document 上,document 上所绑定的统一事件处理程序会将事件分发到具体的组件实例,在分发事件之前,React 首先会对事件进行包装,把原生 DOM 事件包装成合成事件。
**

认识 React 合成事件

合成事件是 React 自定义的事件对象,它符合W3C规范,在底层抹平了不同浏览器的差异,在上层面向开发者暴露统一的、稳定的、与 DOM 原生事件相同的事件接口。

React 事件系统工作流拆解

  • 事件的绑定

image.png

  • 事件的触发

image.png

React 事件系统的设计动机是什么

  • 合成事件符合W3C规范,在底层抹平了不同浏览器的差异,在上层面向开发者暴露统一的、稳定的、与 DOM 原生事件相同的事件接口。开发者们由此便不必再关注烦琐的底层兼容问题,可以专注于业务逻辑的开发
  • 自研事件系统使 React 牢牢把握住了事件处理的主动权
  • 合成事件性能更好?—没有实际验证(事件委托可以节省内存开销 → React 合成事件承袭了事件委托的思想 → 合成事件性能更好)事件委托主要的作用应该在于帮助 React 实现了对所有事件的中心化管控

React 的合成事件

在W3C规范里的事件模型,会经过捕获阶段、目标阶段、冒泡阶段这三个阶段,我们利用冒泡阶段,将多个子节点的时间代理到父节点来减少内存的占用、提升开发效率等

React的合成事件将组件的所有事件统一代理到document上(V17改变为代理到各个组件上)然后再在事件触发后再分配给对应的组件实例。

合成事件抹平了底层浏览器的兼容性,让开发者聚焦到业务开发上;通过合成事件,React掌控了对所有事件的中心化管控,同时也有人认为将所有的事件统一代理到document,可以减少内存占用,提升性能。