手写一个Virtual DOM及源码解析

关注前端小讴,阅读更多原创技术文章

  • Virtual DOM是当今主流框架普遍采用的提高 web 页面性能 的方案,其原理是:
    • 1.把真实的 DOM 树转换成 js 对象(虚拟 DOM)
    • 2.数据更新时生成新的 js 对象(新的虚拟 DOM)
    • 3.二者比对后仅对发生变化的数据进行更新

完整代码参考 →

js 对象模拟 DOM 树

  • 假设有如下 html 结构(见index.html
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
js 复制代码
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
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;
  }
}
js 复制代码
export function createElement(tagName, props, children) {
  return new VNode(tagName, props, children);
}
  • 注掉页面原本的html结构并调用createElement方法(见index.html),可渲染同样的内容
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 结构
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
js 复制代码
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
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
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

    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()
    js 复制代码
    function isIgnoreChildren(node) {
      return node.props && node.props.hasOwnProperty("ignore");
    }
js 复制代码
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()方法
js 复制代码
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需要进行的完整操作
html 复制代码
<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
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()即可
js 复制代码
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
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
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
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为提供公用方法库,为方便阅读简化代码,本文解析时未使用源码中的其他方法,不影响效果
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
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 的,大体涉及到的方法如下
js 复制代码
document.createTextNode(txt); // 创建文本节点
node.setAttribute(key, value); // 设置节点属性
node.removeAttribute(key); // 移除节点属性
parentNode.replaceChild(newNode, node); // 替换子节点
parentNode.removeChild(node); // 移除子节点
parentNode.insertBefore(node, existNode); // 追加子节点
相关推荐
黑客老陈41 分钟前
新手小白如何挖掘cnvd通用漏洞之存储xss漏洞(利用xss钓鱼)
运维·服务器·前端·网络·安全·web3·xss
正小安1 小时前
Vite系列课程 | 11. Vite 配置文件中 CSS 配置(Modules 模块化篇)
前端·vite
编程百晓君1 小时前
一文解释清楚OpenHarmony面向全场景的分布式操作系统
vue.js
暴富的Tdy1 小时前
【CryptoJS库AES加密】
前端·javascript·vue.js
neeef_se1 小时前
Vue中使用a标签下载静态资源文件(比如excel、pdf等),纯前端操作
前端·vue.js·excel
m0_748235611 小时前
web 渗透学习指南——初学者防入狱篇
前端
℘团子এ1 小时前
js和html中,将Excel文件渲染在页面上
javascript·html·excel
z千鑫1 小时前
【前端】入门指南:Vue中使用Node.js进行数据库CRUD操作的详细步骤
前端·vue.js·node.js