现代前端框架的基石: 虚拟 DOM
一入行就开始写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
这个节点整个给注销掉;
然后,再重新走一遍刚才的渲染流程:
- 数据+模板=HTML代码;
- 把 HTML 代码渲染到页面上,形成真实的 DOM。
注意到没有?这一通操作波及了太多无辜群众!
本来我只是想改 jack 的名字,现在整个表格都需要被重新渲染。DOM 操作的范围,从小小的一个表格字段位,扩大到了整个表格。
这会带来严重的性能问题,那为什么会带来性能问题呢?
我们可以简单的把一个简单的 div
元素的属性都打印出来,如图所示:
可以看到真正的 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
比对,找出需要修改的地方,进行定点修改。
因此,当数据更新时,现在的流程是这样的:
- 数据 + 模板 = 虚拟 DOM
diff
新旧两套虚拟 DOM
的差异,得到补丁集- 把"补丁"打到真实 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
方案,但它一直处于不稳定的状态,这导致了它一直无法被大规模地应用。
现在通过vnode
把HTML
进行javascript
化了,那么就可以利用js
模块化规范把HTML
组件化了,这为大规模前端项目的落地提供了可能。
总结
从利用js
手动修改太繁琐,产生了模板渲染,这样大大提高了生产效率。
又因为模板更新每次都是整体更新,当页面内容很复杂时,容易产生卡顿。
那怎么把定点修改+模板数据驱动结合在一起呢?
答案是vnode+diff
。
这就诞生了MVVM
框架,其中的佼佼者是react/vue
。
到这里,你应该能真正理解了什么是虚拟 DOM,而不是再是泛泛而谈了。
转载自:https://juejin.cn/post/7240996011547820069