“虚拟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的本质有一个更清晰、更深刻的认识。

分析完毕,谢谢大家🙂

相关推荐
慧一居士10 分钟前
flex 布局完整功能介绍和示例演示
前端
DoraBigHead12 分钟前
小哆啦解题记——两数失踪事件
前端·算法·面试
一斤代码6 小时前
vue3 下载图片(标签内容可转图)
前端·javascript·vue
中微子6 小时前
React Router 源码深度剖析解决面试中的深层次问题
前端·react.js
光影少年6 小时前
从前端转go开发的学习路线
前端·学习·golang
中微子6 小时前
React Router 面试指南:从基础到实战
前端·react.js·前端框架
3Katrina6 小时前
深入理解 useLayoutEffect:解决 UI "闪烁"问题的利器
前端·javascript·面试
前端_学习之路7 小时前
React--Fiber 架构
前端·react.js·架构
coderlin_7 小时前
BI布局拖拽 (1) 深入react-gird-layout源码
android·javascript·react.js
伍哥的传说8 小时前
React 实现五子棋人机对战小游戏
前端·javascript·react.js·前端框架·node.js·ecmascript·js