Diff 算法

177 阅读7分钟

前言

1. 当数据发生变化时,react和 Vue 是怎么更新View的?

渲染真实DOM节点的开销是非常大的,比如有时候我们修改了某个数据,渲染到真实的DOM树上会引起整个DOM 树的重绘和重排,有没有方案只更新我们修改的那一部分而不更新整个DOM 呢? diff 算法就可以实现。

我们先根据真实DOM 生成一颗virtual DOM , 当 Virtual DOM 某个节点的数据发生变化后,会生成一个新的 newVnode, 然后 newVnode 和 oldVnode 进行比较,发现有不一样的地方就直接修改在真实 DOM上,然后使oldVnode 的值更新成 newVnode。

vdom 因为是纯粹的JS对象,所以操作它会很高效,但是vdom 的变更最终会转换成DOM操作,为了实现高效的DOM操作,一套高效的虚拟DOM diff算法显得很有必要。

2. Virtual DOM 和真实 DOM 的区别?

Virtual DOM 是将真实的Dom 的数据抽取出来,以对象的形式模拟树形结构,diff算法比较的也是virtual Dom 比如 DOM是这样的:

 <div>
  <span>content</span>
 </div>

对应的 Virtual DOM

var vnode = {
  tag: '<div>',
  children: [
    { tag: 'span', text: 'content'}
  ],
}

1. Diff算法(差异查找法)

Diff 算法的作用

Diff 算法的作用是用来计算出 Virtual DOM 中被改变的部分,然后针对该部分进行原生 DOM 操作,而不用重新渲染整个页面。

diff 算法是调和的具体实现调和 是将 Virtual DOM 树转换成 actual DOM 树的最小操作单过程,称为调和。

什么是 Diff 算法

把树形结构按照层级分解,只比较同级元素。给列表结构的每个单元添加唯一的 key 属性,方便比较。

传统 Diff 算法

diff 算法即差异查找算法; 计算一颗树形结构转换为另一课树行结构需要最少步骤,如果使用传统的diff 算法通过循环递归遍历节点进行对比,其复杂度要达到O(n^3),其中 n 是节点总数,效率十分低下,加入我们要展示1000个节点,那么我们要一次执行上十亿次的比较。

let result = [];
// 比较叶子节点
const diffLeafs = funnction(beforeLeaf, afterLeaf){
    // 获取较大节点数的长度
    let count = Math.max(beforeLeaf.children.length, afterLeaf.children.length);
    for (let i = 0; i < count; i ++) {
        const beforeTag = beforeLeaf.children[i];
        const afterTag = afterLeaf.children[i];
        if (beforeTag === undefined) {
            // 添加 afterTag 节点
            result.push({ type: 'add', element: afterTag });
        } else if (afterTag === undefined) {
            // 删除 beforeTag 节点
            result.push({ type: 'remove', element: beforeTag });
        } else if (beforeTag.tagName !== afterTag.tagName) {
            // 节点名发生变化时, 删除before节点,添加after节点
            result.push({ type: 'remove', element: beforeTag });
            result.push({ type: 'add', element: afterTag });
        } else if (beforeTag.innerHTML !== afterTag.innerHTML) {
            // 节点名不变而内容改变时,改变节点
            if (beforeTag.children.length === 0) {
                result.push({
                    type: 'changed',
                    beforeElement: beforeTag,
                    afterElement: afterTag,
                    html: afterTag.innerHTML
                });
            } else {
                // 递归比较
                diffLeafs(beforeTag, afterTag);
            }
        }
    }
    return result;
}

diff 三大策略

  • 1 策略一、Tree Diff

    Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。

  • 2 策略二、Component Diff

    拥有相同类的两个组件将会生成相似的树形结构 。拥有不同类的两个组件将会生成不同的树形结构。

  • 3 策略三、Element Diff

    对于同一层级的一组子节点,通过唯一 id 进行区分。


1.2. tree diff

(1) React 通过 updateDepth 对 Virtual DOM 树进行层级控制。
(2) 对树分层比较,两棵树 只对同一层次节点进行比较。如果该节点不存在时,则该节点及其节点会被完全删除,不会再进行比较。
(3) 只需遍历一次,就能完成整颗 DOM 树的比较。

如果 DOM 节点出现了跨层级操作,diff 会怎么办?

答: diff 只简单考虑同层级的节点位置变换,如果是跨层级的话,只有创建节点和删除节点的操作。

如上图所示,以A为根节点的整棵树会被重新创建,而不是移动,因此官方建议不要进行DOM节点跨层级操作,可以通过CSS隐藏、显示节点,而不是真正的移除、添加DOM节点。

2. Vue Diff算法

Vue的核心是双向绑定和虚拟DOM,vdom 是树状结构,其节点为 vnode, vnode 和浏览器DOM中的node一一对应,通过Vnode的elm 属性可以访问到对应的Node。

概念: diff 算法是一种优化手段,将前后两个模块进行差异化对比,修补差异的过程叫做patch(打补丁)。

vue中列表循环需加:key="唯一标识"

唯一标识可以是 item 里面的id、index 等具有唯一性字段值,因为Vue组件高度复用增加Key可以标识组件的唯一性,那么key是如何更搞笑的更新虚拟DOM的呢?

  • 实例:
    我们希望在BC之间加一个 F, diff算法默认执行起来是这样的,即把C更新成F、D更新成C、E 更新成D,最后插入E,这样效率很差。

所以我们需要使用Key 来给每个节点做一个唯一标识,Diff 算法就可以正确的识别次节点,找到正确的位置区插入新的节点。

Vue 通过一系列的措施提升diff 的性能:

1. 优先处理特殊场景

(1) 头部的同类型节点、尾部的同类型节点
这类节点更新前后位置没有发生变化,所以不用移动它们对应的DOM。
(2) 头尾/尾头的同类型节点
这类节点位置很明确,不需要花心思查找,直接移动DOM就好了。 处理了这些场景之后,一方面一些不需要做移动的DOM得到了快速处理,另一方面待处理节点变少,缩小了后续操作的处理范围,性能也得到了提升。

2. 原地复用

原地复用是指Vue会尽可能复用DOM,尽可能不发生DOM的移动。
Vue在判断更前前后指针是否指向同一个节点,其实不要求它们真实引用同一个DOM节点,实际上它仅判断指向的是否是同类型,如果是同类节点,那么Vue会直接复用DOM,这样的好处是不需要移动DOM。

3. React Diff算法

只有在 React 更新阶段才会有 Diff 算法的运用。

  • React 不可能采用传统算法。

React 的 Diff 算法。

  1. React 采用虚拟 DOM 技术实现对真实 DOM 的映射,即 React Diff 算法的差异查找实质是对两个 JavaScript 对象的差异查找。
  2. 基于三个策略:[Diff 算法的三大策略] 将 O(n^3)复杂度转换成 O(n)复杂度

(1)调和 将 Virtual DOM 树转换成 actual DOM 树的最少操作的过程称为调和。

(2)React Diff 算法,Diff 算法是调和的具体实现。

element diff

当节点处于同一层级时,diff算法提供三种节点操作:删除、插入、移动

情景一: 新旧集合中存在相同节点但位置不同时,如何移动节点:

(1)看着上图的 B,React先从新中取得B,然后判断旧中是否存在相同节点B,当发现存在节点B后,就去判断是否移动B。 B在旧 中的index=1,它的lastIndex=0,不满足 index < lastIndex 的条件,因此 B 不做移动操作。此时,一个操作是,lastIndex=(index,lastIndex)中的较大数=1.

注意:lastIndex有点像浮标,或者说是一个map的索引,一开始默认值是0,它会与map中的元素进行比较,比较完后,会改变自己的值的(取index和lastIndex的较大数)。

(2)看着 A,A在旧的index=0,此时的lastIndex=1(因为先前与新的B比较过了),满足index<lastIndex,因此,对A进行移动操作,此时lastIndex=max(index,lastIndex)=1。

(3)看着D,同(1),不移动,由于D在旧的index=3,比较时,lastIndex=1,所以改变lastIndex=max(index,lastIndex)=3

(4)看着C,同(2),移动,C在旧的index=2,满足index<lastIndex(lastIndex=3),所以移动。

由于C已经是最后一个节点,所以diff操作结束。