从零实现一个简易的 Virtual DOM 与 Diff 算法

为什么需要 Virtual DOM

在直接操作 DOM 的时代,频繁的 DOM 操作是性能瓶颈的主要来源。每次修改 DOM 都可能触发浏览器的重排(reflow)和重绘(repaint),而这两个过程的代价非常高昂。Virtual DOM 的核心思路是:用 JavaScript 对象来描述真实 DOM 的结构,在内存中完成新旧状态的对比,只将必要的变更应用到真实 DOM 上。

这样做带来两个明显的好处:一是减少了对真实 DOM 的无谓操作;二是让开发者可以用声明式的方式描述 UI,而不必关心具体的 DOM 操作细节。

设计 VNode 结构

Virtual DOM 的最小单元是虚拟节点(VNode)。一个 VNode 本质上就是一个普通的 JavaScript 对象,用来描述某个真实 DOM 元素应该长什么样。

function createElement(tag, props, children) {
  return {
    tag,          // 标签名,如 'div'、'span'
    props,        // 属性对象,如 { class: 'box', id: 'app' }
    children,     // 子节点数组,元素是 VNode 或字符串
  };
}

这个结构非常简洁,但已经足够表达一棵完整的 DOM 树。例如,<div class="box"><span>hello</span></div> 可以表示为:

createElement('div', { class: 'box' }, [
  createElement('span', null, ['hello'])
]);

将 VNode 渲染为真实 DOM

有了 VNode 之后,我们需要一个函数把它转换成真实的 DOM 节点。

function render(vnode) {
  // 文本节点直接创建文本
  if (typeof vnode === 'string') {
    return document.createTextNode(vnode);
  }

  const el = document.createElement(vnode.tag);

  // 设置属性
  if (vnode.props) {
    for (const key in vnode.props) {
      el.setAttribute(key, vnode.props[key]);
    }
  }

  // 递归渲染子节点
  if (vnode.children) {
    vnode.children.forEach(child => {
      el.appendChild(render(child));
    });
  }

  return el;
}

这个 render 函数是递归的,它会深度优先地创建整棵 DOM 树。但仅仅有渲染还不够——真正的价值在于当 VNode 发生变化时,如何高效地更新真实 DOM。

Diff 算法的核心思想

Diff 算法要解决的问题是:给定新旧两棵 VNode 树,找出最小的变更操作集合。完整的树 Diff 算法时间复杂度为 O(n³),在实际框架中不可接受。React 等框架通过三条启发式策略将复杂度降到 O(n):

  1. 只比较同层节点,不跨层级比较。如果某个节点在新树中消失了,整个子树会被删除,不会去尝试复用它的子节点。
  2. 类型不同则直接替换。如果新旧节点的 tag 不同,直接销毁旧的、创建新的,不再深入比较。
  3. 通过 key 标识同层节点,帮助算法识别哪些节点是"同一个",从而支持节点的移动和复用。

我们这里实现一个简化版本,覆盖前两条策略,并加入基础的 key 支持。

实现 patch 函数

patch 函数是整个 Diff 算法的入口,它接收真实 DOM 节点、旧 VNode 和新 VNode,完成更新。

function patch(el, oldVnode, newVnode) {
  // 情况一:新旧都是文本节点
  if (typeof oldVnode === 'string' && typeof newVnode === 'string') {
    if (oldVnode !== newVnode) {
      el.textContent = newVnode;
    }
    return;
  }

  // 情况二:类型不同,直接替换
  if (typeof oldVnode !== typeof newVnode || oldVnode.tag !== newVnode.tag) {
    const newEl = render(newVnode);
    el.parentNode.replaceChild(newEl, el);
    return;
  }

  // 情况三:类型相同,更新属性和子节点
  updateProps(el, oldVnode.props, newVnode.props);
  updateChildren(el, oldVnode.children, newVnode.children);
}

更新属性

属性更新需要处理三种情况:新增属性、删除属性、修改属性值。

function updateProps(el, oldProps = {}, newProps = {}) {
  // 删除旧属性中不再存在的
  for (const key in oldProps) {
    if (!(key in newProps)) {
      el.removeAttribute(key);
    }
  }

  // 设置新增或修改的属性
  for (const key in newProps) {
    if (oldProps[key] !== newProps[key]) {
      el.setAttribute(key, newProps[key]);
    }
  }
}

更新子节点

子节点的更新是 Diff 算法中最复杂的部分。我们实现一个基于 key 的简化版本,核心逻辑是:先建立旧子节点的 key 到索引的映射,然后遍历新子节点,对于每个新节点,如果能在旧节点中找到相同 key 且类型相同的节点,就复用并递归 patch,否则创建新节点。

function updateChildren(el, oldChildren = [], newChildren = []) {
  const oldMap = new Map();
  oldChildren.forEach((child, index) => {
    const key = child.props && child.props.key
      ? child.props.key
      : `__index_${index}`;
    oldMap.set(key, { child, index });
  });

  newChildren.forEach((newChild, i) => {
    const key = newChild.props && newChild.props.key
      ? newChild.props.key
      : `__index_${i}`;
    const matched = oldMap.get(key);

    if (matched && matched.child.tag === newChild.tag) {
      // 复用旧节点,递归更新
      const realEl = el.childNodes[matched.index];
      patch(realEl, matched.child, newChild);
      oldMap.delete(key);
    } else {
      // 创建新节点并插入
      const newEl = render(newChild);
      if (el.childNodes[i]) {
        el.insertBefore(newEl, el.childNodes[i]);
      } else {
        el.appendChild(newEl);
      }
    }
  });

  // 删除未复用的旧节点
  oldMap.forEach(({ index }) => {
    const node = el.childNodes[index];
    if (node) el.removeChild(node);
  });
}

这个实现虽然简化,但已经体现了 Diff 的核心思路:通过 key 匹配可复用的节点,只对真正变化的节点执行创建或删除操作。

完整示例

把上面的代码组合起来,就可以实现一个最小可用的 Virtual DOM 系统:

const oldTree = createElement('ul', { class: 'list' }, [
  createElement('li', { key: 'a' }, ['Apple']),
  createElement('li', { key: 'b' }, ['Banana']),
]);

const newTree = createElement('ul', { class: 'list' }, [
  createElement('li', { key: 'b' }, ['Banana']),
  createElement('li', { key: 'c' }, ['Cherry']),
]);

const root = document.getElementById('app');
root.appendChild(render(oldTree));
patch(root.firstChild, oldTree, newTree);

局限与延伸

这个简易实现省略了许多生产级框架中的优化,例如:没有处理事件绑定与解绑、没有实现双端比较(React 的 reconcileChildren 或 Vue 的双端 diff)、没有处理 key 为数字类型时的边界情况、没有支持 Fragment 和组件节点。此外,我们使用 childNodes 索引来定位真实节点,在节点移动场景下可能不够精确——更健壮的做法是维护 VNode 到真实 DOM 的映射关系。

但理解了这个简化版本之后,再去阅读 React 的 reconcileChildren 或 Vue 的 patchKeyedChildren,思路会清晰很多。Virtual DOM 的本质并不神秘,它就是用 JavaScript 对象描述 UI 结构,用 Diff 算法找出最小变更,再批量应用到真实 DOM 上。掌握了这个核心,剩下的都是工程上的优化与取舍。

未经允许不得转载:任鹏个人博客 » 从零实现一个简易的 Virtual DOM 与 Diff 算法

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏