Vue中的 DOM與Diff詳情

DOM Diff

Vue創建視圖分為倆種情況:

  • 首次渲染,會用組件template轉換成的真實DOM來替換應用中的根元素
  • 當數據更新後,視圖重新渲染,此時並不會重新通過組件template對應的虛擬節點來創建真實DOM,而是會用老的虛擬節點和新的虛擬節點進行比對,根據比對結果來更新DOM

第二種情況就是Vue中經常談到的DOM Diff,接下來我們將詳細介紹新老節點的比對過程。

整體思路

老的虛擬節點和新的虛擬節點是倆棵樹,會對倆棵樹每層中的虛擬節點進行比對操作:

在每一層進行對比時,會分別為老節點和新節點設置頭尾指針:

整體的孩子節點比對思路如下:

  • 在老的虛擬節點和新的虛擬節點的頭尾指針之間都有元素時進行遍歷
  • 對以下情況進行優化
  • 老節點的頭指針和新節點的頭指針相同
  • 老節點的尾指針和新節點的尾指針相同
  • 老節點的頭指針和新節點的尾指針相同
  • 老節點的尾指針和新節點的頭指針相同
  • 亂序排列時,要用新節點的頭節點到老節點中查找,如果能找到,對其復用並移動到相應的位置。如果沒有找到,將其插入到真實節點中
  • 遍歷完成後,將新節點頭指針和尾指針之間的元素插入到真實節點中,老節點頭指針和尾指針之間的元素刪除

在我們渲染視圖之前,需要保存當前渲染的虛擬節點。在下一次渲染視圖時,它就是老的虛擬節點,要和新的虛擬節點進行對比:

// src/lifecycle.js
Vue.prototype._update = function (vNode) {
  const vm = this;
  const preVNode = vm._vNode;
  vm._vNode = vNode;
  if (!preVNode) { // 首次渲染,沒有前一次的虛擬節點
    vm.$el = patch(vm.$el, vNode);
  } else { // vm._vNode中存儲瞭前一次的虛擬節點,進行dom diff
    patch(preVNode, vNode);
  }
};

下面我們實現patch方法中的邏輯

處理簡單情況

patch方法中,首先會判斷oldVNode是否為真實DOM。如果不是,會進行DOM diff

如果新的虛擬節點和老的虛擬節點標簽不一樣,直接用新的虛擬節點創建真實節點,然後替換老的真實節點即可:

const vm1 = new Vue();
const html1 = `
  <div id="app">
    111
  </div>
`;
// 將模板編譯為render函數
const render1 = compileToFunctions(html1);
const vNode1 = render1.call(vm1);
// 當oldVNode為DOM元素時,會用新節點直接替換老節點
patch(document.getElementById('app'), vNode1);
const html2 = `
  <span id="app">
    333
  </span>
`;
// 將新的模本編譯為render函數
const render2 = compileToFunctions(html2);
// 生成新的虛擬節點
const vNode2 = render2.call(vm1);
// 老節點和新節點進行對比
patch(vNode1, vNode2);

上述代碼會直接通過新的虛擬節點創建的真實節點來替換老的真實節點,patch中的代碼如下:

export function patch (oldVNode, vNode) {
  if (oldVNode.nodeType) { // 舊的節點為真實節點
    // some code...  
  } else { // 新舊節點都為虛擬節點,要進行dom diff
    if (oldVNode.tag !== vNode.tag) { // 標簽不相同,直接用新節點替換老節點
      const newEle = createElement(vNode);
      replaceChild(newEle, oldVNode.el);
      return newEle;
    }
  }
}

如果老節點和新節點都是文本標簽,那麼直接用新節點的文本替換老節點即可:

// 老的模板
const html1 = `
  <div id="app">
    111
  </div>
`;
// 新的模板
const html2 = `
  <div id="app">
    333
  </div>
`;

上例中的新的文本333會替換掉老的文本111patch中的實現如下:

export function patch (oldVNode, vNode) {
  if (oldVNode.nodeType) { // 舊的節點為真實節點
    // some code ...
  } else { // 新舊節點都為虛擬節點,要進行dom diff
    if (oldVNode.tag !== vNode.tag) { // 不相等直接替換
      // some code ...
    }
    if (!oldVNode.tag) { // 文本節點,tag相同,都為undefined
      oldVNode.el.textContent = vNode.text;
      return oldVNode.el;
    }
  }
}

當老節點和新節點的標簽相同時,要更新標簽對應真實元素的屬性,更新規則如下:

  • 用新節點中的屬性替換老節點中的屬性
  • 刪除老節點中多餘的屬性
function updateProperties (vNode, oldProps = {}) { // 老節點和新節點的屬性
  const { el, props } = vNode;
  // 用新節點替換老節點中的屬性
  for (const key in props) { // 為真實DOM設置新節點的所有屬性
    if (props.hasOwnProperty(key)) {
      const value = props[key];
      if (key === 'style') {
        for (const styleKey in value) {
          if (value.hasOwnProperty(styleKey)) {
            el.style[styleKey] = value[styleKey];
          }
        }
      } else {
        el.setAttribute(key, value);
      }
    }
  }
  // 如果老節點中有,而新節點中沒有,需要將其刪除
  for (const key in oldProps) {
    if (oldProps.hasOwnProperty(key) && !props.hasOwnProperty(key)) {
      el.removeAttribute(key);
    }
  }
  const style = oldProps.style || {};
  const newStyle = props.style || {};
  // 刪除老節點中多餘的樣式
  for (const key in style) {
    if (!newStyle.hasOwnProperty(key) && style.hasOwnProperty(key)) {
      el.style[key] = '';
    }
  }
}

在比對完當前節點後,要繼續比對孩子節點。孩子節點可能有以下情況:

  • 老節點孩子為空,新節點有孩子:將新節點的每一個孩子節點創建為真實節點,插入到老節點對應的真實父節點中
  • 老節點有孩子,新節點孩子為空:將老節點的父節點的孩子節點清空
  • 老節點和新節點都有孩子: 采用雙指針進行對比

patch中對應的代碼如下:

export function patch (oldVNode, vNode) {
  if (oldVNode.nodeType) { // 舊的節點為真實節點
    // some code ...  
  } else { // 新舊節點都為虛擬節點,要進行dom diff
    // 元素相同,需要比較子元素
    const el = vNode.el = oldVNode.el;
    // 更新屬性
    updateProperties(vNode, oldVNode.props);
    const oldChildren = oldVNode.children;
    const newChildren = vNode.children;
    // 老的有,新的沒有,將老的設置為空
    // 老的沒有,新的有,為老節點插入多有的新節點
    // 老的和新的都有,遍歷每一個進行比對
    if (!oldChildren.length && newChildren.length) {
      for (let i = 0; i < newChildren; i++) {
        const child = newChildren[i];
        el.appendChild(createElement(child));
      }
      return;
    }
    if (oldChildren.length && !newChildren.length) {
      return el.innerHTML = '';
    }
    if (oldChildren.length && newChildren.length) {
      updateChildren(oldChildren, newChildren, el);
    }
    return el;
  }
}

下面我們的邏輯便到瞭updateChildren中。

比對優化

在對孩子節點的比對中,對一些常見的DOM操作通過雙指針進行瞭優化:

  • 列表尾部新增元素
  • 列表頭部新增元素
  • 列表開始元素移動到末尾
  • 列表結尾元素移動到開頭

我們在代碼中先聲明需要的變量:

function updateChildren (oldChildren, newChildren, parent) {
  let oldStartIndex = 0, // 老孩子的開始索引
    oldStartVNode = oldChildren[0], // 老孩子的頭虛擬節點 
    oldEndIndex = oldChildren.length - 1, // 老孩子的尾索引
    oldEndVNode = oldChildren[oldEndIndex]; // 老孩子的尾虛擬節點
  let newStartIndex = 0, // 新孩子的開始索引
    newStartVNode = newChildren[0], // 新孩子的頭虛擬節點
    newEndIndex = newChildren.length - 1, // 新孩子的尾索引
    newEndVNode = newChildren[newEndIndex]; // 新孩子的尾虛擬節點
}

當節點的tagkey都相同時,我們認為這倆個節點是同一個節點,可以進行復用:

function isSameVNode (oldVNode, newVNode) {
  return oldVNode.key === newVNode.key && oldVNode.tag === newVNode.tag;
}

下面我們分別來講解對應的優化邏輯

尾部新增元素

我們在老節點孩子的末尾新增一個元素作為新節點,其對應的template如下:

const template1 = `
  <div id="app">
    <ul>
      <li key="A" style="color:red">A</li>
      <li key="B" style="color:yellow">B</li>
      <li key="C" style="color:blue">C</li>
      <li key="D" style="color:green">D</li>
    </ul>
  </div>
`;
const template2 = `
  <div id="app">
    <ul>
      <li key="A" style="color:red">A</li>
      <li key="B" style="color:yellow">B</li>
      <li key="C" style="color:blue">C</li>
      <li key="D" style="color:green">D</li>
      <li key="E" style="color:purple">E</li>
    </ul>
  </div>
`;

此時oldChildren中的頭節點和newChildren中的頭節點相同,其比對邏輯如下:

  • 繼續對oldStartVNodenewStartVNode執行patch方法,比對它們的標簽、屬性、文本以及孩子節點
  • oldStartVNodenewStartVNode同時後移,繼續進行比對
  • 遍歷完老節點後,循環停止
  • 將新節點中剩餘的元素插入到老的虛擬節點的尾節點對應的真實節點的下一個兄弟節點oldEndVNode.el.nextSibling之前

畫圖演示下詳細的比對邏輯:

代碼如下:

function updateChildren (oldChildren, newChildren, parent) {
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    if (isSameVNode(oldStartIndex, newStartIndex)) { // 頭和頭相等
      // 1. 可能是文本節點:需要繼續比對文本節點
      // 2. 可能是元素:先比對元素的屬性,然後再比對子節點
      patch(oldStartVNode, newStartVNode);
      oldStartVNode = oldChildren[++oldStartIndex];
      newStartVNode = newChildren[++newStartIndex];
    }
  }
  // 新節點中剩餘元素插入到真實節點中
  for (let i = newStartIndex; i <= newEndIndex; i++) {
    const child = newChildren[i];
    const refEle = oldChildren[oldEndIndex + 1] || null;
    parent.insertBefore(createElement(child), refEle);
  }
}

頭部新增元素

老節點的孩子的頭部新增元素E,此時新老節點的template結構如下:

const template1 = `
  <div id="app">
    <ul>
      <li key="A" style="color:red">A</li>
      <li key="B" style="color:yellow">B</li>
      <li key="C" style="color:blue">C</li>
      <li key="D" style="color:green">D</li>
    </ul>
  </div>
`;
const template2 = `
  <div id="app">
    <ul>
      <li key="E" style="color:purple">E</li>
      <li key="A" style="color:red">A</li>
      <li key="B" style="color:yellow">B</li>
      <li key="C" style="color:blue">C</li>
      <li key="D" style="color:green">D</li>
    </ul>
  </div>
`;

其比對邏輯和尾部新增類似,隻不過此時是oldEndVNodenewEndVNode相同:

  • 繼續通過patch比對oldEndVNodenewEndVNode的標簽、屬性、文本及孩子節點
  • 此時要將oldEndVNodenewEndVNode同時前移,繼續進行比對
  • 遍歷完老節點後,循環停止
  • 將新節點中剩餘的元素插入到老的虛擬節點的尾節點對應的真實節點的下一個兄弟節點oldEndVNode.el.nextSibling之前

該邏輯的示意圖如下:

patch中新增代碼如下:

function updateChildren (oldChildren, newChildren, parent) {
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    if (isSameVNode(oldStartIndex, newStartIndex)) { // 頭和頭相等
      // some code ...  
    } else if (isSameVNode(oldEndVNode, newEndVNode)) { // 尾和尾相等
      patch(oldEndVNode, newEndVNode);
      oldEndVNode = oldChildren[--oldEndIndex];
      newEndVNode = newChildren[--newEndIndex];
    }
  }
  // some code ...  
}

開始元素移動到末尾

在新節點中,我們將開始元素A移動到末尾,對應的template如下:

const template1 = `
  <div id="app">
    <ul>
      <li key="A" style="color:red">A</li>
      <li key="B" style="color:yellow">B</li>
      <li key="C" style="color:blue">C</li>
      <li key="D" style="color:green">D</li>
    </ul>
  </div>
`;
const template2 = `
  <div id="app">
    <ul>
      <li key="B" style="color:yellow">B</li>
      <li key="C" style="color:blue">C</li>
      <li key="D" style="color:green">D</li>
      <li key="A" style="color:red">A</li>
    </ul>
  </div>
`;

此時oldStartVNodenewEndVNode相同:

  • 繼續通過patch比對oldStartVNodenewEndVNode的標簽、屬性、文本及孩子節點
  • oldStartVNode對應的真實節點插入到oldEndVNode對應的真實節點之後
  • oldStartVNode後移,newEndVNode前移
  • 遍歷完新老節點後,循環停止,此時元素已經移動到瞭正確的位置

用圖來演示該過程:

在patch方法中編寫代碼:

function updateChildren (oldChildren, newChildren, parent) {
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    if (isSameVNode(oldStartIndex, newStartIndex)) { // 頭和頭相等
      // some code ...
    } else if (isSameVNode(oldEndVNode, newEndVNode)) { // 尾和尾相等
      // some code ...
    } else if (isSameVNode(oldStartVNode, newEndVNode)) { // 將開頭元素移動到瞭末尾:尾和頭相同
      // 老節點:需要將頭節點對應的元素移動到尾節點之後
      parent.insertBefore(oldStartVNode, oldEndVNode.el.nextSibling);
      patch(oldStartVNode, newEndVNode);
      oldStartVNode = oldChildren[++oldStartIndex];
      newEndVNode = newChildren[--newEndIndex];
    }
  }
}

末尾元素移動到開頭

講解到這裡,大傢可以先停下閱讀的腳步,參考一下之前的邏輯,想想這裡會如何進行比對?

在新節點中,我們將末尾元素D移動到開頭,對應的template如下:

const template1 = `
  <div id="app">
    <ul>
      <li key="A" style="color:red">A</li>
      <li key="B" style="color:yellow">B</li>
      <li key="C" style="color:blue">C</li>
      <li key="D" style="color:green">D</li>
    </ul>
  </div>
`;
const template2 = `
  <div id="app">
    <ul>
      <li key="D" style="color:green">D</li>
      <li key="A" style="color:red">A</li>
      <li key="B" style="color:yellow">B</li>
      <li key="C" style="color:blue">C</li>
    </ul>
  </div>
`;

此時oldEndVNodenewStartVNode相同:

  • 繼續通過patch比對oldEndVNodenewStartVNode的標簽、屬性、文本及孩子節點
  • oldEndVNode對應的真實節點插入到oldStartVNode對應的真實節點之前
  • oldEndVNode前移,newStartVNode後移
  • 遍歷完新老節點後,循環停止,此時元素已經移動到瞭正確的位置

畫圖來演示該過程:

patch方法中添加處理該邏輯的代碼:

function updateChildren (oldChildren, newChildren, parent) {
  // 更新子節點:
  //  1. 一層一層進行比較,如果發現有一層不一樣,直接就會用新節點的子集來替換父節點的子集。
  //  2. 比較時會采用雙指針,對常見的操作進行優化
  let oldStartIndex = 0,
    oldStartVNode = oldChildren[0],
    oldEndIndex = oldChildren.length - 1,
    oldEndVNode = oldChildren[oldEndIndex];
  let newStartIndex = 0,
    newStartVNode = newChildren[0],
    newEndIndex = newChildren.length - 1,
    newEndVNode = newChildren[newEndIndex];

  function makeMap () {
    const map = {};
    for (let i = 0; i < oldChildren.length; i++) {
      const child = oldChildren[i];
      child.key && (map[child.key] = i);
    }
    return map;
  }

  // 將老節點的key和索引進行映射,之後可以直接通過key找到索引,然後通過索引找到對應的元素
  // 這樣提前做好映射關系,可以將查找的時間復雜度降到O(1)
  const map = makeMap();
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    if (isSameVNode(oldStartIndex, newStartIndex)) { // 頭和頭相等
      // some code ...
    } else if (isSameVNode(oldEndVNode, newEndVNode)) { // 尾和尾相等
      // some code ...
    } else if (isSameVNode(oldStartVNode, newEndVNode)) { // 將開頭元素移動到瞭末尾:尾和頭相同
      // some code ...
    } else if (isSameVNode(oldEndVNode, newStartVNode)) { // 將結尾元素移動到瞭開頭
      // 老節點: 將尾指針元素插入到頭指針之前
      parent.insertBefore(oldEndVNode.el, oldStartVNode.el);
      patch(oldEndVNode, newStartVNode);
      oldEndVNode = oldChildren[--oldEndIndex];
      newStartVNode = newChildren[++newStartIndex];
    }
  }
}

到這裡,patch方法中已經完成瞭所有的優化操作,下面我們來看下如何對比亂序的孩子節點

亂序比對

當進行比對的元素不滿足優化條件時,就要進行亂序對比。下面是倆個亂序的template,看下它們的具體比對過程:

const html1 = `
  <div id="app">
    <ul>
      <li key="D" style="color:red">D</li>
      <li key="B" style="color:yellow">B</li>
      <li key="Z" style="color:blue">Z</li>
      <li key="F" style="color:green">F</li>
    </ul>
  </div>
`;
const html2 = `
  <div id="app">
    <ul>
      <li key="E" style="color:green">E</li>
      <li key="F" style="color:red">F</li>
      <li key="D" style="color:yellow">D</li>
      <li key="Q" style="color:blue">Q</li>
      <li key="B" style="color:#252a34">B</li>
      <li key="M" style="color:#fc5185">M</li>
    </ul>
  </div>
`;

亂序比對的邏輯如下:

  • 用新節點中的頭節點的key在老節點中進行查找
  • 如果在老節點中找到key相同的元素,將對應的真實節點移動到oldStartVNode.el(老虛擬頭節點對應的真實節點)之前,並且將其對應的虛擬節點設置為null,之後遇到null跳過即可,不再對其進行比對。
  • 繼續通過patch方法比對移動的節點和newStartVNode的標簽、屬性、文本以及孩子節點
  • 如果在老節點中沒有找到key相同的元素,會為新節點的頭節點創建對應的真實節點,將其插入到oldStartVNode.el之前
  • 遍歷完成後,將老節點中頭指針和尾指針之間多餘的元素刪除

畫圖演示下template中節點的比對過程:

在比對開始之前,我們要先遍歷老的孩子節點,生成key與索引對應的map:

function updateChildren (oldChildren, newChildren, parent) {
  function makeMap () {
    const map = {};
    for (let i = 0; i < oldChildren.length; i++) {
      const child = oldChildren[i];
      child.key && (map[child.key] = i);
    }
    return map;
  }
  const map = makeMap();
}

有瞭map之後,便可以很方便的通過key來找到老孩子節點的索引,然後通過索引直接找到對應的孩子節點,而不用再次進行遍歷操作。

接下來書寫處理亂序節點的代碼:

function updateChildren (oldChildren, newChildren, parent) {
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    if (oldStartVNode == null) { // 老節點null時跳過該次循環
      oldStartVNode = oldChildren[++oldStartIndex];
      continue;
    } else if (oldEndVNode == null) {
      oldEndVNode = oldChildren[--oldEndIndex];
      continue;
    } else if (isSameVNode(oldStartIndex, newStartIndex)) { // 頭和頭相等
      // some code ...  
    } else if (isSameVNode(oldStartVNode, newEndVNode)) { // 將開頭元素移動到瞭末尾:尾和頭相同
      // some code ...  
    } else if (isSameVNode(oldEndVNode, newStartVNode)) { // 將結尾元素移動到瞭開頭
      // some code ...  
    } else {
      // 1. 用key來進行尋找,找到將其移動到頭節點之前
      // 2. 沒有找到,將新頭節點插入到老頭節點之前
      let moveIndex = map[newStartVNode.key]; // 通過key在map中找到相同元素的索引
      if (moveIndex != null) { // 找到瞭
        const moveVNode = oldChildren[moveIndex];
        parent.insertBefore(moveVNode.el, oldStartVNode.el);
        oldChildren[moveIndex] = null; // 將移動這項標記為null,之後跳過,不再進行比對
        // 還有對其屬性和子節點再進行比較
        patch(moveVNode, newStartVNode);
      } else {
        // 為新頭節創建對應的真實節點並插入到老節點的頭節點之前
        parent.insertBefore(createElement(newStartVNode), oldStartVNode.el);
      }
      newStartVNode = newChildren[++newStartIndex];
    }
  }
  // some code ...
  // 老節點中從頭指針到尾指針為多餘的元素,需要刪除掉
  for (let i = oldStartIndex; i <= oldEndIndex; i++) {
    const child = oldChildren[i];
    parent.removeChild(child.el);
  }
}

當新節點在老節點中存在時,我們會將找到的真實節點移動到相應的位置。此時老節點中的該節點不需要再被遍歷,為瞭防止數組塌陷,便將該節點設置為null。之後再遍歷時,如果發現節點的值為null,便跳過本次循環。

現在我們便完成瞭Vue在數組更新時所有的DOM Diff邏輯。

寫在最後

文中主要書寫瞭patch方法的代碼,其主要功能如下:

希望小夥伴在讀完本文之後,可以對VueDOM Diff過程有更深的理解。

到此這篇關於Vue中的 DOM與Diff詳情的文章就介紹到這瞭,更多相關Vue DOM與Diff內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: