“虚拟DOM”到底是什么?我们用300行代码来实现一个

提到现代前端框架,比如React、Vue,你一定听过"虚拟DOM"(Virtual DOM)这个词。它被认为是提升性能的关键所在,是框架设计的核心思想之一。

但是,虚拟DOM到底是什么?它为什么能带来性能提升?它内部又是如何工作的?

与其停留在概念层面,不如我们一起动手,用大约300行左右的JavaScript代码,实现一个最简化的"虚拟DOM",来揭开它。

什么是"虚拟DOM"?

简单来说,虚拟DOM就是一个用普通的JavaScript对象(plain JavaScript objects)来描述真实DOM结构的"轻量级副本"。

想象一下,真实的DOM就像一棵庞大而复杂的树,包含各种HTML元素、属性、事件等等。直接操作真实DOM的代价是昂贵的,因为这会触发浏览器的重排(Layout)和重绘(Paint),影响性能。

而虚拟DOM,就像是存在于内存中的一个"草稿",我们可以在这个"草稿"上进行各种修改,最后再将"修改稿"批量更新到真实的DOM上。

用JavaScript对象描述DOM

我们的"虚拟DOM"需要能够表示HTML元素及其属性。我们可以用一个简单的JavaScript对象来描述一个DOM节点:

比如,一个这样的真实DOM节点:

html 复制代码
<div id="app" class="container">
    <h1>Hello, Virtual DOM!</h1>
</div>

可以用这样的虚拟DOM对象来表示:

javascript 复制代码
const virtualDom = {
  type: 'div',
  props: {
    id: 'app',
    className: 'container'
  },
  children: [{
    type: 'h1',
    props: {},
    children: ['Hello, Virtual DOM\!']
  }]
};

可以看到,每个虚拟DOM节点都有以下几个关键属性:

  • type: 节点的标签名(比如 'div', 'h1')。
  • props: 一个包含节点属性的对象(比如 { id: 'app', className: 'container' })。
  • children: 一个包含子节点的数组。子节点可以是其他的虚拟DOM对象,也可以是简单的文本内容(字符串)。

创建真实DOM节点

现在,我们需要一个函数,能将我们的虚拟DOM对象"渲染"成真实的DOM节点:

javascript 复制代码
function createElement(vnode) {
  if (typeof vnode === 'string') {
    return document.createTextNode(vnode);
  }
  const $el = document.createElement(vnode.type);
  for (const key in vnode.props) {
    if (vnode.props.hasOwnProperty(key)) {
      $el.setAttribute(key, vnode.props(key));
    }
  }
  vnode.children.map(createElement).forEach($el.appendChild.bind($el));
  return $el;
}

这个 createElement 函数:

  • 如果 vnode 是字符串,直接创建一个文本节点。
  • 否则,创建一个对应 vnode.type 的HTML元素。
  • 遍历 vnode.props,将属性设置到创建的元素上。
  • 递归地处理 vnode.children,将它们创建成真实的DOM节点,并添加到当前元素的子节点中。

现在,如果我们执行 createElement(virtualDom),我们就能得到对应的真实DOM结构。

对比两棵虚拟DOM树(Diffing)

虚拟DOM的核心价值在于"按需更新"。当数据发生变化时,我们不是直接操作真实DOM,而是先创建一个新的虚拟DOM树,然后将新的虚拟DOM树与旧的虚拟DOM树进行比较(diff),找出它们之间的差异,最后只更新那些真正发生变化的部分到真实DOM上。

这是最复杂,也是最关键的一步。我们的简化版Diff算法会关注以下几个方面:

javascript 复制代码
function diff(oldVnode, newVnode) {
  // 1. 类型不同,直接替换
  if (oldVnode.type !== newVnode.type) {
    return {
      type: 'REPLACE',
      newNode: createElement(newVnode)
    };
  }

  // 2. 文本节点内容不同,更新文本
  if (typeof oldVnode === 'string' && typeof newVnode === 'string' && oldVnode !== newVnode) {
    return {
      type: 'TEXT',
      content: newVnode
    };
  }

  // 3. 比较属性差异
  const propsDiff = diffProps(oldVnode.props, newVnode.props);

  // 4. 比较子节点差异
  const childrenDiff = diffChildren(oldVnode.children, newVnode.children);

  if (propsDiff.length > 0 || childrenDiff.length > 0) {
    return {
      type: 'PROPS_AND_CHILDREN',
      props: propsDiff,
      children: childrenDiff
    };
  } else {
    return null; // 没有变化
  }
}

function diffProps(oldProps, newProps) {
  const patches = [];
  const allProps = {
    ...oldProps,
    ...newProps
  };
  for (const key in allProps) {
    if (oldProps(key) !== newProps(key)) {
      patches.push({
        type: 'CHANGE',
        key,
        value: newProps(key)
      });
    }
  }
  return patches;
}

function diffChildren(oldChildren, newChildren) {
  const patches = [];
  const maxLength = Math.max(oldChildren.length, newChildren.length);
  for (let i = 0; i < maxLength; i++) {
    patches.push(diff(oldChildren(i), newChildren(i)));
  }
  return patches;
}

我们的简化版 diff 函数:

  • 如果新旧虚拟DOM节点的类型不同,我们直接返回一个 REPLACE 类型的更新。
  • 如果都是文本节点,且内容不同,我们返回一个 TEXT 类型的更新。
  • 调用 diffProps 比较属性的差异。
  • 调用 diffChildren 递归地比较子节点的差异。
  • 如果属性或子节点有变化,返回一个 PROPS_AND_CHILDREN 类型的更新,包含具体的属性差异和子节点差异。

更新真实DOM

最后,我们需要一个 patch 函数,根据 diff 函数返回的差异对象,来更新真实的DOM:

javascript 复制代码
function patch($node, patches) {
  if (!patches) {
    return;
  }

  switch (patches.type) {
    case 'REPLACE':
      return $node.parentNode.replaceChild(patches.newNode, $node);
    case 'TEXT':
      return ($node.textContent = patches.content);
    case 'PROPS_AND_CHILDREN':
      patchProps($node, patches.props);
      patches.children.forEach((childPatch, i) => {
        patch($node.childNodes(i), childPatch);
      });
      break;
    default:
      break;
  }
}

function patchProps($node, propsPatches) {
  propsPatches.forEach(propPatch => {
    if (propPatch.type === 'CHANGE') {
      $node.setAttribute(propPatch.key, propPatch.value);
    }
  });
}

这个 patch 函数:

  • 根据 patches.type 来执行不同的更新操作。
  • REPLACE: 直接替换整个节点。
  • TEXT: 更新节点的文本内容。
  • PROPS_AND_CHILDREN: 调用 patchProps 更新属性,并递归地处理子节点的 patches

一个简单的例子

现在,我们把这些函数串联起来,看一个简单的例子:

javascript 复制代码
const initialVDOM = {
  type: 'div',
  props: {
    id: 'app'
  },
  children: [{
      type: 'p',
      props: {},
      children: ['Count: ', {
        type: 'span',
        props: {
          class: 'count'
        },
        children: ['0']
      }]
    },
    {
      type: 'button',
      props: {
        onclick: () => updateCount()
      },
      children: ['Increment']
    }
  ]
};

let currentVDOM = initialVDOM;
const $root = document.getElementById('root');
const $el = createElement(initialVDOM);
$root.appendChild($el);

let count = 0;

function updateCount() {
  count++;
  const newVDOM = {
    type: 'div',
    props: {
      id: 'app'
    },
    children: [{
        type: 'p',
        props: {},
        children: ['Count: ', {
          type: 'span',
          props: {
            class: 'count'
          },
          children: [count + '']
        }]
      },
      {
        type: 'button',
        props: {
          onclick: () => updateCount()
        },
        children: ['Increment']
      }
    ]
  };
  const patches = diff(currentVDOM, newVDOM);
  patch($el, patches);
  currentVDOM = newVDOM;
}

在这个例子中:

  • 我们创建了一个初始的虚拟DOM initialVDOM 并渲染到页面上。
  • updateCount 函数模拟了数据更新,创建了一个新的虚拟DOM newVDOM
  • 我们使用 diff 函数比较 currentVDOMnewVDOM,得到差异 patches
  • 我们使用 patch 函数将这些差异应用到真实的DOM $el 上。
  • 最后,更新 currentVDOMnewVDOM,为下一次更新做准备。

当你点击按钮时,你会发现只有 <span> 标签里的数字更新了,而整个 <div><p> 标签并没有重新创建或渲染,这就是虚拟DOM带来的"按需更新"的性能优化。


我们用不到300行的代码,实现了一个非常简化的虚拟DOM。它包含了虚拟DOM的核心思想:

  1. 用JavaScript对象描述DOM结构。
  2. 将虚拟DOM渲染成真实DOM。
  3. 当数据变化时,创建新的虚拟DOM树。
  4. 比较新旧虚拟DOM树的差异(Diffing)。
  5. 只将差异更新到真实的DOM上(Patching)。

当然,真实的React、Vue等框架的虚拟DOM实现要复杂得多,它们会考虑更多的性能优化、Key的处理、组件的生命周期等等。但是,理解了这个最核心的流程,你就能对虚拟DOM的本质有一个更清晰、更深刻的认识。

分析完毕,谢谢大家🙂

相关推荐
前端大卫1 小时前
Vue3 + Element-Plus 自定义虚拟表格滚动实现方案【附源码】
前端
却尘1 小时前
Next.js 请求最佳实践 - vercel 2026一月发布指南
前端·react.js·next.js
ccnocare1 小时前
浅浅看一下设计模式
前端
Lee川1 小时前
🎬 从标签到屏幕:揭秘现代网页构建与适配之道
前端·面试
Ticnix2 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人2 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl2 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅2 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人2 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼2 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端