likes
comments
collection
share

现代前端框架的基石: 虚拟 DOM

作者站长头像
站长
· 阅读数 9

一入行就开始写vue2,不知不觉写了很多年了,头几年学会了 vue api的使用,对内部的原理一无所知,后几年开始慢慢跟着网上的大佬学习源码,收获颇多。

于是决定从源码的角度对vue2的理解写一下,即是为后面学习vue3打下坚实的基础,也为彻底告别vue2转向vue3做个纪念。

本文是该系列文章的第二篇,欢迎阅读。

学习虚拟 DOM,首先要搞清楚它的出现是为了解决什么问题。

DOM 操作演化史

在十多年前,当我们要修改<div id="app">内容</div>里面的内容时,只能这么修改:

document.getElementById("app").innerHTML = '新内容'

如果前端页面"展示"的属性远远强于其"交互"的属性,前端工程师需要关心的 DOM 操作是有限的。

这样看来,使用 JS、jQuery 来定点对 DOM 进行修改好像也不是什么特别让人头大的事情。

随着前端业务复杂度不断提升,前端页面对交互体验的要求越来越高,骤增的动态内容带来了大量的 DOM 修改需求。

此时若再要求前端工程师们去逐一修改 DOM 节点,其工作量将大到令人绝望。很快,大佬们就创造出了模板这一解决方案。

比如说我有一个表格需要展示,那么我可以给它一组初始化数据data

[
  {
    name: 'jack',
    sex: '男'
  },
  {
    name: 'jim',
    sex: '女'
  },
  {
    name: 'tom',
    sex: '男'
  }
]

然后把这组数据塞进模版template 去:

<table>
  {% data.forEach(function(item){ %}
  <tr>
    <td>{% item.name %}</td>
    <td>{% item.sex %}</td>
    </tr>
  {% }); %}
</table>

模板会把data数据源读进去,塞到上面这段 template 代码里,吐出一段目标 HTML给你。然后这段 HTML 代码就可以直接被拿去渲染到页面上,成为 DOM

大致过程如下:

// 数据和模板融合出 HTML 代码
var targetDOM = template({data: data})
// 添加到页面中去
document.body.appendChild(targetDOM)

有了模板的帮助,我们只需要关心数据及数据变化,而不必操心具体的 DOM 细节,大大解放了生产力。

但是模板也会有问题,看下面这个场景:

如果我发现上述表格中某个名字写错了,jack 其实叫 jump。现在我要把这个名字改掉,于是我改了 data 里对应的姓名信息,模板会做什么呢?

首先,模板引擎会把 targetDOM 这个节点整个给注销掉;

然后,再重新走一遍刚才的渲染流程:

  1. 数据+模板=HTML代码;
  2. 把 HTML 代码渲染到页面上,形成真实的 DOM。

注意到没有?这一通操作波及了太多无辜群众!

本来我只是想改 jack 的名字,现在整个表格都需要被重新渲染。DOM 操作的范围,从小小的一个表格字段位,扩大到了整个表格。

这会带来严重的性能问题,那为什么会带来性能问题呢?

我们可以简单的把一个简单的 div 元素的属性都打印出来,如图所示:

现代前端框架的基石:  虚拟 DOM

可以看到真正的 DOM 元素是非常庞大的,因为浏览器的标准就把 DOM 设计的非常复杂,当我们频繁的去做 DOM 更新,会产生一定的性能问题。

由于上述更新过程中涉及的 DOM 节点注销和 DOM 节点添加,都是真刀真枪、真耗性能的 DOM 操作。当我们更新频率稍微高一点时,页面就会吃不消了。因此,模板渲染方案并不能很好地解决更新的问题。

现代前端框架的基石:虚拟 DOM

上面我们利用模板来实现 DOM 操作,当数据更新时,进行了如下操作:

注销旧 DOM -> 数据 + 模板 => 新的一套HTML 代码 -> 挂载新 DOM

"旧 DOM"、"新 DOM"指的都是模板对应的整块 DOM,每次更新都是整个DOM的整体更新。

如果有一种方法,可以既帮我们保持住模板方案的数据驱动思想,又做到像JS、jQuery 一样能够定点只对需要修改的 DOM 做小范围操作,那该多好!

那如何做到定点修改呢?

只需要对新旧 DOM 进行比较,找出其中的不同,然后对不同的 DOM 进行修改即可。

这就是大名鼎鼎的 diff 算法。

但是我们不可能对HTML节点进行比较,比如下面两个HTML节点:

DOM
<div>内容</div>
DOM
<div>新内容</div>

因为diff算法只能对对象进行比较,既然是对对象进行比较,那么可以把HTML对应的DOM对象进行比较啊,因为DOM就是一个对象。

也不行,因为在前一节可以看到,浏览器把DOM对象设计得很复杂,有几百个属性和方法,我们判断两个HTML的不同只需要比较几个关键属性即可。

因此,需要对DOM对象进行瘦身,瘦身后的DOM对象就是:虚拟DOM(vnode)

export default class VNode {
    this.tag = tag // 标签名
    this.data = data // 标签属性
    this.children = children // 子节点
    this.text = text // 文本节点
    this.elm = elm // 虚拟dom所对应的真实dom节点
    ...
  }
}

vnode 是对真实 DOM 的一种抽象描述,它的核心定义无非就几个关键属性,标签名、数据、子节点、键值等,其它属性都是用来扩展 VNode 的灵活性以及实现一些特殊需求的。

由于 VNode 只是用来映射到真实 DOM 的渲染,不需要包含操作 DOM 的方法,因此它是非常轻量和简单的

这样我们就可以对虚拟DOM进行diff比对,找出需要修改的地方,进行定点修改。

因此,当数据更新时,现在的流程是这样的:

  1. 数据 + 模板 = 虚拟 DOM
  2. diff 新旧两套虚拟 DOM 的差异,得到补丁集
  3. 把"补丁"打到真实 DOM 上。

其中,虚拟 DOM 这一层是用 JS 实现的。也就是说在这个阶段所有的更改、对比操作都是纯 JS 层面的计算,JS vs DOM 操作,其性能消耗完全不在一个量级上。

如此一来,简单粗暴的"删了重写",变成了灵活精确的"定点打击"!

模板渲染带来的性能问题,就这样被 虚拟DOM 完美地解决了。

虚拟 DOM 的好处有哪些?

当你问一些前端开发人员这样一个问题:你觉得虚拟 DOM 好在哪时?

90% 的人都会脱口而出说性能好

那为什么虚拟 DOM 性能好呢?

有人说虚拟 DOM 比手动操作真实 DOM 要快,所以虚拟 DOM 性能更好

这种说法是错的

因为同样的 DOM 更改,你使用原生 JS 手动去操作,是不需要走 diff 流程的,实际上要快一些。

React/Vue它们从来没有声称:我比直接操作 DOM 更快。

这里说的虚拟DOM性能好,主要要看跟谁比:

  • 跟直接操作 DOM 比,那恐怕还是直接操作 DOM 快一些

因为,在每次组件渲染生成vnode的过程中,会有一定的耗时,大组件尤其如此。

举个例子,对于一个1000行 x 10列的 table 组件,组件渲染生成vnode的过程会遍历 10000 次去创建内部的cell vnode,整体耗时就会变得比较长。再加上挂载 vnode 生成真实 DOM 的过程也会有一定的耗时,所以当我们更新组件的时候,用户会明显感觉到卡顿。

虽然diff算法在减少DOM操作方面足够优秀,但最终还是免不了操作DOM。

  • 跟模板比,虚拟 DOM 从性能上确实好很多

模板每次数据变更时,它会直接重置整个DOM,即使变更的只是一小段数据。相比之下,虚拟 DOM 方案每次只更新必要的 DOM,虽然它增加了 diff 过程,但是增加的是 js 计算,换来的可是 DOM 开销。

其实,性能好并不是vnode的最大优势所在

我认为vnode最大的好处是可跨平台

虚拟DOM用JS对象描述了界面信息,这样就可以在不同的平台上渲染成平台对应的界面,比如基于vnode做服务端渲染、weex平台渲染,以及小程序平台的渲染。

还有一个重大好处是:模板的组件化

我们都知道JS是可以模块化开发的,但是HTML是不可以的,虽然业界也提出了web components方案,但它一直处于不稳定的状态,这导致了它一直无法被大规模地应用。

现在通过vnodeHTML进行javascript化了,那么就可以利用js模块化规范把HTML组件化了,这为大规模前端项目的落地提供了可能。

总结

从利用js手动修改太繁琐,产生了模板渲染,这样大大提高了生产效率。

又因为模板更新每次都是整体更新,当页面内容很复杂时,容易产生卡顿。

那怎么把定点修改+模板数据驱动结合在一起呢?

答案是vnode+diff

这就诞生了MVVM框架,其中的佼佼者是react/vue

到这里,你应该能真正理解了什么是虚拟 DOM,而不是再是泛泛而谈了。