从DOM到虚拟DOM

——前端DOM发展史、性能与产能双赢背后的思考


写在前面

开发工程化的基石——设计模式

MVC:经典设计模式。

  • 什么是设计模式?

    设计模式是一套被反复使用的代码设计经验的总结。使用设计模式是为了重用代码、让代码更容易被他人理解、保证代码可靠性。

设计模式是开发工程化的基石。可以理解成面对一个现实需求时候的常态解决方案框架。

  • MVC模式:

    MVC:Model View Controller,即模型、视图、控制器。

    Model层:模型(用于封装业务逻辑相关的数据和对数据的操控)

    View层:试图(渲染图形化界面,即GUI)

    Controller层:控制器(M和V直接的连接器,主要处理核心业务逻辑,数据通信、控制M和V的更新等)

image-20201209151231812

前端中的MVC

具体解释:Model 和服务器交互,Model 将得到的数据交给 Controller,Controller 把数据填入 View,并监听 View
用户操作 View,如点击按钮,Controller 就会接受到点击事件,Controller 这时会去调用 Model,Model 会与服务器交互,得到数据后返回给 Controller,Controller 得到数据就去更新 View

  • MVC在前端分别负责什么
    • Model 数据管理,包括数据逻辑、数据请求、数据存储等功能。前端 Model 主要负责 AJAX 请求或者 LocalStorage 存储
    • View 负责用户界面,前端 View 主要负责 HTML 渲染。
    • Controller 负责处理View 的事件,并更新 Model;也负责监听 Model的变化,并更新 ViewController 控制其他的所有流程。

MVVM:

我们看到的是一个典型的 MVC 设置。Model 呈现数据,View 呈现用户界面,而 View Controller 调节它两者之间的交互。Cool!

稍微考虑一下,虽然 View 和 View Controller 是技术上不同的组件,但它们几乎总是手牵手在一起,成对的。你什么时候看到一个 View 能够与不同 View Controller 配对?或者反过来?所以,为什么不正规化它们的连接呢?

在典型的 MVC 应用里,许多逻辑被放在 View Controller 里。它们中的一些确实属于 View Controller,但更多的是所谓的“表示逻辑(presentation logic)”,以 MVVM 属术语来说,就是那些将 Model 数据转换为 View 可以呈现的东西的事情。

我们的图解里缺少某些东西,那些使我们可以把所有表示逻辑放进去的东西。我们打算将其称为 “View Model” —— 它位于 View/Controller 与 Model 之间:

img

个图解准确地描述了什么是 MVVM:一个 MVC 的增强版,我们正式连接了视图和控制器,并将表示逻辑从 Controller 移出放到一个新的对象里,即 View Model。MVVM 听起来很复杂,但它本质上就是一个精心优化的 MVC 架构,而 MVC 你早已熟悉。

img

MVVM将“数据模型数据双向绑定”的思想作为核心,因此在View和Model之间没有联系,通过ViewModel进行交互,而且Model和ViewModel之间的交互是双向的,因此视图的数据的变化会同时修改数据源,而数据源数据的变化也会立即反应到View上。即,ViewModel 是一个 View 信息的存储结构,ViewModel 和 View 上的信息是一一映射关系。

使用MVVM模式的好处

  • 低耦合。View可以独立于Model变化和修改,一个ViewModel可以绑定到不同的View上,当View变化的时候Model可以不变,当Model变化的时候View也可以不变。
  • 可重用性。可以把一些视图的逻辑放在ViewModel里面,让很多View重用这段视图逻辑。
  • 独立开发。开发人员可以专注与业务逻辑和数据的开发(ViewModel)。设计人员可以专注于界面(View)的设计。
  • 可测试性。可以针对ViewModel来对界面(View)进行测试

使用 MVVM 模式,程序的 UI 和其背后的展现与业务逻辑将被分离至三个类中:

  • 1-视图,封装 UI 与 UI 逻辑
  • 2-模型视图,封装展示逻辑与状态
  • 3-模型,封装程序的业务逻辑以及数据

在 MVVM 模式中,视图通过数据绑定以及命令行与视图模型交互,并改变事件通知。视图模型查询观察并协调模型更新,转换,校验以及聚合数据,从而在视图显示。

以Vue/React为例:

比如我现在有这样一个显示 count 的 React 应用, 下面这段代码相当于告诉 React :

我现在想渲染一段 html, 它的 innerHtml 就是this.props.count 的值

img

这其实就是 MVVM 中 VM 的部分.

在MVVM中,model只关心数据,和验证,不关心行为。

在MVVM中,view是用户最直观的交互部分。

在MVVM中,viewmodel是一个专门转换数据的控制中心。控制这view的显示和model的数据信息。

Vue的双向数据绑定是通过Object.defineProperty的get/set对M层数据进行监控,当数据发生变化时,自动更新VM层绑定的数据,而当用户更改了VM层表单控件的数据时,通过v-model自动更新到M层(v-model是对表单控件的事件的封装)

从DOM到虚拟DOM,我们应该提出哪些问题?

在如今面向面试学习的大氛围下,我们最常用被问到的关于虚拟DOM的问题是:

“什么是虚拟DOM?”

“为什么要用虚拟DOM?”

第一个问题其实比较好解答,个人觉得比较好的回答是:“虚拟DOM本质上是一组JS到DOM的映射,他在表现形式上呈现为一个包含了所有DOM所需信息的JS对象。”其实一般而言,是什么之类的问题会比较好解答。因为你只需要描述他,而为什么之类的问题就往往难以下口,因为其往往内部蕴含这很多很多延伸的信息。

于是乎在我听到看到过对于第二个问题的解答中,最多的是:“选择使用虚拟DOM的原因是因为直接操作DOM节点的代价太昂贵,而操作JS的成本就要小的多,直接操作DOM节点会引起浏览器的回流重绘,JS则可以发挥他的优势自由选择操作时机和方式。

但真的仅仅是如此吗?

让我们拉开我们的视野,不再死盯着虚拟DOM这一个单词,而是回望整个前端高速迭代的这几年,重新思考一下以下这些问题。

  • 在没有虚拟DOM前,我们经历了什么?
  • 是什么让程序员们萌生了使用虚拟DOM的想法?
  • 虚拟DOM的优势到底是什么,是什么让它成为了时代的选择?
  • 是什么推动了前端职能和功能的高速发展?

身为前端,我们经历了什么?

虽然现在大家可能都听说过vue/react的框架,但是他们都是在一个怎么样的环境中间诞生的呢?前端的发展其实也是根据我们现实的需求去逐渐发展的。对这些发展有一个大局上的认识,对于你后续学习的方向大概会更加明确一点。

静态页面与切图——低需求下的低要求

从前(听说的),有这么一个年代,前端同学们最麻烦的工作是切图,最枯燥的任务是搓DOM,所以又被成为切图仔…

那时候只有一些初级版本的js框架,包括但不限于prototype,开发者靠着大量手搓DOM和原生JS实现一些简单的交互,高端的操作可能会事扒脚本,扒组件以实现自己的功能。

这个时期的前端开发者很纯粹,那时尚未开始前后端分离的进程,开发者要做的就是将页面合理的呈现出来,以及实现不多的交互需求。

JQ时代——日益增长的需求与低下开发效率的矛盾

一件事物一旦出现,历史的浪潮就会裹挟着它不断的向前进步。很快的,人们对于页面的要求不再是单纯的能用就行——他们需要更加炫酷的交互,这为前端工程师们带来了难题,究其原因,还是因为那时原生的js-api实在是又缺又难用。于是,JQ等一大批js框架便应运而生了。

同时代的框架包括但不限于JQ,Dojo,prototype等等,这些对于年轻的前端工程师们来说无疑已经是近乎古董的代名词。可是在当时那个年代,后浏览器战争时代带来的后遗症——兼容性问题已经是折磨当时工程师们的主要凶手(当然现在也是,但在越来越丰富的插件辅助下已经缓解许多)。JQ凭借着其优异的兼容性和性能,在一代js框架中占据了主流。

从当今的眼光来看,JQ依然是不可多得的好框架,得益于Sizzle选择器引擎的研发成功,其性能有了质的突破。它将编写前端代码从后端操纵类的思维中脱离出来,便携式的获取一个或一组DOM节点,并且提供了链式调用。前端开发者们不再需要费劲心思的去想着怎么去合理的操作DOM,他们只要关心业务逻辑的实现即可。

JQ时代是需求和效率第一次大型冲突的产物,从此,解放生产力成为了前端工程师心中亘古不变的追求。

这也许就是表明,看似偶然的东西其实是必然会出现的,需求、动力、发展、事物产生与jQuery的诞生。

一个成熟的东西显然不是一口气就出来的,所谓“一铲子挖不了一口井”,我想jQuery的原作者再天才,也是循序渐进过来的,如何个循序渐进法,我想,很有可能就是需求驱动而产生的。

1. gelElementById太长了
页面上有个按钮,还有个图片,我想点击按钮图片隐藏,如下HTML:

1
2
<button id="button">点击我</button>
<img id="image" src="xxx.jpg">

于是,我的脚本可能就这样:

1
2
3
4
5
6
var button = document.getElementById("button"),
image = document.getElementById("image")

button.onclick = function() {
image.style.display = "none";
};

有何问题?人几乎都是天生的“懒惰者”,document.getElementById名称长且重复出现,好像到了公司发现卡没带又回家重新拿卡的感觉,我希望越简单越好。恩,我很喜欢钱,$这个符号我很喜欢,我打算改造一番,简化我的工作:

1
2
3
4
5
6
7
var $ = function(id) {
return document.getElementById(id);
};

$("button").onclick = function() {
$("image").style.display = "none";
};

这里的$()就是最简单的包装器,只是返回的是原生的DOM对象。

2. 我需要一个简洁的暗号,就像“芝麻开门”
后来页面复杂了,点击一个按钮,有2张图片要隐藏。

1
2
3
4
$("button").onclick = function() {
$("image1").style.display = "none";
$("image2").style.display = "none";
};

好像又看见长长的重复的东西,xxx.style.display = "none", 为什么每次开门都要从包里找到钥匙、对准插口插进去、还要左扭扭右扭扭呢?一次还好,天天经常老是这样怎受得了。设想,要是有个芝麻开门的暗号就好了,“open开”,声音识别,门自动开了,多省心。

这里每次隐藏都要xxx.style.display = "none", 比每天拿钥匙开门还要烦,我希望有一个快捷的方式,例如,“hide隐”,语句识别,元素自动隐藏,多省心。

就是要变成下面的效果:

1
2
3
4
$("button").onclick = function() {
$("image1").hide();
$("image2").hide();
};

3. 如何识别“芝麻开门”的暗号
$("image1")本质是个DOM元素,$("image1").hide()也就是在DOM元素上扩展一个hide方法,调用即隐藏。

哦,扩展,立马想到了JS中的prototype原型。//zxx: 老板,现在满大街什么菜最便宜。老板:原型啊,都泛滥了!

1
2
3
HTMLElement.prototype.hide = function() {
this.style.display = "none";
};

后JQ时代——进一步提升效率的模板语法

JQ和其高度发达的生态环境催生了JQ插件的高速发展,当时的前端开发者们想要实现一个功能,第一个反应就去搜插件,所以在当时的HTML文件内经常密密麻麻的插入了十几甚至几十的<script>标签,这不仅让页面变的臃肿不堪,更带来了全局变量污染的问题和性能问题(例如经典的白屏问题)。

受累于不同素质的开发者(插件素质层次不齐)以及日益庞大的插件库所带来的全局变量污染问题,前端开发者们从后端同胞的代码中得到了启发——他们也想要模块化。

在那个年代,javaScript尚没有importexport这样的标准化支持,于是他们便自己建立了一个标准,那就是CommonJs。当然,之后还有分裂出AMD,CMD等等。这为后来Node.js的蓬勃发展埋下了伏笔。模块化相关知识与本文主旨关系不大,所以不展开细说。

同时JQ也没能很好的解决循环插入DOM节点的需求,拼接数据组成的DOM字符串让开发者们叫苦不迭。于是乎模版语法也运营而生了。用一个经典的公式来概括,那就是:

1
HTML = template(data)

可以看出,template其实就是一种将数据转为DOM的规则——而且能自定义!而template的原理其实很简单,大致分为以下几步:

  • 接受数据,解析他们
  • 填入模板
  • innerHtml

示例

首先我们从示例的HTML部分开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<table id="producttable">
<thead>
<tr>
<td>UPC_Code</td>
<td>Product_Name</td>
</tr>
</thead>
<tbody>
<!-- 现有数据可以可选地包括在这里 -->
</tbody>
</table>

<template id="productrow">
<tr>
<td class="record"></td>
<td></td>
</tr>
</template>

首先,我们有一个表,稍后我们将使用JavaScript代码在其中插入内容。然后是模板,它描述了表示单个表行的HTML片段的结构。

既然已经创建了表并定义了模板,我们使用JavaScript将行插入到表中,每一行都是以模板为基础构建的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 通过检查来测试浏览器是否支持HTML模板元素 
// 用于保存模板元素的内容属性。
if ('content' in document.createElement('template')) {

// 使用现有的HTML tbody实例化表和该行与模板
let t = document.querySelector('#productrow'),
td = t.content.querySelectorAll("td");
td[0].textContent = "1235646565";
td[1].textContent = "Stuff";

// 克隆新行并将其插入表中
let tb = document.getElementsByTagName("tbody");
let clone = document.importNode(t.content, true);
tb[0].appendChild(clone);

// 创建一个新行
td[0].textContent = "0384928528";
td[1].textContent = "Acme Kidney Beans";

// 克隆新行并将其插入表中
let clone2 = document.importNode(t.content, true);
tb[0].appendChild(clone2);

} else {
// 找到另一种方法来添加行到表,因为不支持HTML模板元素。
}

结果是原始的HTML表格,通过JavaScript添加了两行新内容:

在解决了循环插入DOM节点的需求后前端工程师们发现了模板语法更大的优点——你要做的只是数据到DOM节点的映射,不用去点对点的操作DOM,不用关心数据如何变化,你只需要控制规则。兴许是从那一刻开始,前端开发者们寻到了一个崭新而又正确的发展方向——数据驱动。

但模版语法也有他的缺点。说白了,他只是一个数据的解析器,你不可能指望他去解决多么复杂炫酷的问题。而且在早期的模板语法中,他的更新会将所有dom节点注销,然后生成完整的新节点再插入页面中。这样大批量的操作dom必然会导致性能问题——操作dom的开销实在是太大了,何况是一整个页面的的dom呢?

于是乎开发者们在写的爽和用的爽之间陷入了两难,但用的不爽是绝对不行的(你懂的),于是乎也不知是哪一位大佬突然在某天拍案而起,操作dom开销那么大,我操作js不就行了!

三大框架时代——虚拟DOM的狂飙

操作dom开销那么大,我操作js不就行了?

这个疯狂却又合理想法一旦产生就再也无法扑灭,因为这太适合js了。js在诞生之初就可以操作DOM的本能,而js本身的能力又那么强大,模板语法的缺点它完全可以消化和包容,包括但不限于差量更新 ,批量更新等一系列梦寐以求的可以提高渲染性能的手段终于可以提上日程了。

于是,新世界的大门就此打开。

img

至此,我们来明确一点,虚拟DOM,正是作为数据和真实DOM之间的缓冲诞生。

有了虚拟DOM,前端世界迸发了更大的活力,Angular,React,Vue这三种基于虚拟DOM的框架在大浪淘沙之后终于存活了下来,成为了新世界的主流。

虚拟DOM的优势到底是什么,是什么让它成为了时代的选择?

什么是虚拟DOM?

虚拟DOM(Virtual Dom),也就是我们常说的虚拟节点,是用JS对象来模拟真实DOM中的节点,该对象包含了真实DOM的结构及其属性,用于对比虚拟DOM和真实DOM的差异,从而进行局部渲染来达到优化性能的目的。

真实的元素节点:

1
2
3
<div id="wrap">
<p class="title">Hello world!</p>
</div>

VNode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
tag:'div',
attrs:{
id:'wrap'
},
children:[
{
tag:'p',
text:'Hello world!',
attrs:{
class:'title',
}
}
]
}

为什么使用虚拟DOM?

简单了解虚拟DOM后,是不是有小伙伴会问:Vue和React框架中为什么会用到它呢?好问题!那来解决下小伙伴的疑问。

起初我们在使用JS/JQuery时,不可避免的会大量操作DOM,而DOM的变化又会引发回流或重绘,从而降低页面渲染性能。那么怎样来减少对DOM的操作呢?此时虚拟DOM应用而生,所以虚拟DOM出现的主要目的就是为了减少频繁操作DOM而引起回流重绘所引发的性能问题的

虚拟DOM的作用是什么?

  1. 兼容性好。因为Vnode本质是JS对象,所以不管Node还是浏览器环境,都可以操作;
  2. 减少了对Dom的操作。页面中的数据和状态变化,都通过Vnode对比,只需要在比对完之后更新DOM,不需要频繁操作,提高了页面性能;

虚拟DOM和真实DOM的区别?

说到这里,那么虚拟DOM和真实DOM的区别是什么呢?总结大概如下:

  • 虚拟DOM不会进行回流和重绘;
  • 真实DOM在频繁操作时引发的回流重绘导致性能很低;
  • 虚拟DOM频繁修改,然后一次性对比差异并修改真实DOM,最后进行依次回流重绘,减少了真实DOM中多次回流重绘引起的性能损耗;
  • 虚拟DOM有效降低大面积的重绘与排版,因为是和真实DOM对比,更新差异部分,所以只渲染局部;

DIFF算法

当数据变化时,vue如何来更新视图的?其实很简单,一开始会根据真实DOM生成虚拟DOM,当虚拟DOM某个节点的数据改变后会生成一个新的Vnode,然后VNode和oldVnode对比,把不同的地方修改在真实DOM上,最后再使得oldVnode的值为Vnode。

diff过程就是调用patch函数,比较新老节点,一边比较一边给真实DOM打补丁(patch);

正如之前说的,虚拟DOM是作为数据和真实DOM之间的缓冲层诞生的。数据通过某种模板/语法糖/函数将数据转换为虚拟DOM,从而实现在js层面的控制。这个转换,在vue里是<template>,在react中是JSX

react中<div>虚拟DOM</div>的虚拟DOM形式 img

这让“差量更新”这一至关重要的功能得以应用,新的虚拟DOM树会和旧虚拟DOM树以diff算法进行比较,形成一个“补丁”,最后用batch方法将这个补丁打到需要更新的节点上。差量更新既能让开发者感受到舒适的开发体验,又保持了优异的性能。可谓是写的爽和用的爽的双赢典范。

在“差量更新”这一至关重要的功能得以应用之外,还有一个重要的功能是“批量更新”,即用户在短时间内dom进行高频操作的时候会取最后一次的操作结果,以此避免大量的大成本的性能消耗。这里就涉及batch方法的内容了,他会缓冲每次生成的补丁集,然后把它们放入一个队列中,算出一个渲染结果后再将结果交给渲染函数,以此实现批量更新。

有那么多优势的情况下,人们下意识的认为虚拟DOM的性能优于模板语法,可这真的对吗?

我们不妨回头看看两者的流程区别,模板语法的流程是 数据->模板->真实dom,虚拟DOM的流程是数据->模版/算法/语法糖->虚拟dom->一系列js操作->真实dom

你突然发现,虚拟DOM的流程明明长了那么多,为什么大家会说它的性能更好?

其实这个问题的答案应该分环境来讲: 在数据量少的情况下,两者性能相差无几。数据量多的情况下,若是数据改变大,接近于全页面更新,模版语法性能更好。在局部更新为主的环境下,虚拟DOM的性能更好。而这,恰好是最频繁的线上情况。

在现代的模板语法中,模板语法同样具有将数据转化为虚拟DOM的能力。所以大家别下意识的认定虚拟DOM的性能一定优于模板语法。这种说法是有谬误的,应该说在特定情况下,应用虚拟DOM有着比单独应用模板语法更优越的性能。

更进一步来说,虚拟DOM提供了一个同一化的能够操作dom对象的入口,在如今越来越提倡多端代码一套共用的今天,虚拟DOM可以是IOS界面,可以是安卓界面,可以是小程序,可以是其他…它需要的只是不同的转译,这就实现了真正的多端通用。

至此,我们来总结一下虚拟DOM的优势到底是什么。

1.它让用户用的很爽,因为它解决了页面性能优化的关键性痛点。

2.他让开发者开发的很爽,爽意味着迭代的效率,意味着社区维护的积极性。

3.他能解决多端复用统一性的问题,这可以减少成本,而减少成本,意味着占有市场。

是什么推动了前端职能和功能的高速发展?

从之前的阐述中,我们不经惊叹前端技术栈的日新月异。从一个静态页面切图仔到如今的大前端大全栈微前端,仿佛前端的大厦在短短的十几年里尘埃落定,熠熠生辉。

那么是什么推动了前端职能和功能的高速发展呢?

这个问题我想认真了解前端发展史的同学一定会若有所思。我们不如换个更落地的问题。

什么样的技术/项目,会成为受欢迎的项目,甚至引领环境的发展呢?

从我的角度来说。所有能称之为成功的技术/项目都有一个特点——性能和产能的双赢

JQ解放了开发者的生产力同时拥有一般原生api无法比拟的性能,于是它是一个时代的标志。

虚拟DOM,精准解决了多交互情况下性能不佳的痛点,又让开发者从DOM的迷锁中解脱,只去关注数据和数据的变化,更为多端统一打开了大门

Node.js,让js也能写后端,划时代的理念

作为一名开发者,技术的提升会随着精力的消逝而慢慢暂缓,而性能和产能将会是我们最需要关注和关心的项目指标。此时,前瞻性,系统性的思维就必不可少。

这篇文章其实没有阐述太多的技术细节,我想向大家传达的是一种理解技术的思路。这种技术,这个项目为什么会火?在它的背后有没有什么实现的动机?它的前身或者前辈们曾踏足的道路是如何的?若是展望未来,你会有什么想法?它的存在解决了什么痛点?当我们以超脱技术的视野去俯瞰技术本身,我们可能能比单纯的技术实现收获的更多。

最后,前端这个职业从微尘发展至今,我们有理由相信它的未来在星辰大海。


参考资料:

https://www.cnblogs.com/aaa6818162/p/4915830.html

https://juejin.cn/post/6900887137239957512#heading-3

https://juejin.cn/post/6900887137239957512

https://mp.weixin.qq.com/s/lq7XQGM56CovF2Q_Y8Ht4A