深入剖析Vue原始碼 - 來,跟我一起實現diff演算法!

不做祖國的韭菜發表於2019-07-25

這一節,依然是深入剖析Vue原始碼系列,上幾節內容介紹了Virtual DOM是Vue在渲染機制上做的優化,而渲染的核心在於資料變化時,如何高效的更新節點,這就是diff演算法。由於原始碼中關於diff演算法部分流程複雜,直接剖析每個流程不易於理解,所以這一節我們換一個思路,參考原始碼來手動實現一個簡易版的diff演算法。

之前講到Vue在渲染機制的優化上,引入了Virtual DOM的概念,利用Virtual DOM描述一個真實的DOM,本質上是在JS和真實DOM之間架起了一層緩衝層。當我們通過大量的JS運算,並將最終結果反應到瀏覽器進行渲染時,Virtual DOM可以將多個改動合併成一個批量的操作,從而減少 dom 重排的次數,進而縮短了生成渲染樹和繪製節點所花的時間,達到渲染優化的目的。之前的章節,我們簡單的介紹了VueVnode的概念,以及建立Vnode渲染Vnode再到真實DOM的過程。如果有忘記流程的,可以參考前面的章節分析。

**從render函式到建立虛擬DOM,再到渲染真實節點,這一過程是完整的,也是容易理解的。然而引入虛擬DOM的核心不在這裡,而在於當資料發生變化時,如何最優化資料變動到檢視更新的過程。這一個過程才是Vnode更新檢視的核心,也就是常說的diff演算法。**下面跟著我來實現一個簡易版的diff演算法

8.1 建立基礎類

程式碼編寫過程會遇到很多基本型別的判斷,第一步需要先將這些方法封裝。

class Util {
  constructor() {}
  // 檢測基礎型別
  _isPrimitive(value) {
    return (typeof value === 'string' || typeof value === 'number' || typeof value === 'symbol' || typeof value === 'boolean')
  }
  // 判斷值不為空
  _isDef(v) {
    return v !== undefined && v !== null
  }
}
// 工具類的使用
const util = new Util()
複製程式碼

8.2 建立Vnode

Vnode這個類在之前章節已經分析過原始碼,本質上是用一個物件去描述一個真實的DOM元素,簡易版關注點在於元素的tag標籤,元素的屬性集合data,元素的子節點children,text為元素的文字節點,簡單的描述類如下:

class VNode {
  constructor(tag, data, children) {
    this.tag = tag;
    this.data = data;
    this.children = children;
    this.elm = ''
    // text屬性用於標誌Vnode節點沒有其他子節點,只有純文字
    this.text = util._isPrimitive(this.children) ? this.children : ''
  }
}
複製程式碼

8.3 模擬渲染過程

接下來需要建立另一個類模擬將render函式轉換為Vnode,並將Vnode渲染為真實DOM的過程,我們將這個類定義為Vn,Vn具有兩個基本的方法createVnode, createElement, 分別實現建立虛擬Vnode,和建立真實DOM的過程。

8.3.1 createVnode

createVnode模擬Vuerender函式的實現思路,目的是將資料轉換為虛擬的Vnode,先看具體的使用和定義。

// index.html

<script src="diff.js">
<script>

// 建立Vnode

let createVnode = function() {
  let _c = vn.createVnode;
  return _c('div', { attrs: { id: 'test' } }, arr.map(a => _c(a.tag, {}, a.text)))
}

// 元素內容結構
let arr = 
  [{
    tag: 'i',
    text: 2
  }, {
    tag: 'span',
    text: 3
  }, {
    tag: 'strong',
    text: 4
  }]
</script>



// diff.js
(function(global) {
  class Vn {
    constructor() {}
    // 建立虛擬Vnode
    createVnode(tag, data, children) {
      return new VNode(tag, data, children)
    }
  }
  global.vn = new Vn()
}(this))

複製程式碼

這是一個完整的Vnode物件,我們已經可以用這個物件來簡單的描述一個DOM節點,而createElement就是將這個物件對應到真實節點的過程。最終我們希望的結果是這樣的。

Vnode物件

深入剖析Vue原始碼 - 來,跟我一起實現diff演算法!

渲染結果

深入剖析Vue原始碼 - 來,跟我一起實現diff演算法!

8.3.2 createElement

渲染真實DOM的過程就是遍歷Vnode物件,遞迴建立真實節點的過程,這個不是本文的重點,所以我們可以粗糙的實現。

class Vn {
  createElement(vnode, options) {
      let el = options.el;
      if(!el || !document.querySelector(el)) return console.error('無法找到根節點')
      let _createElement = vnode => {
        const { tag, data, children } = vnode;
        const ele = document.createElement(tag);
        // 新增屬性
        this.setAttr(ele, data);
        // 簡單的文字節點,只要建立文字節點即可
        if (util._isPrimitive(children)) {
          const testEle = document.createTextNode(children);
          ele.appendChild(testEle)
        } else {
        // 複雜的子節點需要遍歷子節點遞迴建立節點。
          children.map(c => ele.appendChild(_createElement(c)))
        }
        return ele
      }
      document.querySelector(el).appendChild(_createElement(vnode))
    }
}
複製程式碼

8.3.3 setAttr

setAttr是為節點設定屬性的方法,利用DOM原生的setAttribute為每個節點設定屬性值。

class Vn {
  setAttr(el, data) {
    if (!el) return
    const attrs = data.attrs;
    if (!attrs) return;
    Object.keys(attrs).forEach(a => {
      el.setAttribute(a, attrs[a]);
    })
  }
}
複製程式碼

至此一個簡單的 **資料 -> Virtual DOM => 真實DOM**的模型搭建成功,這也是資料變化、比較、更新的基礎。

8.4 diff演算法實現

更新元件的過程首先是響應式資料發生了變化,資料頻繁的修改如果直接渲染到真實DOM上會引起整個DOM樹的重繪和重排,頻繁的重繪和重排是極其消耗效能的。如何優化這一渲染過程,Vue原始碼中給出了兩個具體的思路,其中一個是在介紹響應式系統時提到的將多次修改推到一個佇列中,在下一個tick去執行檢視更新,另一個就是接下來要著重介紹的diff演算法,將需要修改的資料進行比較,並只渲染必要的DOM

資料的改變最終會導致節點的改變,所以diff演算法的核心在於在儘可能小變動的前提下找到需要更新的節點,直接呼叫原生相關DOM方法修改檢視。不管是真實DOM還是前面建立的Virtual DOM,都可以理解為一顆DOM樹,演算法比較節點不同時,只會進行同層節點的比較,不會跨層進行比較,這也大大減少了演算法複雜度。

8.4.1 diffVnode

在之前的基礎上,我們實現一個思路,1秒之後資料發生改變。

// index.html
setTimeout(function() {
  arr = [{
    tag: 'span',
    text: 1
  },{
    tag: 'strong',
    text: 2
  },{
    tag: 'i',
    text: 3
  },{
    tag: 'i',
    text: 4
  }]
  // newVnode 表示改變後新的Vnode樹
  const newVnode = createVnode();
  // diffVnode會比較新舊Vnode樹,並完成檢視更新
  vn.diffVnode(newVnode, preVnode);
})
複製程式碼

diffVnode的邏輯,會對比新舊節點的不同,並完成檢視渲染更新

class Vn {
  ···
  diffVnode(nVnode, oVnode) {
    if (!this._sameVnode(nVnode, oVnode)) {
      // 直接更新根節點及所有子節點
      return ***
    }
    this.generateElm(vonde);
    this.patchVnode(nVnode, oVnode);
  }
}
複製程式碼

8.4.2 _sameVnode

新舊節點的對比是演算法的第一步,如果新舊節點的根節點不是同一個節點,則直接替換節點。這遵從上面提到的原則,只進行同層節點的比較,節點不一致,直接用新節點及其子節點替換舊節點。為了理解方便,我們假定節點相同的判斷是tag標籤是否一致(實際原始碼要複雜)。

class Vn {
  _sameVnode(n, o) {
    return n.tag === o.tag;
  }
}
複製程式碼

8.4.3 generateElm

generateElm的作用是跟蹤每個節點實際的真實節點,方便在對比虛擬節點後實時更新真實DOM節點。雖然Vue原始碼中做法不同,但是這不是分析diff的重點。

class Vn {
  generateElm(vnode) {
    const traverseTree = (v, parentEl) => {
      let children = v.children;
      if(Array.isArray(children)) {
        children.forEach((c, i) => {
          c.elm = parentEl.childNodes[i];
          traverseTree(c, c.elm)
        })
      }
    }
    traverseTree(vnode, this.el);
  }
}
複製程式碼

執行generateElm方法後,我們可以在舊節點的Vnode中跟蹤到每個Virtual DOM的真實節點資訊。

8.4.4 patchVnode

patchVnode是新舊Vnode對比的核心方法,對比的邏輯如下。

  1. 節點相同,且節點除了擁有文字節點外沒有其他子節點。這種情況下直接替換文字內容。
  2. 新節點沒有子節點,舊節點有子節點,則刪除舊節點所有子節點。
  3. 舊節點沒有子節點,新節點有子節點,則用新的所有子節點去更新舊節點。
  4. 新舊都存在子節點。則對比子節點內容做操作。

程式碼邏輯如下:

class Vn {
  patchVnode(nVnode, oVnode) {
    
    if(nVnode.text && nVnode.text !== oVnode) {
      // 當前真實dom元素
      let ele = oVnode.elm
      // 子節點為文字節點
      ele.textContent = nVnode.text;
    } else {
      const oldCh = oVnode.children;
      const newCh = nVnode.children;
      // 新舊節點都存在。對比子節點
      if (util._isDef(oldCh) && util._isDef(newCh)) {
        this.updateChildren(ele, newCh, oldCh)
      } else if (util._isDef(oldCh)) {
        // 新節點沒有子節點
      } else {
        // 老節點沒有子節點
      }
    }
  }
}
複製程式碼

上述例子在patchVnode過程中,新舊子節點都存在,所以會走updateChildren分支。

8.4.5 updateChildren

子節點的對比,我們通過文字和畫圖的形式分析,通過圖解的形式可以很清晰看到diff演算法的巧妙之處。

大致邏輯是:

  1. 舊節點的起始位置為oldStartIndex,截至位置為oldEndIndex,新節點的起始位置為newStartIndex,截至位置為newEndIndex
  2. 新舊children的起始位置的元素兩兩對比,順序是newStartVnode, oldStartVnode; newEndVnode, oldEndVnode;newEndVnode, oldStartVnode;newStartIndex, oldEndIndex
  3. newStartVnode, oldStartVnode節點相同,執行一次patchVnode過程,也就是遞迴對比相應子節點,並替換節點的過程。oldStartIndex,newStartIndex都像右移動一位。
  4. newEndVnode, oldEndVnode節點相同,執行一次patchVnode過程,遞迴對比相應子節點,並替換節點。oldEndIndex, newEndIndex都像左移動一位。
  5. newEndVnode, oldStartVnode節點相同,執行一次patchVnode過程,並將舊的oldStartVnode移動到尾部,oldStartIndex右移一味,newEndIndex左移一位。
  6. newStartIndex, oldEndIndex節點相同,執行一次patchVnode過程,並將舊的oldEndVnode移動到頭部,oldEndIndex左移一味,newStartIndex右移一位。
  7. 四種組合都不相同,則會搜尋舊節點所有子節點,找到將這個舊節點和newStartVnode執行patchVnode過程。
  8. 不斷對比的過程使得oldStartIndex不斷逼近oldEndIndexnewStartIndex不斷逼近newEndIndex。當oldEndIndex <= oldStartIndex說明舊節點已經遍歷完了,此時只要批量增加新節點即可。當newEndIndex <= newStartIndex說明舊節點還有剩下,此時只要批量刪除舊節點即可。

結合前面的例子:

第一步:

深入剖析Vue原始碼 - 來,跟我一起實現diff演算法!

第二步:

深入剖析Vue原始碼 - 來,跟我一起實現diff演算法!

第三步:

深入剖析Vue原始碼 - 來,跟我一起實現diff演算法!

第三步:

深入剖析Vue原始碼 - 來,跟我一起實現diff演算法!

第四步:

深入剖析Vue原始碼 - 來,跟我一起實現diff演算法!

根據這些步驟,程式碼實現如下:

class Vn {
  updateChildren(el, newCh, oldCh) {
    // 新children開始標誌
    let newStartIndex = 0;
    // 舊children開始標誌
    let oldStartIndex = 0;
    // 新children結束標誌
    let newEndIndex = newCh.length - 1;
    // 舊children結束標誌
    let oldEndIndex = oldCh.length - 1;
    let oldKeyToId;
    let idxInOld;
    let newStartVnode = newCh[newStartIndex];
    let oldStartVnode = oldCh[oldStartIndex];
    let newEndVnode = newCh[newEndIndex];
    let oldEndVnode = oldCh[oldEndIndex];
    // 遍歷結束條件
    while (newStartIndex <= newEndIndex && oldStartIndex <= oldEndIndex) {
      // 新children開始節點和舊開始節點相同
      if (this._sameVnode(newStartVnode, oldStartVnode)) {
        this.patchVnode(newCh[newStartIndex], oldCh[oldStartIndex]);
        newStartVnode = newCh[++newStartIndex];
        oldStartVnode = oldCh[++oldStartIndex]
      } else if (this._sameVnode(newEndVnode, oldEndVnode)) {
      // 新childre結束節點和舊結束節點相同
        this.patchVnode(newCh[newEndIndex], oldCh[oldEndIndex])
        oldEndVnode = oldCh[--oldEndIndex];
        newEndVnode = newCh[--newEndIndex]
      } else if (this._sameVnode(newEndVnode, oldStartVnode)) {
      // 新childre結束節點和舊開始節點相同
        this.patchVnode(newCh[newEndIndex], oldCh[oldStartIndex])
        // 舊的oldStartVnode移動到尾部
        el.insertBefore(oldCh[oldStartIndex].elm, null);
        oldStartVnode = oldCh[++oldStartIndex];
        newEndVnode = newCh[--newEndIndex];
      } else if (this._sameVnode(newStartVnode, oldEndVnode)) {
        // 新children開始節點和舊結束節點相同
        this.patchVnode(newCh[newStartIndex], oldCh[oldEndIndex]);
        el.insertBefore(oldCh[oldEndIndex].elm, oldCh[oldStartIndex].elm);
        oldEndVnode = oldCh[--oldEndIndex];
        newStartVnode = newCh[++newStartIndex];
      } else {
        // 都不符合的處理,查詢新節點中與對比舊節點相同的vnode
        this.findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
      }
    }
    // 新節點比舊節點多,批量增加節點
    if(oldEndIndex <= oldStartIndex) {
      for (let i = newStartIndex; i <= newEndIndex; i++) {
        // 批量增加節點
        this.createElm(oldCh[oldEndIndex].elm, newCh[i])
      }
    }
  }

  createElm(el, vnode) {
    let tag = vnode.tag;
    const ele = document.createElement(tag);
    this._setAttrs(ele, vnode.data);
    const testEle = document.createTextNode(vnode.children);
    ele.appendChild(testEle)
    el.parentNode.insertBefore(ele, el.nextSibling)
  }

  // 查詢匹配值
  findIdxInOld(newStartVnode, oldCh, start, end) {
    for (var i = start; i < end; i++) {
      var c = oldCh[i];
      if (util.isDef(c) && this.sameVnode(newStartVnode, c)) { return i }
    }
  }
}
複製程式碼

8.5 diff演算法優化

前面有個分支,當四種比較節點都找不到匹配時,會呼叫findIdxInOld找到舊節點中和新的比較節點一致的節點。節點搜尋在數量級較大時是緩慢的。檢視Vue的原始碼,發現它在這一個環節做了優化,也就是我們經常在編寫列表時被要求加入的唯一屬性key,有了這個唯一的標誌位,我們可以對舊節點建立簡單的字典查詢,只要有key值便可以方便的搜尋到符合要求的舊節點。修改程式碼:

class Vn {
  updateChildren() {
    ···
    } else {
      // 都不符合的處理,查詢新節點中與對比舊節點相同的vnode
      if (!oldKeyToId) oldKeyToId = this.createKeyMap(oldCh, oldStartIndex, oldEndIndex);
      idxInOld = util._isDef(newStartVnode.key) ? oldKeyToId[newStartVnode.key] : this.findIdxInOld(newStartVnode, oldCh, oldStartIndex, oldEndIndex);
      // 後續操作
    }
  }
  // 建立字典
  createKeyMap(oldCh, start, old) {
    const map = {};
    for(let i = start; i < old; i++) {
      if(oldCh.key) map[key] = i;
    }
    return map;
  }
}


複製程式碼

8.6 問題思考

最後我們思考一個問題,Virtual DOM 的重繪效能真的比單純的innerHTML要好嗎,其實並不是這樣的,作者的解釋

  • innerHTML: render html string O(template size) + 重新建立所有 DOM 元素 O(DOM size)
  • Virtual DOM: render Virtual DOM + diff O(template size) + 必要的 DOM 更新 O(DOM change)
  • Virtual DOM render + diff 顯然比渲染 html 字串要慢,但是!它依然是純 js 層面的計算,比起後面的 DOM 操作來說,依然便宜了太多。可以看到,innerHTML 的總計算量不管是 js 計算還是 DOM操作都是和整個介面的大小相關,但Virtual DOM 的計算量裡面,只有 js 計算和介面大小相關,DOM 操作是和資料的變動量相關的。

相關文章