虚拟DOM

真是DOM 的缺陷:

  • js 操纵Dom 会 影响到整个渲染流水线
  • 我们可以调用document.body.appendChild(node)往 body 节点上添加一个元素,调用该 API 之后会引发一系列的连锁反应。
    1、首先渲染引擎会将 node 节点添加到 body 节点之上,
    2、然后触发样式计算、布局、绘制、栅格化、合成等任务,我们把这一过程称为重排。
    3、除了重排之外,还有可能引起重绘或者合成操作,形象地理解就是“牵一发而动全身”

虚拟dom

虚拟dom需要解决的事情:
1、将页面改变的内容应用到虚拟 DOM 上,而不是直接应用到 DOM 上
2、变化被应用到虚拟 DOM 上时,虚拟 DOM 并不急着去渲染页面,而仅仅是调整虚拟 DOM 的内部状态,这样操作虚拟 DOM 的代价就变得非常轻了
3、在虚拟 DOM 收集到足够的改变时,再把这些变化一次性应用到真实的 DOM 上

Virtual Dom 的优势在哪里?
一般面试官都会问到Virtual Dom 的优势

面试官不是想听到 [直接操作/频繁操作 DOM 的性能差」,如果 DOM 操作的性能如此不堪,那么 jQuery 也不至于活到今天。所以面试官更想听到 VDOM 想解决的问题以及为什么频繁的 DOM 操作会性能差。

DOM 引擎、JS 引擎 相互独立,但又工作在同一线程(主线程)
JS 代码调用 DOM API 必须 挂起 JS 引擎、转换传入参数数据、激活 DOM 引擎,DOM 重绘后再转换可能有的返回值,最后激活 JS 引擎并继续执行若有频繁的 DOM API 调用,且浏览器厂商不做“批量处理”优化,
引擎间切换的单位代价将迅速积累若其中有强制重绘的 DOM API 调用,重新计算布局、重新绘制图像会引起更大的性能消耗。

  • 虚拟 DOM 不会立马进行排版与重绘操作
  • 虚拟 DOM 进行频繁修改,然后一次性比较并修改真实 DOM 中需要改的部分,最后在真实 DOM 中进行排版与重绘,减少过多DOM节点排版与重绘损耗
  • 虚拟 DOM 有效降低大面积真实 DOM 的重绘与排版,因为最终与真实 DOM 比较差异,可以只渲染局部

除了上面说的带来的优势,但并不是全部。虚拟 DOM 最大的优势在于抽象了原本的渲染过程,实现了跨平台的能力而不仅仅局限于浏览器的 DOM,可以是安卓和 IOS 的原生组件,可以是近期很火热的小程序,也可以是各种 GUI

直接操作 DOM 的性能并不会低于虚拟 DOM 和 Diff 算法,甚至还会优于。

原因是因为:
比较是为了找出不同从而有的放矢的更新页面。但是比较也是要消耗性能的。而直接操作 DOM 就是有的放矢,我们知道该更新什么不该更新什么,所以不需要有比较的过程。所以直接操作 DOM 效率可能更高

React 厉害的地方并不是说它比 DOM 快,而是说不管你数据怎么变化,我都可以以最小的代价来进行更新 DOM。 方法就是我在内存里面用新的数据刷新一个虚拟 DOM 树,然后新旧 DOM 进行比较,找出差异,再更新到 DOM 树上

React 的出现,将命令式变成了声明式,摒弃了直接操作 DOM 的细节,只关注数据的变动,DOM 操作由框架来完成,从而大幅度提升了代码的可读性和可维护性
框架的意义在于为你掩盖底层的 DOM 操作

虚拟DOM的作用:

1、Virtual DOM 在牺牲(牺牲很关键)部分性能的前提下,增加了可维护性,这也是很多框架的通性。

2、实现了对 DOM 的集中化操作,在数据改变时先对虚拟 DOM 进行修改,再反映到真实的 DOM 中,用最小的代价来更新 DOM

3、打开了函数式 UI 编程的大门

4、可以渲染到 DOM 以外的端,使得框架跨平台,比如 ReactNative,React VR 等。

5、组件的高度抽象化。

Vue 2.0 引入 vdom 的主要原因是 vdom 把渲染过程抽象化了,从而使得组件的抽象能力也得到提升,并且可以适配 DOM 以外的渲染目标。来自尤大文章:Vue 的理念问题[6]

虚拟DOM 的缺点:

1、首次渲染大量 DOM 时,由于多了一层虚拟 DOM 的计算,会比 innerHTML 插入慢

2、虚拟 DOM 需要在内存中的维护一份 DOM 的副本(更上面一条其实也差不多,上面一条是从速度上,这条是空间上)

3、如果虚拟 DOM 大量更改,这是合适的。但是单一的,频繁的更新的话,虚拟 DOM 将会花费更多的时间处理计算的工作。所以,如果你有一个 DOM 节点相对较少页面,用虚拟 DOM,它实际上有可能会更慢。但对于大多数单页面应用,这应该都会更快。

react结合虚拟DOM:

虚拟DOM_第1张图片
cf2089ad62af94881757c2f2de277890.png

  • 创建阶段:首先依据 JSX 和基础数据创建出来虚拟 DOM,它反映了真实的 DOM 树的结构。然后由虚拟 DOM 树创建出真实 DOM 树,真实的 DOM 树生成完后,再触发渲染流水线往屏幕输出页面

  • 更新阶段:如果数据发生了改变,那么就需要根据新的数据创建一个新的虚拟 DOM 树;然后 React 比较两个树,找出变化的地方,并把变化的地方一次性更新到真实的 DOM 树上;最后渲染引擎更新渲染流水线,并生成新的页面。

为什么要使用虚拟DOM?

  • 前面 说过每次更新一次dom 都会走一次渲染流程,对浏览器性能 有很大的影响,可能会造成浏览器卡顿的现象;
虚拟DOM_第2张图片
WechatIMG534.png

浏览器的引擎工作流程都差不多,如上图大致分5步:创建DOM tree –> 创建Style Rules -> 构建Render tree -> 布局Layout –> 绘制Painting

  • 1、如果用传统的 api 或者jq 操作 DOM 树,浏览器会从构建DOM树开始从头到尾执行一遍流程。
    比如:当你一次操作需要更新 10个dom 节点理想的状态是一次性更新完成,但是浏览器接受第一个更新的请求之后,不知道后面还有,就会马上执行流程。
    紧接着下一次更新 又要重新来一遍,前面的计算也是无用的。
    频繁操作还是会出现页面卡顿,影响用户的体验。
    虚拟dom 会把10次更新的diff 保存到内存的js 对象中,最后一次性 attach 到dom 树上。

  • 2、具有更强的表达能力,如生命周期和更新时机。

  • 3、执行效率高,便于diff

React Dom diff:

传统diff:

  • 在传统的diff算法下,对比前后两个节点,如果发现节点改变了,会继续去比较节点的子节点,一层一层去对比。就这样循环递归去进行对比,复杂度就达到了o(n3),n是树的节点数

传统的diff 通过对 循环递归 对节点对比,效率比较低。算法复杂度达到 O(n^3)。如果 React 只是单纯的引入 diff 算法而没有任何的优化改进,那么其效率是远远无法满足前端渲染所要求的性能,那么react 是怎么做的呢?

react diff:
React 通过制定大胆的策略,将 O(n^3) 复杂度的问题转换成 O(n) 复杂度的问题。

react diff 策略:
策略一(tree diff):Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计 (DOM结构发生改变-----直接卸载并重新creat)。
策略二(component diff):拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
策略三(element diff):对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。

React 分别对 tree diff、component diff 以及 element diff 进行算法优化

  • 1、tree diff:

对树进行分层比较,两棵树只会对同一层次的节点进行比较。
React 只会对同一层的节点作比较,不会跨层级比较

虚拟DOM_第3张图片
2791851910-5becd605889ec_articlex.png

如果出现了 DOM 节点跨层级的移动操作,React diff 会有怎样的表现呢?

虚拟DOM_第4张图片
d712a73769688afe1ef1a055391d99ed_r.jpg

A 节点(包括其子节点)整个被移动到 D 节点下,由于 React 只会简单的考虑同层级节点的位置变换,而对于不同层级的节点,只有创建和删除操作。

当根节点发现子节点中 A 消失了,就会直接销毁 A;当 D 发现多了一个子节点 A,则会创建新的 A(包括子节点)作为其子节点。此时,React diff 的执行情况:create A -> create B -> create C -> delete A

注意:在开发组件时,保持稳定的 DOM 结构会有助于性能的提升。例如,可以通过 CSS 隐藏或显示节点,而不是真的移除或添加 DOM 节点

  • 2、component diff:

  • 如果是同类型,按照原来策略 继续比较 virtual DOM tree;

  • 如果不是一个类型,替换整个组件下的所有子节点

  • 对于同一类型的组件,有可能其 Virtual DOM 没有任何变化,如果能够确切的知道这点那可以节省大量的 diff 运算时间,因此 React 允许用户通过 shouldComponentUpdate() 来判断该组件是否需要进行 diff。

  • 3、element diff:

  • React diff 提供了三种节点操作,分别为:INSERT_MARKUP(插入)、MOVE_EXISTING(移动)和 REMOVE_NODE(删除)

  • 全新的节点,老的集合里面没有 ,就重新创建 插入;

  • 可以复用之前的,做了移动;

  • 老 component 类型,在新集合里也有,但对应的 element 不同则不能直接复用和更新,需要执行删除操作,或者老 component 不在新集合里的,也需要执行删除操作。

虚拟DOM_第5张图片
7541670c089b84c59b84e9438e92a8e9_r.jpg

按照diff 规则,B != A,则创建并插入 B 至新集合,删除老集合 A;以此类推,创建并插入 A、D 和 C,删除 B、C 和 D。这样比较繁琐,因为都是相同的节点,移动就可以减少操作,所以提出了优化的方法:允许开发者对同一层级的同组子节点,添加唯一 key 进行区分,虽然只是小小的改动,性能上却发生了翻天覆地的变化

主要分析新老集合中存在相同节点但位置不同时,对节点进行位置移动的

总结

  • React 通过制定大胆的 diff 策略,将 O(n3) 复杂度的问题转换成 O(n) 复杂度的问题
  • React 通过分层求异的策略,对 tree diff 进行算法优化
  • React 通过相同类生成相似树形结构,不同类生成不同树形结构的策略,对 component diff 进行算法优化
  • React 通过设置唯一 key的策略,对 element diff 进行算法优化
  • 建议,在开发组件时,保持稳定的 DOM 结构会有助于性能的提升

我们都知道 react 的核心的思想:
内存维护虚拟dom (js 对象),数据变化时(setState),自动更新虚拟 DOM,得到一颗新树,然后 Diff 新老虚拟 DOM 树,找到有变化的部分,得到一个 Change(Patch),将这个 Patch 加入队列,最终批量更新这些 Patch 到 DOM 中

调和阶段(Reconciler)

React 会自顶向下通过递归,遍历新数据生成新的 Virtual DOM,然后通过 Diff 算法,找到需要变更的元素(Patch),放到更新队列里面去。

在协调阶段,采用递归的遍历方法 ,称为Stack Reconciler 这种方式:
一旦任务开始进行,就无法中断,那么 js 将一直占用主线程, 一直要等到整棵 Virtual DOM 树计算完成之后,才能把执行权交给渲染引擎,那么这就会导致一些用户交互、动画等任务无法立即得到处理,就会有卡顿,非常的影响用户体验

针对任务一旦执行,无法中断,js 一直占用主线程导致卡顿的问题如何优化?

为什么会出现卡顿?

  • 1、处理用户交互;
  • 2、js 解析执行
  • 3、帧开始。窗口尺寸变更,页面滚去等的处理
  • 4、requestAnimationFrame
  • 5、布局
  • 6、绘制

以上任意一个环节占用的时间过长都用可能造成卡顿的现象;在协调阶段 js 执行过长,那么就有可能本来应该渲染下一帧,但是当前js 还在执行,导致卡顿感。

解决方法:
把渲染更新过程拆分成多个子任务,每次只做一小部分,做完看是否还有剩余时间,如果有继续下一个任务;如果没有,挂起当前任务,将时间控制权交给主线程,等主线程不忙的时候在继续执行。 操作系统常用任务调度策略之一。

操作系统常用任务调度策略:
先来先服务(FCFS)调度算法;
短作业(进程)优先调度算法(SJ/PF);
最高优先权优先调度算法(FPF);
高响应比优先调度算法(HRN);
时间片轮转法(RR);
多级队列反馈法。

  • 合作式调度主要就是用来分配任务的,当有更新任务来的时候,不会立马diff,而是把更新推入 Update Queue 中,然后交给 Scheduler 去处理。
  • 两个执行帧之间,主线程通常会有一小段空闲时间,requestIdleCallback可以在这个空闲期(Idle Period)调用空闲期回调(Idle Callback),执行一些任务。
  • requestIdleCallback方法提供 deadline,即任务执行限制时间,以切分任务,避免长时间执行,阻塞UI渲染而导致掉帧;

1、如何拆分子任务?
2、有剩余时间怎么去调度应该执行哪一个任务?
3、没有剩余时间之前的任务怎么办?

对于上面的问题 ,react 通过 Fiber 来解决

渲染阶段 (render)

遍历更新队列,通过调用宿主环境的API,实际更新渲染对应元素。

Fiber

fiber 代表 一种工作单元,需要重新实现一个堆栈帧的调度,可以按照自己的调度算法执行他们,另外这些调度可以自己控制,所以本质上Fiber 也可以理解为一个虚拟的堆栈帧。 Fiber 是一种数据结构(堆栈帧),也可以说是一种解决可中断的调用任务的一种解决方案,它的特性就是时间分片(time slicing)和暂停(supense)。

Fiber 是如何工作的?
  • ReactDOM.render() 和 setState 的时候开始创建更新;
  • 将创建的更新加入任务队列,等待调度
  • 在 requestIdleCallback 空闲时执行任务
  • 从根节点开始遍历 Fiber Node,并且构建 WokeInProgress Tree。
  • 生成 effectList
  • 根据 EffectList 更新 DOM。

第一:
从 ReactDOM.render() 方法开始,把接收的 React Element 转换为 Fiber 节点,并为其设置优先级,创建 Update,加入到更新队列,这部分主要是做一些初始数据的准备

创建ReactRoot 实例 root 调用root.render -> updateContainer-> 设置优先级

第二:
主要是三个函数:scheduleWork、requestWork、performWork,即安排工作、申请工作、正式工作三部曲,React 16 新增的异步调用的功能则在这部分实现,这部分就是 Schedule 阶段

第三:
遍历所有的 Fiber 节点,通过 Diff 算法计算所有更新工作,产出 EffectList 给到 commit 阶段使用,这部分的核心是 beginWork 函数

Fiber的关键特性如下::

  • 增量渲染(把渲染任务拆分成块,匀到多帧)
  • 更新时能够暂停,终止,复用渲染任务
  • 给不同类型的更新赋予优先级
  • 并发方面新的基础能力
Fiber Node
{
  ...
  // 跟当前Fiber相关本地状态(比如浏览器环境就是DOM节点)
  stateNode: any,

    // 单链表树结构
  return: Fiber | null,// 指向他在Fiber节点树中的`parent`,用来在处理完这个节点之后向上返回
  child: Fiber | null,// 指向自己的第一个子节点
  sibling: Fiber | null,  // 指向自己的兄弟结构,兄弟节点的return指向同一个父节点

  // 更新相关
  pendingProps: any,  // 新的变动带来的新的props
  memoizedProps: any,  // 上一次渲染完成之后的props
  updateQueue: UpdateQueue | null,  // 该Fiber对应的组件产生的Update会存放在这个队列里面
  memoizedState: any, // 上一次渲染的时候的state

  // Scheduler 相关
  expirationTime: ExpirationTime,  // 代表任务在未来的哪个时间点应该被完成,不包括他的子树产生的任务
  // 快速确定子树中是否有不在等待的变化
  childExpirationTime: ExpirationTime,

 // 在Fiber树更新的过程中,每个Fiber都会有一个跟其对应的Fiber
  // 我们称他为`current <==> workInProgress`
  // 在渲染完成之后他们会交换位置
  alternate: Fiber | null,

  // Effect 相关的
  effectTag: SideEffectTag, // 用来记录Side Effect
  nextEffect: Fiber | null, // 单链表用来快速查找下一个side effect
  firstEffect: Fiber | null,  // 子树中第一个side effect
  lastEffect: Fiber | null, // 子树中最后一个side effect
  ....
};
Fiber Reconciler

Fiber Reconciler 是 React 里的调和器,这也是任务调度完成之后,如何去执行每个任务,如何去更新每一个节点的过程

reconcile 过程分为2个阶段(phase):
1、(可中断)render/reconciliation 通过构造 WorkInProgress Tree 得出 Change。
2、(不可中断)commit 应用这些DOM change。

reconciliation 阶段

由于 reconciliation 阶段是可中断的,一旦中断之后恢复的时候又会重新执行,所以很可能 reconciliation 阶段的生命周期方法会被多次调用

commit 阶段

commit 阶段可以理解为就是将 Diff 的结果反映到真实 DOM 的过程

commit 阶段会执行如下的声明周期方法:

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

注意区别 reconciler、reconcile 和 reconciliation,reconciler 是调和器,是一个名词,可以说是 React 工作的一个模块,协调模块;reconcile 是调和器调和的动作,是一个动词;而 reconciliation 只是 reconcile 过程的第一个阶段。

Fiber Tree 和 WorkInProgress Tree
  • React 在 render 第一次渲染时,会通过 React.createElement 创建一颗 Element 树,可以称之为 Virtual DOM Tree,

  • 由于要记录上下文信息,加入了 Fiber,每一个 Element 会对应一个 Fiber Node,将 Fiber Node 链接起来的结构成为 Fiber Tree。

  • 它反映了用于渲染 UI 的应用程序的状态。这棵树通常被称为 current 树(当前树,记录当前页面的状态)

  • 在后续的更新过程中(setState),每次重新渲染都会重新创建 Element, 但是 Fiber 不会,Fiber 只会使用对应的 Element 中的数据来更新自己必要的属性

  • Fiber Tree 一个重要的特点是链表结构,将递归遍历编程循环遍历,然后配合 requestIdleCallback API, 实现任务拆分、中断与恢复

虚拟DOM_第6张图片
WechatIMG537.png

每一个 Fiber Node 节点与 Virtual Dom 一一对应,所有 Fiber Node 连接起来形成 Fiber tree, 是个单链表树结构

当调用 setState 的时候又是如何 Diff 得到 change 的呢

采用的是一种叫双缓冲技术(double buffering),这个时候就需要另外一颗树:WorkInProgress Tree,它反映了要刷新到屏幕的未来状态

WorkInProgress Tree 构造完毕,得到的就是新的 Fiber Tree,然后喜新厌旧(把 current 指针指向WorkInProgress Tree,丢掉旧的 Fiber Tree)就好了

创建 WorkInProgress Tree 的过程也是一个 Diff 的过程,Diff 完成之后会生成一个 Effect List,这个 Effect List 就是最终 Commit 阶段用来处理副作用的阶段。

源码实现(中文版): https://libin1991.github.io/2019/10/25/React-Fiber%E8%B0%83%E5%BA%A6%E5%8E%9F%E7%90%86

源码实现(英文版): https://pomb.us/build-your-own-react/

Fiber 介绍: http://www.ayqy.net/blog/dive-into-react-fiber/

你可能感兴趣的:(虚拟DOM)