深刻剖析Vue源碼 - 來,跟我一塊兒實現diff算法!

這一節,依然是深刻剖析Vue源碼系列,上幾節內容介紹了Virtual DOM是Vue在渲染機制上作的優化,而渲染的核心在於數據變化時,如何高效的更新節點,這就是diff算法。因爲源碼中關於diff算法部分流程複雜,直接剖析每一個流程不易於理解,因此這一節咱們換一個思路,參考源碼來手動實現一個簡易版的diff算法。javascript

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

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

8.1 建立基礎類

代碼編寫過程會遇到不少基本類型的判斷,第一步須要先將這些方法封裝。node

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,先看具體的使用和定義。app

// 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就是將這個對象對應到真實節點的過程。最終咱們但願的結果是這樣的。dom

Vnode對象函數

渲染結果工具

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說明舊節點還有剩下,此時只要批量刪除舊節點便可。

結合前面的例子:

第一步:

第二步:

第三步:

第三步:

第四步:

根據這些步驟,代碼實現以下:

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 操做是和數據的變更量相關的。

相關文章
相關標籤/搜索