React 虚拟 DOM 的 Diff 算法

158 阅读9分钟

一、虚拟dom

1. 虚拟 dom 是什么?

虚拟dom是一个对象,一个用js来模拟真实dom的对象;

// 真实的dom结构
<ul id='list'>    
	<li class='item1'>111</li>    
	<li class='item2'>222</li>    
	<li class='item3'>333</li>
</ul>

那么上述dom结构,在虚拟dom中是如何进行展示的呢?

// 旧的虚拟dom结构
const oldVDom = {      
    tagName: 'ul', // 标签名     
    props: {  // 标签属性        
        id: 'list'      
    },     
    children: [ // 标签子节点        
        { tagName: 'li', props: { class: 'item1' }, children: ['111'] },        
        { tagName: 'li', props: { class: 'item2' }, children: ['222'] },        
        { tagName: 'li', props: { class: 'item3' }, children: ['333'] },     
    ]
}

此时我修改一下真实的dom结构后:

<ul id='list'>    
    <li class='item1'>111</li>    
    <li class='item2'>222</li>   
    <li class='item3'>three-three</li>
</ul>

之后会生成新的虚拟dom:

// 新的虚拟dom结构
const newVDom = {      
    tagName: 'ul', // 标签名     
    props: {  // 标签属性        
    	id: 'list'      
    },     
    children: [ // 标签子节点 // 在diff中,会通过patch发现此处两个节点没有变化,并将其复用        
        { tagName: 'li', props: { class: 'item1' }, children: ['111'] },        
        { tagName: 'li', props: { class: 'item2' }, children: ['222'] },    // 在diff的过程中,会通过patch来找出此处发生了更改,并将其替换        
        { tagName: 'li', props: { class: 'item3' }, children: ['three-three']},     
    ]
}

此时看到的两个dom结构就是我们常说的 新旧虚拟dom

2. 为什么要有虚拟 dom ?解决了什么问题?

在虚拟dom出现之前,我们都是jQuery一把梭(不多说了jQuery yyds)。

这里先来了解一下浏览器的渲染原理:

  1. 图片

  2. 由图可以发现触发一次重排的代价还是比较大的;如果频繁触发浏览器的重排,无疑会造成很大的性能成本。

我们都知道,在每一次事件循环后浏览器会有一个UI的渲染过程,那么在一次事件循环内触发的所有dom操作都会被当作为异步任务被放进异步任务队列中等待被处理。

那么此例子只是更改了一次dom结构,如果更改100+次呢?

虽然浏览器做了优化,在一段时间内频繁触发的dom不会被立即执行,浏览器会积攒变动以最高60HZ的频率更新视图;但是难免还是会造成一定次数的重排。

这时候,虚拟dom就派上了用场:不管更改多少次,多少个地方的结构,都会映射到新的虚拟dom结构中去,然后进行diff的对比,最终渲染成真实的dom,在这一次render中只会操作一次真实的dom结构,所以只会造成一次重排。

同时,采用JS对象去模拟DOM结构的好处是,页面的更新完全可以映射到JS对象中去处理,而操作内存中的JS对象速度也会更快。

所以才有了虚拟dom的出现,可以看下图虚拟dom工作原理:

  • 先根据初始的dom结构,生成一个 旧的虚拟dom:oldVDom
  • 再根据修改后的dom结构,生成 一个新的虚拟dom:newVDom
  • 然后通过diff算法来对比新旧虚拟DOM,从而找出需要替换的节点,然后将其渲染为真实的dom结构;

图片

  • 虚拟dom的缺点?

看了上述虚拟dom的优点,我们来聊聊使用它的一些代价:

  1. 首屏加载时间更长

  2. 由于我们需要根据当前的节点,来生成对应的虚拟dom,我们都知道虚拟dom是一个JS对象,所以在项目初始化的时候去生成对应的虚拟节点也是一笔时间上的开销;因此项目的首次加载可能耗费更多时间

  3. 极端场景下性能不是最优解

  4. 栗子🌰:如果当前页面的节点基本全都改变了,那我们去做了一次diff的patch过程相当于做了无效操作;

二、Diff算法

了解了虚拟dom结构之后,我们都清楚了diff的触发时机是在新旧VDom进行对比的时候

tips:既然所有的更改都被映射到了新的VDom上,那么为何不直接将新的VDom渲染成真实的dom呢?

answer:如果直接渲染的话,会默认把所有节点都更新一遍,造成不必要的节点更新;而经过了diff的比较后可以精确的找出那些节点需要更新,从而实现按需更新的理念,节省性能;

那么Diff算法的比较规则有哪些呢?

同层比较

为什么要同层比较?

如果不同层比较的话,全部的对比完一整个dom结构,时间复杂度是 O(n^3) ( 除了查找过程消耗了O(n^2)之外,找到差异后还要计算最小转换方式,最终结果为O(n^3)。 ); 时间成本太大了;所以改用同层比较这样的方法去牺牲了精度而提高了时间效率。

可以看到图中每一层的节点,都是同层在进行对比,这样的好处就是,不会每一层的对比都是相对独立的,不会影响到下一层的对比;同时同层对比的时间复杂度也是 O(n);

同时也是遵循一个深度优先的原则;diff的过程是一个深度优先遍历节点,然后将该节点与newVDom中的同层节点进行对比,如果有差异,则记录该节点到JS对象中。

图片

在同层对比的过程中有这样几种情况:

<div>    <p>ppp</p>    <ul id='list' >        <li class='item1'>111</li>           <li class='item2'>222</li>          <li class='item3'>333</li>    </ul>    <div>div</div></div>
<div>    // 1. 节点类型发生了改变    <h3>ppp</h3>    // 2. 节点类型一样,属性发生变化    <ul id='list-change'>        <li class='item1'>111</li>           <li class='item2'>222</li>          // 3. 节点被删除        // <li class='item3'>333</li>         // 4. 新增节点        <li class='item4'>444</li>      </ul>    // 4. 文本变化    <div>属性变化</div></div>

1. 节点类型变了

节点p标签 变成了h3标签,此时diff的过程中p节点会被直接销毁,然后挂载新的节点 h3,同时p标签的子节点也会被全部销毁;虽然可能造成一些不必要的销毁,但是为了实现同层比较的方法节省时间成本只能这样做咯;同时这样也告诫我们在写代码的时候,可以规避一些不必要的父节点的类型替换,比如将p标签换成了div等。

2. 节点类型一样,属性或者属性值发生变化

此时不会触发节点的卸载和挂载,只会触发当前节点的更新

3. 删除/新增/改变 节点

这时候就需要找出这些节点并在newVDom中进行插入/删除,这个过程请看下面vue和react是如何利用key值来处理的吧!

4. 文本变化

只会触发文本的改变

React 虚拟 DOM 的 Diff 原理全解析

谈到 React,diff 算法几乎是一个避不开的话题,因为它对于应用性能来说实在非常重要,但本小节的主角是 shouldComponentUpdate, 因此在正文只是有所提及,现在在彩蛋部分我们就来彻底地整理一下 React 虚拟 DOM 的 diff 算法究竟是如何做的。其实整个过程并不难,难的是它的源码对于边界情况和其他细节的处理,但精通源码,那是参与 React 框架开发的人要做的,我们要做的只是明白其中的原理,以此来帮助我们的应用开发。

思维图 (建议收藏):
img

接下来一一地对其中的过程进行拆解。

设计思想概述

首先是设计思想,其实从一个树参照另一棵树进行更新,如果利用循环递归的方式对每一个节点进行比较,那算法的复杂度可以到达是 O (n^3), 通俗点来说 1000 个节点的树,要比对 10 亿次,还不包括比对类型、属性等等节点的细节,即使目前性能最高的 CPU 也很难再一秒内算出结果。

但是 React 说它的 diff 就是能达到 O (n) 级别。

不可思议吧!但它其实就是偷工减料,并没有老老实实地比对每一个节点,有一套自己的方法论,简单的归纳一下就是下面三条:

  1. 永远只比较同层节点,不会跨层级比较节点。
  2. 不同的两个节点产生不同的树。这也就是上面总结的类型不相同的情况,把原来的节点以及它的后代全部干掉,替换成新的。
  3. 通过 key 值指定哪些元素是相同的。(后面来展开介绍。)

执行规则 (流程)

1、元素类型不相同时

见上文分析。

2. 元素类型相同时

a. 都是 DOM 节点
<div className="old" title="老节点" />

<div className="new" title="新节点" />

通过比对这两个元素,React 知道需要修改 DOM 元素上的 className 属性和 title 属性。

处理完该节点后,React 继续对子节点进行递归。

b. 都是组件元素

组件实例保持不变,更新 props。值得注意的是,这时候调用组件实例的 componentWillReceiveProps () 方法。然后通过 shouldComponentUpdate 返回值决定是否调用 render 方法。

处理完该节点后,依然继续对子节点进行递归。

特殊情况讨论:遍历子元素列表

引入 key 值

首先,我们往列表末尾插入一个元素:

<ul>
  <li>1</li>
  <li>2</li>
</ul>

插入后为:

<ul>
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ul>

React 会先匹配两个对应的树,最后插入第三个元素,没有任何问题。

但是如果在头部插入呢?

<ul>
  <li>3</li>
  <li>1</li>
  <li>2</li>
</ul>

此时前两个元素和原来都不一样,第三个元素被当作新增的节点,明明只需要更新 1 个节点,现在更新了 3 个。这样的情况效率是非常低的。

于是,React 引入了 key 值的概念。

<ul>
  <li key="first">1</li>
  <li key="second">2</li>
</ul>

插入之后变为:

<ul>
  <li key="third">3</li>
  <li key="first">1</li>
  <li key="second">2</li>
</ul>

现在 React 通过 key 得知 1 和 2 原来是存在的,现在只是换了位置,因此不需要更新整个节点了,只需要移动位置即可,大大提升效率。

选取 key 值的问题

key 选取的原一般是 不需要全局唯一,但必须列表中保持唯一

有很多人喜欢用数组元素的下标作为 key 值,在元素顺序不改变的情况是没有问题的,但一旦顺序发生改变,diff 效率就有可能骤然下降。

举个例子,现在在五个元素中插入 F img 现在由于 F 的插入,后面的 C、D、E 索引值都改变,即 key 值改变,因此后面的节点都得更新。而且,数组乱序或者在头部插入都会导致同样的更新问题。

因此,不用数组索引做 key 值的根本原因在于:数组下标值不稳定,修改顺序会修改当前 key

当我们利用 key 值以后,上面的问题便迎刃而解,后面的 C、D、E 只需要向后挪动一个位置即可,真正需要更新的就只有新增的节点了。