首頁 > web前端 > Vue.js > 主體

聊聊Vue2中的雙端diff演算法,看看如何更新節點的!

青灯夜游
發布: 2022-07-18 19:56:55
轉載
1683 人瀏覽過

這篇文章帶大家了解Vue2中的雙端diff演算法,並聊聊Vue2 是如何更新節點的,希望對大家有幫助!

聊聊Vue2中的雙端diff演算法,看看如何更新節點的!

今天我們來聊聊 Vue2 的雙端 diff 演算法。 (學習影片分享:vue影片教學

為什麼要聊呢,最直白的原因就是面試會考,當面試官問你key 的作用,十有八九都會引到diff 演算法。

沒辦法,面試問咱們就只能學,好在也不怎麼難,跟著我一起看看吧。

篇幅原因,本文並不會介紹虛擬DOM 樹是如何產生的,僅講解在資料更新時,是如何比較兩顆虛擬DOM 樹並更新真實DOM 的,主要實作Vue2 原始碼中的patchVnodeupdateChildren 函數

#預備知識

##diff 演算法作用

#聊diff 演算法前得認識它是乾嘛的。

我們知道在網頁運行中,我們改變一些數據,它們可能會影響 DOM 樹。如何在頁面中展示最新的數據呢,最簡單的方式就是整棵樹推到重建,當然這樣會導致大量的浪費,所以Vue 使用虛擬DOM 保存頁面中DOM 樹的狀態,在數據變化後,構建一棵新的虛擬DOM 樹,找到前後兩棵樹的不同之處,針對性地更新真實DOM。

而如何找到兩顆樹的不同之處,減少DOM 元素的銷毀與重建,就是diff 演算法的作用

虛擬DOM##虛擬DOM,又稱虛擬節點(vnode),簡單來說就是包含DOM 元素資訊的對象,一般由

h

函數創建,下面這個對象就可以看成是一個虛擬節點

const vnode = {
    tag: 'div', // 标签类型
    text: '', // 文本内容
    children: undefined, // 子节点
}
登入後複製
對於這段HTML

a

b

c

登入後複製

轉換成vnode 是這樣的

const vnode = {
    tag: 'div', // 标签类型
    text: undefined, // 文本内容
    children: [ // 子节点
        {
            tag: 'p',
            text: 'a',
            children: undefined,
        },
        {
            tag: 'p',
            text: 'b',
            children: undefined,
        },
        {
            tag: 'p',
            text: 'c',
            children: undefined,
        },
    ],
}
登入後複製

#因為我們需要透過虛擬節點去操作真實DOM,所以vnode 身上有個elm 屬性指向真實的DOM 元素。而且在之後的diff 演算法中,也會用到一個key 來對節點進行唯一標識,所以下文中的vnode 是這樣的物件

const vnode = {
    tag: 'div', // 标签类型
    text: '', // 文本内容
    children: undefined, // 子节点
    elm: undefined, // 对应的真实DOM
    key: '', // 唯一标识
}
登入後複製

Vue 的虛擬節點還有很多屬性,不過與diff 演算法無關,就不列舉了

說明一點,虛擬節點的text 和children 不會同時有值。在有children 屬性的情況下,text 中的內容會轉換為一個文字節點置入children 陣列中

預備函數

為了使等會的程式碼實作更簡單,我們準備幾個函數,功能不難,直接貼程式碼了

我們首先需要就是一個將虛擬節點轉換為真實DOM 的函數

// 根据虚拟节点创建真实节点
function createElement(vnode) {
    const dom = document.createElement(vnode.tag)
    if (vnode.children) {
        // 包含子节点,递归创建
        for (let i = 0; i < vnode.children.length; i++) {
            const childDom = createElement(vnode.children[i])
            dom.appendChild(childDom)
        }
    } else {
        // 内部是文字
        dom.innerHTML = vnode.text
    }
    // 补充elm属性
    vnode.elm = dom
    return dom
}
登入後複製

以及三個工具函數

// 判断是否未定义
function isUndef(v) {
    return v === undefined || v === null
}
// 判断是否已定义
function isDef(v) {
    return v !== undefined && v !== null
}
// 检查是否可复用
function checkSameVnode(a, b) {
    return a.tag === b.tag && a.key === b.key
}
登入後複製

patchVnode

當資料更新後,Vue 會建立一棵新vnode,然後執行

patchVnode

函數比較新老兩個虛擬節點的不同之處,然後根據情況進行處理

function patchVnode(newVnode, oldVnode) {}
登入後複製
首先判斷新舊兩個虛擬節點是同一對象,如果是的話就不用處理

if (oldVnode === newVnode) return
登入後複製

然後將舊節點的DOM 元素賦給新節點,並取得新舊節點的children 屬性

let elm = (newVnode.elm = oldVnode.elm)
let oldCh = oldVnode.children
let newCh = newVnode.children
登入後複製

這裡可以直接賦值是因為呼叫patchVnode 的新舊節點它們的tag 與key 是一定相同的,在下文會有講解

然後根據兩個節點內容,決定如何更新DOM

1、新舊兩個節點內容都是文字。修改文字即可

if (isUndef(oldCh) && isUndef(newCh)) {
    if (newVnode.text !== oldVnode.text) {
        elm.innerText = newVnode.text
    }
}
登入後複製

2、舊節點有子節點,新節點內容是文字。清空舊節點內容,改為文本

if (isDef(oldCh) && isUndef(newCh)) {
    elm.innerHTML = newVnode.text
}
登入後複製

3、舊節點內容是文本,新節點有子節點。清空舊節點內容,遍歷新節點產生子 DOM 元素插入節點中

if (isUndef(oldCh) && isDef(newCh)) {
    elm.innerHTML = ''
    for (let i = 0, n = newCh.length; i < n; i++){
        elm.appendChild(createElement(newCh[i]))
    }
}
登入後複製

4、新舊節點都有子節點。呼叫

updateChildren

來處理,該函數在下一章講解

if (isDef(oldCh) && isDef(newCh)) {
    updateChildren(elm, oldCh, newCh)
}
登入後複製
情況 4 可以與情況 3 的處理一樣,清空舊節點,然後遍歷產生新 DOM。但是我們要知道,創建 DOM 元素是一件非常耗時的工作,而且新舊子節點在大多數時候都是相同的,如果可以重複使用,將極大地優化我們的效能。

那我們要如何判定一個節點是否可以重複使用呢?

這就需要Vue 的使用者來幫忙了,使用者在節點上定義key 屬性,告訴Vue 哪些節點可以重複使用

只要標籤類型與key 值都相等,就說明當前元素可以被重複使用

然而在我們的專案中,一般只有在v-for 中才會設定key,其他節點都沒設定key

其實

沒有設定key 的節點,它們的key 值預設相等

事實也是如此,我們專案中大部分元素都可以重複使用,只有v-for 產生的子元素,它依賴的陣列可能會發生一些較複雜的變化,所以才需要明確標註key 值,以幫助Vue 盡可能地重複使用節點。

patchVnode 的内容当然不止这些,还有样式、类名、props等数据的对比更换,篇幅原因本文将其省略了。

updateChildren

为什么采用双端 diff

好了,Vue 的使用者为每个节点的设置了 key,我们要如何从老节点中找到 key 相等的节点复用元素呢?

简单的方式就是穷举遍历,对于每个新节点的 key 遍历所有老节点,找到了就移动到首位,没找到就创建添加。

然而这明显有优化的空间,Vue 实现这部分功能时借鉴了 snabbdom 的双端 diff 算法,因为此算法将我们平时操作数组常见的 4 种情况抽离了出来,涵盖了我们业务中的大多数场景,将 O(n2) 的时间复杂度降到了 O(n)

接下来我们来学习这是如何实现的

函数实现

函数实现较为复杂,我直接把完整的代码放上来,再带领大家一段段解读

// 三个参数为:父DOM元素,旧的子节点数组,新的子节点数组
function updateChildren(parentElm, oldCh, newCh) {
    // 旧前索引
    let oldStartIdx = 0
    // 新前索引
    let newStartIdx = 0
    // 旧后索引
    let oldEndIdx = oldCh.length - 1
    // 新后索引
    let newEndIdx = newCh.length - 1
    // 四个索引对应节点
    let oldStartVnode = oldCh[0]
    let newStartVnode = newCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndVnode = newCh[newEndIdx]

    let keyMap

    // 开始循环
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 跳过空节点 (和最后一种情况有关)
        if (isUndef(oldStartVnode)) {
            oldStartVnode = oldCh[++oldStartIdx]
        } else if (isUndef(oldEndVnode)) {
            oldEndVnode = oldCh[--oldEndIdx]
        } else if (checkSameVnode(oldStartVnode, newStartVnode)) {
            // 情况1
            // 旧前和新前相等,不需要移动
            patchVnode(newStartVnode, oldStartVnode)
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
        } else if (checkSameVnode(oldEndVnode, newEndVnode)) {
            // 情况2
            // 旧后和新后相等,也不需要移动
            patchVnode(newEndVnode, oldEndVnode)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
        } else if (checkSameVnode(oldStartVnode, newEndVnode)) {
            // 情况3
            // 旧前和新后相等
            // 旧序列的第一个节点,变成了新序列的最后一个节点
            // 需要将这个节点移动到旧序列最后一个节点的后面
            // 也就是最后一个节点的下一个节点的前面
            parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
            patchVnode(newEndVnode, oldStartVnode)
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
        } else if (checkSameVnode(oldEndVnode, newStartVnode)) {
            // 情况4
            // 旧后和新前相等
            // 旧序列的最后一个节点,变成了新序列的第一个节点
            // 需要将这个节点移动到旧序列第一个节点的前面
            parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
            patchVnode(newStartVnode, oldEndVnode)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        } else {
            // 以上四种情况都不符合
            // 制作旧节点key的映射对象
            // 键为 key,值为 索引
            if (!keyMap) {
                keyMap = {}
                for (let i = oldStartIdx; i <= oldEndIdx; i++) {
                    keyMap[oldCh[i].key] = i
                }
            }
            // 寻找当前新节点在keyMap中映射的位置序号
            const idxInOld = keyMap[newStartVnode.key]
            if (isUndef(idxInOld)) {
                // 没有找到,表示他是全新的项
                // 转化为DOM节点,加入旧序列第一个节点的前面
                parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
            } else {
                // 不是全新的项,需要移动
                const oldVnode = oldCh[idxInOld]
                // 移动到旧序列第一个节点之前
                parentElm.insertBefore(oldVnode.elm, oldStartVnode.elm)
                patchVnode(oldVnode, newStartVnode)
                // 把这项设置成空,循环时遇到时跳过
                oldCh[idxInOld] = undefined
            }
            // 当前新节点处理完毕,下一轮循环处理下一个新节点
            newStartVnode = newCh[++newStartIdx]
        }
    }

    // 循环结束了,start还是比end小,说明有节点没有处理到
    if (newStartIdx <= newEndIdx) {
        // 新节点没有处理到,则创建按DOM添加到新序列最后一个节点的前面
        for (let i = newStartIdx; i <= newEndIdx; i++) {
            // insertBefore方法传入null则添加到队尾
            const before = newCh[newEndIdx + 1]?.elm || null
            parentElm.insertBefore(createElement(newCh[i]), before)
        }
    } else if (oldStartIdx <= oldEndIdx) {
        // 旧节点没有处理到,删除
        for (let i = oldStartIdx; i <= oldEndIdx; i++) {
            parentElm.removeChild(oldCh[i].elm)
        }
    }
}
登入後複製

代码注释中及下文的新/旧序列,仅包含从新/旧开始索引到结束索引间的节点,也就是还未处理的节点序列,而不是整个子节点数组。

根据例子讲解

我们以下图的例子,来讲解这个函数的运行流程(方框中的内容为子节点的 key,所有节点标签相同)

首先定义了 8 个变量,表示新旧序列的开始和结束位置的索引与节点

聊聊Vue2中的雙端diff演算法,看看如何更新節點的!

然后开始循环,初始时节点都不为空

第一次循环命中情况 1,旧前与新前(key)相等

这表示旧序列的第一个节点到新序列仍是第一个节点,也就不需要移动,但还需要比较一下节点的内容有没有改变(patchVnode),并且让新旧开始索引都前进一步

// 比较节点的数据及子节点,并且将旧节点的DOM赋给新节点
patchVnode(newStartVnode, oldStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
登入後複製

聊聊Vue2中的雙端diff演算法,看看如何更新節點的!

情况 1 是业务中最常见的,表示从前至后两两比较。一般把商品添加到购物车末尾,或是没有设置 key 值的子节点,都是依靠情况 1 把可复用的节点筛选完毕。

第二次循环命中情况 2,旧后和新后相等

这表示序列的末尾节点到新序列仍是末尾节点,也不需要移动,然后让新旧结束索引都后退一步

patchVnode(newEndVnode, oldEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
登入後複製

聊聊Vue2中的雙端diff演算法,看看如何更新節點的!

情况 2 是情况 1 的补充,表示从后向前两两比较。有时会把新发布的评论插到开头,或者从购物车删除了一些商品,这时仅依靠情况 1 就无法迅速的筛选可复用节点,所以需要从后向前比较来配合。

第三次循环命中情况 3,旧前和新后相等

这表示旧序列的第一个节点,变成了新序列的最后一个节点。需要将这个节点移动到序列的末尾,也就是旧序列末尾节点的下一个节点(节点 e)的前面

parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
登入後複製
登入後複製

聊聊Vue2中的雙端diff演算法,看看如何更新節點的!

然后比较新旧节点,修改索引

patchVnode(newEndVnode, oldStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
登入後複製

聊聊Vue2中的雙端diff演算法,看看如何更新節點的!

情况 3 主要处理数组反转的情况,比如升序改降序,每个起始节点被移动到了末尾的位置,使用此情况将它们重新排序。

第四次循环命中情况 4,旧后与新前相等

这表示旧序列的最后一个节点,变成了新序列的第一个节点。需要将这个节点移动到序列的开头,也就是旧序列开始节点(节点 c)的前面

parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
登入後複製
登入後複製

聊聊Vue2中的雙端diff演算法,看看如何更新節點的!

到这里说一下,图上标注的是节点 a 的后面,是因为节点 b 被移动到了末尾

节点的移动都是根据旧节点来定位的,如果想把一个节点放到序列的开头,就放到旧序列开始节点的前面;如果想把一个节点放到序列的末尾,就要放到旧序列结束节点的下一个节点的前面

然后也是比较新旧节点,修改索引,之后是下图情况

patchVnode(newStartVnode, oldEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
登入後複製

聊聊Vue2中的雙端diff演算法,看看如何更新節點的!

情况 4 是情况 3 的补充,避免反转数组后又插入/删除了节点导致情况 3 无法匹配,本例就是这个情况。

第五次循环,4 种情况均为未命中

很遗憾,无法迅速锁定节点的位置,只能用传统的方式进行遍历

我们这里选择了以空间换时间的方式,定义了 keyMap,将旧序列节点的 key 与索引存起来,然后使用新开始节点的 key 去查找。

如果没找到,说明这是一个新节点,创建节点并放到开头,也就是插入到旧序列开始节点的前面

但如果找到了,则同样移动节点到序列开头,然后将对应的旧节点索引置空,在以后循环遇到空的旧节点就跳过了

本例中是未找到的情况,此节点处理完毕,新开始索引加一,超过了新结束索引,不满足循环条件,退出循环

聊聊Vue2中的雙端diff演算法,看看如何更新節點的!

然而,节点 c 并没有被处理,此时的 DOM 序列为:a,d,f,c,b,e

所以在循环之后,要检测是否有未处理的节点,如果是旧节点未处理,删除即可;

如果是新节点未处理,则创建新节点插入到旧序列的末尾或者旧序列的开头,二者其实是一个位置

我们假设旧节点中没有 c,则在第四次循环后就会出现以下情况(第四次循环命中的是情况 1)

聊聊Vue2中的雙端diff演算法,看看如何更新節點的!

如果把 f 放到序列的开头,就是旧开始节点(节点 e)的前面

而如果把 f 放到序列的末尾,也就是旧结束节点的下一个节点(节点 e)的前面

此时旧序列就是一个点,不分开头和结尾,只要保证新增节点序列按序添加就好了

至此,双端 diff 算法就讲完了

Vue 中的 key

学完 diff 算法,再聊聊 key 的作用

v-for 中的 key

上面讲的都是有 key 情况下,diff 算法能够迅速找到新旧序列中的同一节点,以较小的代价完成更新。

而如果在 v-for 中不设置 key 呢?

假设我们在数组头部插入了一个新节点,然后开始循环,每次循环都命中情况 1,尝试“复用”此节点。

但是,在对比新旧节点的内容时,都会发现内容不同,需要用新节点的内容替换旧节点。这只是复用了 DOM 的外壳,节点的内容、数据以及该节点的子节点全都要更改。

相比有 key 时的只添加一个新节点,无 key 则将所有节点都修改一遍。

v-if 自带 key

v-for 以外的元素我们一般是不设置 key 的,但是如果子元素中有 v-if 的话,就像下面这个场景(abcd是内容,并不是 key),更新子元素又会复现上一节的情况。

聊聊Vue2中的雙端diff演算法,看看如何更新節點的!

然而 Vue 官方也考虑到了这点,会为 v-if 的元素加上利用 hash 函数生成的唯一 key

// 以下出自 v2 源码
var needsKey = !!el.if 
……
needsKey ? ',null,false,' + hash(generatedSlots) : ''
登入後複製

key 的另一个用法

顺便再提一嘴,key 可以绑到任意元素上,当 key 发生变化时,会导致 DOM 的销毁与重建,一般用来重复触发动画或生命周期钩子。

详情可看官方链接

https://cn.vuejs.org/v2/api/#key

结语

不记得这是第几次梳理双端 diff 的逻辑了,很早之前就已经拥抱 v3 了,把 v2 的响应式原理和 diff 算法总结一遍也算是给 v2 画上句号了。

没想到这篇文章写了 4000 多字,为此还特意去翻看了 v2 的源码。本文的代码和 v2 差别还是蛮大的,主要参考的是 snabbdom 和以前的教程,加了点自己的风格将 patchVnodeupdateChildren 实现了一遍。

接下来就能心无旁骛地看 v3 的源码了……

整理不易,如果有所帮助的话,希望能点赞关注,鼓励一下作者。

原文地址:https://juejin.cn/post/7120919895713251335

作者:清隆

【相关视频教程推荐:vuejs入门教程web前端入门

以上是聊聊Vue2中的雙端diff演算法,看看如何更新節點的!的詳細內容。更多資訊請關注PHP中文網其他相關文章!

相關標籤:
來源:juejin.cn
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板
關於我們 免責聲明 Sitemap
PHP中文網:公益線上PHP培訓,幫助PHP學習者快速成長!