阅读视图

发现新文章,点击刷新页面。

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

image.png

提到现代前端框架,比如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节点:

<div id="app" class="container">
    <h1>Hello, Virtual DOM!</h1>
</div>

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

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节点:

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算法会关注以下几个方面:

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:

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

一个简单的例子

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

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

分析完毕,谢谢大家🙂

我为什么放弃了“大厂梦”,去了一家“小公司”?

我,前端八年。我的履历上,没有那些能让HR眼前一亮的名字,比如字节、阿里,国内那些头部的互联网公司。

“每个程序员都有一个大厂梦”,这句话我听了八年。说实话,我也有过,而且非常强烈。

刚毕业那几年,我把进大厂当作唯一的目标。我刷过算法题,背过“八股文”,也曾一次次地在面试中被刷下来。那种“求之不得”的滋味,相信很多人都体会过。

但今天,我想聊的是,我是如何从一开始的“执念”,到后来的“审视”,再到现在的“坦然”,并最终心甘情愿地在一家小公司里,找到了属于我自己的价值。

这是一个普通的、三十多岁的工程师,与自己和解的经历。


那段“求之不得”的日子

我还记得大概四五年前,是我冲击大厂最疯狂的时候。

市面上所有关于React底层原理、V8引擎、事件循环的面经,我都能倒背如流。我把LeetCode热题前100道刷了两遍,看到“数组”、“链表”这些词,脑子里就能自动冒出“双指针”、“哈希表”这些解法。

我信心满满地投简历,然后参加了一轮又一轮的面试。

结果呢?大部分都是在三轮、四轮之后,收到一句“感谢您的参与,我们后续会保持联系”。我一次次地复盘,是我哪里没答好?是项目经验不够亮眼?还是算法题的最优解没写出来?

那种感觉很糟糕。你会陷入一种深深的自我怀疑,觉得自己的能力是不是有问题,是不是自己“不配”进入那个“高手如云”的世界。


开始问自己:“大厂”真的是唯一的出路吗?

在经历了一段密集而失败的面试后,我累了,也开始冷静下来思考。

我观察身边那些成功进入大厂的朋友。他们确实有很高的薪水和很好的福利,但他们也常常在半夜的朋友圈里,吐槽着无休止的会议、复杂的流程、以及自己只是庞大系统里一颗“螺丝钉”的无力感。

我看到他们为了一个需求,要跟七八个不同部门的人“对齐”;看到他们写的代码,90%都是在维护内部庞大而陈旧的系统;看到他们即使想做一个小小的技术改进,也要经过层层审批。

我突然问自己:这真的是我想要的生活吗?我想要的是什么?

当我把这些想清楚之后,我发现,大厂的光环,对我来说,好像没那么耀眼了。


在“小公司”,找到了意想不到的“宝藏”

后来,我加入了一家规模不大的科技公司。在这里,我确实找到了我想要的东西。

成了一个“产品工程师”,而不仅仅是“前端工程师”

在小公司,边界是模糊的。

我不仅要写前端代码,有时候也得用Node.js写一点中间层。我需要自己去研究CI/CD,把自动化部署的流程跑起来。我甚至需要直接跟客户沟通,去理解他们最原始的需求。

这个过程很“野”,也很累,但我的成长是全方位的。我不再只关心页面好不好看,我开始关心整个产品的逻辑、服务器的成本、用户的留存。我的视野被强制性地拉高了。

“影响力”被无限放大

在这里,我就是前端的负责人。

用Vue还是React?用Tailwind CSS还是CSS Modules?这些技术决策,我能够和老板、和团队一起讨论,并最终拍板。我们建立的每一个前端规范,写的每一个公共组件,都会立刻成为整个团队的标准。

这种“规则制定者”的身份,和在大厂当一个“规则遵守者”,是完全不同的体验。你能清晰地看到自己的每一个决定,都对产品和团队产生了直接而深远的影响。

离“价值”更近了

最重要的一点是,我能非常直接地感受到自己工作的价值。

我花一周时间开发的新功能上线后,第二天就能从运营同事那里拿到用户的反馈数据。我知道用户喜不喜欢它,它有没有帮助公司赚到钱。这种即时的、正向的反馈,比任何KPI或者年终奖金,更能给我带来成就感。


还会羡慕那些在大厂的朋友吗?

当然会。我羡慕他们优厚的薪酬福利,羡慕他们能参与到改变数亿人的项目中去。

但我不再因此而焦虑,也不再因此而自我否定。

你可以多想一想你真正想要的是什么? 一个公司的名字,并不能定义你作为一名工程师的价值。你的价值,体现在你写的代码里,体现在你解决的问题里,也有可能体现在你创造的产品里。

找到一个能让你发光发热的地方,比挤进一个让你黯淡无光的地方,重要得多。

分享完毕。谢谢大家🙂

❌