博客 / 詳情

返回

手寫一個Virtual DOM及源碼解析

關注前端小謳,閲讀更多原創技術文章
  • Virtual DOM是當今主流框架普遍採用的提高 web 頁面性能的方案,其原理是:

    • 1.把真實的 DOM 樹轉換成 js 對象(虛擬 DOM)
    • 2.數據更新時生成新的 js 對象(新的虛擬 DOM)
    • 3.二者比對後僅對發生變化的數據進行更新

完整代碼參考 →

js 對象模擬 DOM 樹

  • 假設有如下 html 結構(見index.html
<div id="virtual-dom" style="color:red">
  <p>Virtual DOM</p>
  <ul id="list">
    <li class="item">Item 1</li>
    <li class="item">Item 2</li>
    <li class="item">Item 3</li>
  </ul>
  <div>Hello World</div>
</div>
  • 用 js 對象表示該結構,標籤作為tagName屬性,id、class等作為props屬性,標籤內再嵌套的標籤或文本均作為children
const elNode = {
  tagName: "div",
  props: { id: "virtual-dom" },
  children: [
    { tagName: "p", children: ["Virtual DOM"] }, // 沒有props
    {
      tagName: "ul",
      props: { id: "list" },
      children: [
        {
          tagName: "li",
          props: { class: "item" },
          children: ["Item 1"],
        },
        {
          tagName: "li",
          props: { class: "item" },
          children: ["Item 2"],
        },
        {
          tagName: "li",
          props: { class: "item" },
          children: ["Item 3"],
        },
      ],
    },
    { tagName: "div", props: {}, children: ["Hello World"] },
  ],
};
  • 創建VNode類,用以將以上 js 結構轉換成 VNode 節點對象(見vnode.js),並創建調用Vnode的方法createElement(見create-element.js
export default class VNode {
  constructor(tagName, props, children) {
    if (props instanceof Array) {
      // 第二個參數是數組,説明傳的是children,即沒有傳props
      children = props; // 把props賦給原本應是子節點的第三個參數
      props = {}; // props被賦值為空對象
    }
    this.tagName = tagName;
    this.props = props;
    this.children = children;
  }
  // render 將virdual-dom 對象渲染為實際 DOM 元素
  render() {
    // console.log(this.tagName, this.props, this.children);
    let el = document.createElement(this.tagName);
    let props = this.props;
    // 設置節點的DOM屬性
    for (let propName in props) {
      let propValue = props[propName];
      el.setAttribute(propName, propValue);
    }
    // 保存子節點
    let children = this.children || [];
    children.forEach((child) => {
      let childEl =
        child instanceof VNode
          ? child.render() // 如果子節點也是虛擬DOM,遞歸構建DOM節點
          : document.createTextNode(child); // 如果字符串,只構建文本節點
      el.appendChild(childEl); // 子節點dom
    });
    return el;
  }
}
export function createElement(tagName, props, children) {
  return new VNode(tagName, props, children);
}
  • 注掉頁面原本的html結構並調用createElement方法(見index.html),可渲染同樣的內容
<!-- <div id="virtual-dom">
  <p>Virtual DOM</p>
  <ul id="list">
    <li class="item">Item 1</li>
    <li class="item">Item 2</li>
    <li class="item">Item 3</li>
  </ul>
  <div>Hello World</div>
</div> -->

<script type="module">
  import { createElement } from "./create-element.js";
  let elNode = createElement("div", { id: "virtual-dom", color: "red" }, [
    createElement("p", ["Virtual DOM"]), // 沒有props
    createElement("ul", { id: "list" }, [
      createElement("li", { class: "item" }, ["Item 1"]),
      createElement("li", { class: "item" }, ["Item 2"]),
      createElement("li", { class: "item" }, ["Item 3"]),
    ]),
    createElement("div", ["Hello World"]),
  ]);

  let elRoot = elNode.render(); // 調用VNode原型上的render方法,創建相應節點
  document.body.appendChild(elRoot); // 頁面可渲染與注掉相同的內容
</script>

比較兩顆虛擬 DOM 樹

  • 假設上文渲染的內容,想要變成如下 html 結構
<div id="virtual-dom2">
  <p>New Virtual DOM</p>
  <ul id="list">
    <li class="item" style="height: 30px">Item 1</li>
    <li class="item">Item 2</li>
    <li class="item">Item 3</li>
    <li class="item">Item 4</li>
  </ul>
  <div>Hello World</div>
</div>
  • 仍舊是先用虛擬 dom 表示該結構(見index.html
let elNodeNew = createElement("div", { id: "virtual-dom2" }, [
  createElement("p", { color: "red" }, ["New Virtual DOM"]),
  createElement("ul", { id: "list" }, [
    createElement("li", { class: "item", style: "height: 30px" }, ["Item 1"]),
    createElement("li", { class: "item" }, ["Item 2"]),
    createElement("li", { class: "item" }, ["Item 3"]),
    createElement("li", { class: "item" }, ["Item 4"]),
  ]),
  createElement("div", {}, ["Hello World"]),
]);
  • VNode類追加countkeykey用作遍歷時的唯一標識,count用作後續比對(見vnode.js
export default class VNode {
  constructor(tagName, props, children) {
    if (props instanceof Array) {
      // 第二個參數是數組,説明傳的是children,即沒有傳props
      children = props; // 把props賦給原本應是子節點的第三個參數
      props = {}; // props被賦值為空對象
    }
    this.tagName = tagName;
    this.props = props;
    this.children = children;

    // 保存key鍵:如果有屬性則保存key,否則返回undefined
    this.key = props ? props.key : void 0;

    let count = 0;
    this.children.forEach((child, i) => {
      // 如果是元素的實列的話
      if (child instanceof VNode) {
        count += child.count;
      } else {
        // 如果是文本節點的話,直接賦值
        children[i] = "" + child;
      }
      count++; // 每遍歷children後,count都會+1
    });
    this.count = count;
  }
  render() {
    // ...
  }
}

/* elNode為例,追加後查看打印:
  VNode {
    tagName: 'div',
    props: { id: 'virtual-dom' },
    children: [
      VNode {  tagName: 'p', props: {}, children: ['Virtual DOM'], count: 1, key: undefined },
      VNode {
        tagName: 'ul',
        props: { id: 'list' },
        children: [
          VNode { tagName: 'li', props: { class: 'item' }, children: ['Item 1'], count: 1, key: undefined },
          VNode { tagName: 'li', props: { class: 'item' }, children: ['Item 2'], count: 1, key: undefined },
          VNode { tagName: 'li', props: { class: 'item' }, children: ['Item 3'], count: 1, key: undefined },
        ],
        count: 6,
        key: undefined
      },
      VNode { tagName: 'div', props: {}, children: ['Hello World'], count: 1, key: undefined }
    ],
    count: 11,
    key: undefined
  }
*/

比對elNodeelNodeNew

  • 調用diff()方法(見diff.js
export function diff(oldTree, newTree) {
  let index = 0; // 當前節點的標誌
  let patches = {}; // 用來記錄每個節點差異的對象
  deepWalk(oldTree, newTree, index, patches);
  return patches;
}
  • 核心方法deepWalk(),對兩棵樹進行深度優先遍歷(見diff.js):

    • 如果節點被刪除,則無需操作
    • 如果替換文本(肯定無 children),則記錄更新文字
    • 如果標籤相同

      • 如果屬性不同,則記錄更新屬性
      • 比較子節點(如果新節點有ignore屬性,則不需要比較),調用diffChildren()方法,比較子元素的變化
    • 如果標籤不同,則記錄整體重置
    • 前置 1:在patch.js中設置不同的操作類型(patch.js
    let REPLACE = 0; // 整體重置
    let REORDER = 1; // 重新排序
    let PROPS = 2; // 更新屬性
    let TEXT = 3; // 更新文字
    
    patch.REPLACE = REPLACE;
    patch.REORDER = REORDER;
    patch.PROPS = PROPS;
    patch.TEXT = TEXT;
    • 前置 2:判斷新節點是否有ignore屬性的方法isIgnoreChildren()
    function isIgnoreChildren(node) {
      return node.props && node.props.hasOwnProperty("ignore");
    }
import { patch } from "./patch.js";

function deepWalk(oldNode, newNode, index, patches) {
  // console.log(oldNode, newNode);
  let currentPatch = [];
  if (newNode === null) {
    // 節點被刪除掉(真正的DOM節點時,將刪除執行重新排序,所以不需要做任何事)
  } else if (typeof oldNode === "string" && typeof newNode === "string") {
    // 替換文本節點
    if (newNode !== oldNode) {
      currentPatch.push({ type: patch.TEXT, content: newNode }); // type為3,content為新節點文本內容
    }
  } else if (
    oldNode.tagName === newNode.tagName &&
    oldNode.key === newNode.key
  ) {
    // 相同的節點,但是新舊節點的屬性不同的情況下 比較屬性
    let propsPatches = diffProps(oldNode, newNode);
    if (propsPatches) {
      currentPatch.push({ type: patch.PROPS, props: propsPatches }); // type為2
    }
    // console.log(currentPatch);
    // 比較子節點,如果新節點有'ignore'屬性,則不需要比較
    if (!isIgnoreChildren(newNode)) {
      diffChildren(
        oldNode.children,
        newNode.children,
        index,
        patches,
        currentPatch
      );
    }
  } else {
    // 不同的節點,那麼新節點替換舊節點
    currentPatch.push({ type: patch.REPLACE, node: newNode }); // type為0
  }
  // console.log(currentPatch);
  if (currentPatch.length) {
    patches[index] = currentPatch; // 把對應的currentPatch存儲到patches對象內中的對應項
  }
  // console.log(patches);
}
  • deepWalk()對兩顆樹進行比對後,如果節點的標籤相同,則還需調用diffChildren()比較子節點(見diff.js

    • 新舊節點,採用list-diff算法(見listDiff.js),根據key做比對,返回如{ moves: moves, children: children }的數據結構(有關list-diff算法可參見這篇詳解 →,本文不多做贅述)
    • moves為需要操作的步驟,遍歷後記錄為重新排序
    • 遞歸,子節點繼續調用deepWalk()方法
function diffChildren(oldChildren, newChildren, index, patches, currentPatch) {
  // console.log(oldChildren, newChildren, index);
  let diffs = listDiff(oldChildren, newChildren, "key"); // 新舊節點按照字符串'key'來比較
  console.log(diffs);
  newChildren = diffs.children; // diffs.children同listDiff方法中的simulateList,即要操作的相似列表
  if (diffs.moves.length) {
    let recorderPatch = { type: patch.REORDER, moves: diffs.moves };
    currentPatch.push(recorderPatch);
  }
  let leftNode = null;
  let currentNodeIndex = index;
  oldChildren.forEach((child, i) => {
    let newChild = newChildren[i];
    currentNodeIndex =
      leftNode && leftNode.count
        ? currentNodeIndex + leftNode.count + 1 // 非首次遍歷時,leftNode為上一次遍歷的子節點
        : currentNodeIndex + 1; // 首次遍歷時,leftNode為null,currentNodeIndex被賦值為1
    deepWalk(child, newChild, currentNodeIndex, patches); // 遞歸遍歷,直至最內層
    leftNode = child;
  });
}
  • 在頁面中調用diff()方法,比對elNodeelNodeNew(見index.html),返回值即為從elNode變化到elNodeNew需要進行的完整操作
<script type="module">
  import { createElement } from "./create-element.js";
  import { diff } from "./diff.js";
  // let elNode = ...
  // let elNodeNew = ...

  let elRoot = elNode.render(); // 調用VNode原型上的render方法,創建相應節點
  document.body.appendChild(elRoot); // 頁面可渲染與注掉相同的內容

  setTimeout(() => {
    let patches = diff(elNode, elNodeNew);
    console.log(patches);
    /* 
      {
        0: [{ props: {id: 'virtual-dom2', style: undefined}, type: 2 }],
        1: [{ props: {color: 'red'}, type: 2 }],
        2: [{ type: 3, content: 'New Virtual DOM' }],
        3: [{ 
             moves: [{
               index: 3, 
               item: VNode{
                 children: ['Item 4'], 
                 count: 1, 
                 key: undefined, 
                 props: {class: 'item'}, 
                 tagName:  "li"
               }, 
               type: 1
             }],
             type: 1 
           }],
        4: [{ props: {id: 'virtual-dom2', style: undefined}, type: 2 }],
      }
    */
  }, 1000);
</script>

對發生變化的數據進行更新

  • patch()方法,對elRoot(變化前的)和patches(調用diff()返回值)進行操作(見patch.js
export function patch(node, patches) {
  let walker = { index: 0 }; // 從key為0開始遍歷patches
  deepWalk(node, walker, patches); // 調用patch.js裏的deepWalk方法,不是diff.js裏的
}
  • 調用deepWalk()方法,對elRoot的全部子節點進行遍歷和遞歸(見patch.js

    • walker.index初始為 0,每次遍歷加 1
    • 如果在patches中有對應walker.index屬性的項,則調用applyPatches()針對當前節點進行相應操作
    • 重點diff.jsindexpatch.jswalker.index,都是針對elNode的每個節點逐一遍歷直至最內層,因此迴文patches裏的keywalker.index相對應,對當前遍歷到的node執行applyPatches()即可
function deepWalk(node, walker, patches) {
  // console.log(node, walker, patches);
  let currentPatches = patches[walker.index];
  let len = node.childNodes ? node.childNodes.length : 0; // node.childNodes返回包含指定節點的子節點的集合,包括HTML節點、所有屬性、文本節點
  // console.log(node.childNodes, len);
  for (let i = 0; i < len; i++) {
    let child = node.childNodes[i];
    walker.index++;
    deepWalk(child, walker, patches); // 遞歸遍歷,直至最內層(node.childNodes.length為0)
  }
  // console.log(currentPatches);
  if (currentPatches) {
    applyPatches(node, currentPatches); // 在patches中有對應的操作,則執行
  }
}
  • applyPatches()方法會根據傳入的type類型,對節點進行相應操作(見patch.js
function applyPatches(node, currentPatches) {
  // console.log(node, currentPatches);
  currentPatches.forEach((currentPatch) => {
    switch (currentPatch.type) {
      case REPLACE: // 整體重置
        let newNode =
          typeof currentPatch.node === "string"
            ? document.createTextNode(currentPatch.node) // 字符串節點
            : currentPatch.node.render(); // dom節點
        node.parentNode.replaceChild(newNode, node); // 替換子節點
        break;
      case REORDER: // 重新排序
        reorderChildren(node, currentPatch.moves);
        break;
      case PROPS: // 更新屬性
        setProps(node, currentPatch.props);
        break;
      case TEXT: // 更新文字
        if (node.textContent) {
          node.textContent = currentPatch.content;
        } else {
          // ie bug
          node.nodeValue = currentPatch.content;
        }
        break;
      default:
        throw new Error("Unknow patch type" + currentPatch.type);
    }
  });
}
  • reorderChildren()方法對子節點進行排序(見patch.js
function reorderChildren(node, moves) {
  // console.log(node, moves);
  let staticNodeList = Array.from(node.childNodes);
  // console.log(staticNodeList);
  let maps = {};
  staticNodeList.forEach((node) => {
    // 如果是元素節點
    if (node.nodeType === 1) {
      let key = node.getAttribute("key");
      if (key) {
        maps[key] = node;
      }
    }
  });
  moves.forEach((move) => {
    let index = move.index;
    if (move.type === 0) {
      // 移除項
      if (staticNodeList[index] === node.childNodes[index]) {
        node.removeChild(node.childNodes[index]); // 移除該子節點
      }
      staticNodeList.splice(index, 1); // 從staticNodeList數組中移除
    } else if (move.type === 1) {
      // 插入項
      let insertNode = maps[move.item.key]
        ? maps[move.item.key].cloneNode(true)
        : typeof move.item === "object" // 插入節點對象
        ? move.item.render() // 直接渲染
        : document.createTextNode(move.item); // 插入文本
      // console.log(insertNode);
      staticNodeList.splice(index, 0, insertNode); // 插入
      node.insertBefore(insertNode, node.childNodes[index] || null);
    }
  });
}
  • setProps()方法設置屬性(見patch.js
function setProps(node, props) {
  // console.log(node, props);
  for (let key in props) {
    if (props[key] === void 0) {
      node.removeAttribute(key); // 沒有屬性->移除屬性
    } else {
      let value = props[key];
      utils.setAttr(node, key, value); // 有屬性->重新賦值
    }
  }
}
  • 給屬性重新賦值時,需區分屬性為stylevalue兩種情況,屬性為value時還需判斷標籤是否為文本框或文本域(見utils.js
  • utils.js為提供公用方法庫,為方便閲讀簡化代碼,本文解析時未使用源碼中的其他方法,不影響效果
let obj = {
  setAttr: function (node, key, value) {
    switch (key) {
      case "style":
        node.style.cssText = value; // 更新樣式
        break;
      case "value":
        let tagName = node.tagName || "";
        tagName = tagName.toLowerCase();
        if (tagName === "input" || tagName === "textarea") {
          // 輸入框 或 文本域
          node.value = value; // 更新綁定值
        } else {
          // 其餘
          node.setAttribute(key, value); // 更新屬性
        }
        break;
      default:
        node.setAttribute(key, value); // 更新屬性
        break;
    }
  },
};

export { obj as utils };

效果實現

  • 在頁面中將elRootpatches傳給patch()並調用即可(見index.html
<script type="module">
  import { createElement } from "./create-element.js";
  import { diff } from "./diff.js";
  import { patch } from "./patch.js";

  let elNode = createElement("div", { id: "virtual-dom", style: "color:red" }, [
    createElement("p", ["Virtual DOM"]), // 沒有props
    createElement("ul", { id: "list" }, [
      createElement("li", { class: "item" }, ["Item 1"]),
      createElement("li", { class: "item" }, ["Item 2"]),
      createElement("li", { class: "item" }, ["Item 3"]),
    ]),
    createElement("div", ["Hello World"]),
  ]);

  let elRoot = elNode.render(); // 調用VNode原型上的render方法,創建相應節點
  document.body.appendChild(elRoot);

  let elNodeNew = createElement("div", { id: "virtual-dom2" }, [
    createElement("p", { color: "red" }, ["New Virtual DOM"]),
    createElement("ul", { id: "list" }, [
      createElement("li", { class: "item", style: "height: 30px" }, ["Item 1"]),
      createElement("li", { class: "item" }, ["Item 2"]),
      createElement("li", { class: "item" }, ["Item 3"]),
      createElement("li", { class: "item" }, ["Item 4"]),
    ]),
    createElement("div", {}, ["Hello World"]),
  ]);

  setTimeout(() => {
    let patches = diff(elNode, elNodeNew);
    console.log(patches);
    patch(elRoot, patches); // 執行patch方法
  }, 1000); // 1秒後,由elNode變化成elNodeNew,elRoot本身沒有重新掛載,實現虛擬dom更新
</script>

核心 dom 方法

  • 虛擬 dom 只是節省了節點更新次數,但萬變不離其宗,最終還是要更新真實 dom 的,大體涉及到的方法如下
document.createTextNode(txt); // 創建文本節點
node.setAttribute(key, value); // 設置節點屬性
node.removeAttribute(key); // 移除節點屬性
parentNode.replaceChild(newNode, node); // 替換子節點
parentNode.removeChild(node); // 移除子節點
parentNode.insertBefore(node, existNode); // 追加子節點
user avatar jidongdehai_co4lxh 頭像 tigerandflower 頭像 webxejir 頭像 esunr 頭像 coderleo 頭像 mulander 頭像 79px 頭像 musicfe 頭像 pangsir8983 頭像 mrqueue 頭像 dashnowords 頭像 warn 頭像
60 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.