Virtual DOM的实现原理
Virtual DOM的实现原理
课程目标
- 了解什么是虚拟
DOM,以及虚拟DOM的作用 Snabbdom的基本使用(Vue内部的虚拟Dom是改造了开源库Snabbdom)Snabbdom的源码解析
在面试的时候经常会问到虚拟DOM是怎么工作的,通过查看Snabbdom源码,可以对这块内容有更加深入的了解。
1、什么是Virtual DOM
Virtual Dom(虚拟DOM),是由普通的JS对象来描述DOM对象,因为不是真实的DOM对象,所以叫做Virtual DOM.
我们为什么用虚拟DOM来模拟真实的DOM呢?
因为我们知道一个DOM对象中的成员是非常多。所以创建Dom对象的成本非常高。
如果使用虚拟Dom来描述真实Dom,就会发现创建的成员少,成本也就低了。
2、为什么使用Virtual DOM
手动操作
Dom比较麻烦,还需要考虑浏览器兼容性问题,虽然有Jquery等库简化DOM操作,但是随着项目的复杂度越来越高,DOM操作复杂提升,既要考虑Dom操作,还要考虑数据的操作。为了简化
DOM的复杂操作于是出现了各种的MVVM框架,MVVM框架解决了视图和状态的同步问题,也就是当数据发生变化,更新视图,当视图发生变化更新数据。为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题(当数据发生了变化后,无法获取上一次的状态,只有将页面上的元素删除,然后在重新创建,这时页面有刷新的问题,同时频繁操作
Dom,性能也会非常低),于是Virtual Dom出现了。Virtual Dom的好处就是当状态改变时不需要立即更新DOM,只需要创建一个虚拟树来描述DOM,Virtual Dom内部将弄清楚如何有效(diff)的更新DOM.(例如:向用户添加列表中添加一个用户,只添加新的内容,原有的结构会被重用)下面,我们看一段代码,该代码是使用
jquery来实现的数据展示与排序,是纯DOM操作的方式
<html>
<head>
<title></title>
<meta charset="utf-8">
<script type="text/javascript" src="https://code.jquery.com/jquery-1.11.3.js"></script>
</head>
<body>
<div id="app">
</div>
<div id="sort" style="margin-top: 20px;">按年纪排序</div>
<script type="text/javascript">
var datas = [
{ 'name': 'kongzhi11', 'age': 32 },
{ 'name': 'kongzhi44', 'age': 29 },
{ 'name': 'kongzhi22', 'age': 31 },
{ 'name': 'kongzhi33', 'age': 30 }
];
var render = function() {
var html = '';
datas.forEach(function(item, index) {
html += `<li>
<div class="u-cls">
<span class="name">姓名:${item.name}</span>
<span class="age" style="margin-left:20px;">年龄:${item.age}</span>
<span class="closed">x</span>
</div>
</li>`;
});
return html;
};
$("#app").html(render());
$('#sort').on('click', function() {
datas = datas.sort(function(a, b) {
return a.age - b.age;
});
$('#app').html(render());
})
</script>
</body>
</html>如上
demo排序,虽然在使用jquery时代这种方式是可行的,我们点击按钮,它就可以从小到大的排序,但是它比较暴力,它会将之前的dom全部删除,然后重新渲染新的dom节点,我们知道,操作DOM会影响页面的性能,并且有时候数据根本就没有发生改变,我们希望未更改的数据不需要重新渲染操作。因此虚拟
DOM的思想就出来了,虚拟DOM的思想是先控制数据再到视图,但是数据状态是通过diff比对,它会比对新旧虚拟DOM节点,然后找出两者之前的不同,然后再把不同的节点再发生渲染操作。如下图所示:

总结:
虚拟
DOM可以维护程序的状态,跟踪上一次的状态通过比较前后两次状态的差异来更新真实
DOM
3、虚拟DOM的作用
维护视图和状态的关系(虚拟DOM会记录状态的变化,只需要更新状态变化的内容就可以了)
复杂视图情况下提升渲染性能。
下面我们看一个案例,该案例的功能比较简单,单击按钮后,更新div中的内容。
let div=document.querySelector('#app')  | 
以上代码非常简单,而且是使用DOM操作的方式来实现的。
如果上面的案例,我们使用虚拟DOM来实现,应该怎样处理呢?首先,我们要创建一个虚拟DOM的对象,
虚拟DOM对象就是一个普通的JS对象。当单击按钮的时候,需要对比两次状态的差异。所以说,仅仅是该案例,
我们使用虚拟DOM的方式来实现,要比使用纯DOM的方式来实现,性能要低。
所以说,并不是所有的情况下使用虚拟DOM都会提升性能的。只有在视图比较复杂的情况下使用虚拟DOM才会提升渲染的性能。
虚拟DOM除了渲染DOM以外,还可以实现渲染到其它的平台,例如可以实现服务端渲染(ssr),原生应用(React Native),小程序(uni-app等)。以上列举的案例中,内部都使用了虚拟DOM.
Vue中虚拟DOM生成真实DOM的过程

下面我们重点要讲解的就是一个开源的虚拟DOM库—Snabbdom.
从Vue2.x开始内部使用的虚拟DOM,就是改造的Snabbdom.
Snabbdom源码大约200行作用,可以通过模块来进行扩展,所以功能比较强大。
源码使用TypeScript开发,官方宣称是最块的Virtual Dom之一。
4、Snabbdom基本使用
4.1 创建项目
在讲解Snabbdom的基本使用之前,我们先来创建一个项目。
打包工具为了方便使用,使用了parcel,你也可以使用webpack.
下面创建项目,并安装parcel
//创建项目目录  | 
配置package.json中的scripts‘
"srcipts":{  | 
创建目录结构
index.html  | 
4.2 导入Snabbdom
方法文档:
https://github.com/snabbdom/snabbdom  | 
下面先安装Snabbdom.(这里最新版本有问题,可以先安装0.7.4)
npm install snabbdom@0.7.4  | 
在项目的js文件夹下的01- basicusage.js文件中,添加如下代码:
import snabbdom from "snabbdom";  | 
以上代码的意思就是导入snabbdom这个包,然后打印其内容。
项目的启动
npm run dev  | 
这时,开启的端口号为1234
http://localhost:1234  | 
在打开的浏览器中,查看控制台的输出,发现输出的内容为undefined
为什么输出的是undefined呢?
这里我们需要查看对应的源码,
在node_modules/snabbdom/snabbdom.js文件中,
我们可以看到在整个文件中,并没有使用export default的方式进行导出,所以就不能使用import snabbdom这种方式进行导入。同时在,源码中,我们可以看到导出了三项内容分别为h,thunk,init.
所以,导入的代码修改成如下的形式
import { h, thunk, init } from "snabbdom";  | 
Snabbdom的核心仅提供最基本的功能,只导出了三个函数init(),h( ),thunk( )
init函数是高阶函数,返回patch( ),一会在看该方法
h函数返回虚拟节点VNode,这个函数我们在使用Vue.js的时候见过。
   h()函数用于创建虚拟DOM,在Snabbdom中用VNode描述虚拟节点,也就是虚拟DOM。
new Vue({  | 
thunk函数是一种优化策略,可以在处理不可变数据时使用(用于优化复杂的视图)。
4.3 Snabbdom的基本使用
下面我们来看一下Snabbdom的基本使用,在01- basicusage.js文件中编写如下代码
import { h, thunk, init } from "snabbdom";  | 
运行上面的代码,可以在浏览器中看到Hello World.
可以查看对应生成的元素。

下面我们再来看一个问题,假如在某个时刻需要重新获取服务端的数据,并且将获取到的数据重新渲染到该div中(id='container'的div)。
我们这里,就需要重新创建一个VNode, 然后传递给patch,让patch比较一下新的VNode与原有的VNode之间的差异。
补充后的代码如下:
import { h, thunk, init } from "snabbdom";  | 
在上面的代码中,我们又创建了一个虚拟DOM ,vnode。然后把这个vnode与oldNode进行对比,最后渲染到页面中。
下面我们在做一个案例:
在js目录下面在创建一个文件:02-basicusage.js
实现的代码如下:
// 本案例实现的要求是:在div中设置子元素h1,p  | 
同时还需要修改index.html文件中的引入。
<body>  | 
这时,可以在浏览器中查看更新后的内容。
下面我们再来看另外一个问题,就是模拟从服务器获取数据,然后更新页面中的内容。
在上面的代码中,再增加如下的内容:
// 本案例实现的要求是:在div中设置子元素h1,p  | 
在上面的代码中,首先记录第一次patch方法更新后的vnode,同时在2秒钟以后,通过h函数重新创建了一个虚拟DOM,并且通过patch函数与原有的虚拟DOM进行比较,然后重新更新页面内容。
2秒钟以后清空节点内容
let a = patch(oldVnode, vnode);  | 
4.4 模块
Snabbdom的核心库并不能处理元素的属性/样式/事件等,如果需要处理,可以使用模块.
常用模块
官方提供了6个模块
 **attributes**:设置DOM元素的属性,内部使用setAttribute()来设置属性,处理布尔类型的属性(可以对布尔类型的属性作相应的判断处理,布尔类型的属性,我们比较熟悉的有selected,checked`等)。
props:  和attributes模块类似,设置DOM元素的属性element[attr]=value,不处理布尔类型的属性。
class: 切换样式类,注意:给元素设置类样式是通过sel选择器。··
dataset:设置 HTML5 中的 data-* 的自定义属性
eventlisteners: 注册和移除事件
style:设置行内样式,支持动画(内部创建transitionend事件),会增加额外的属性:delayed / remove / destroy
下面看一下模块的使用
使用模块的步骤:
第一步:导入需要的模块
第二步:在init()中注册模块
第三步:使用h函数创建VNode的时候,可以把第二个参数设置为对象(对象中是模块需要的数据,可以设置行内样式、事件等),其它参数往后移。
下面我们要实现的案例,就是给div添加一个背景,同时为其添加一个单击事件,当然在div中还要创建两个元素分别是h1与p.
具体实现的代码如下:
import { init, h } from "snabbdom";  | 
注意:在index.html文件中要引入以上代码所在的js文件,如下所示:
<body>  | 
5、Snabbdom源码解读
通过前面的学习,我们可以总结出Snabbdom的核心:
使用
h( )函数创建JavaScript对象(VNode)描述真实DOM。init( )函数设置模块,创建patch( )函数。patch( )函数比较新旧两个VNode把变化的内容更新到真实
DOM树上
5.1  h函数
h函数介绍
在使用Vue的时候加过h( )函数
new Vue({  | 
render函数的参数就是Snabbdom中的h函数,当然在Vue中将h函数做了一定的修改,可以用来支持组件,原有的Snabbdom中的h函数不支持组件的内容。在Snabbdom中h( )函数的作用就是用来创建VNode.
在看源码之前,我们先来了解一个概念:函数重载。
因为在源码中用到了函数重载。
所谓的函数重载: 参数个数或类型不同的函数,称之为函数重载
但是在JavaScript中没有重载的概念,在TypeScript中是有重载的。
我们来看一段重载的示例代码
function add(a,b){  | 
以上我们是通过参数个数的形式,展示了一段函数重载的代码。
下面我们来看一下h函数的源码。
源码位置:node_modules/snabbdom/src/h.ts
// h函数的重载  | 
addNs方法的实现如下:
function addNS(data: any, children: VNodes | undefined, sel: string | undefined): void {  | 
addNs方法中就是给data添加了命名空间,然后通过递归的方式给chilren中的所有子元素都添加了命名空间。
在看VNode这个方法的源码之前,我们先来说一下看源码必备的快捷键。
- 光标移动到某个变量处,按
F12快速定位到该变量的定义位置。 ALT+ 左方向键,回到上次的代码位置Ctrl+ 单击,跳转到某个变量的定义处- 选中某个变量或方法名,按
F12显示出该变量或方法的具体代码 
5.2 VNode函数
在h函数的最后调用了VNode函数创建了一个虚拟节点,并返回。下面看一下VNode函数内部实现。
VNode函数的代码在vnode.ts文件中
import {Hooks} from './hooks';  | 
在上面的代码中,我们首先关注的就是接口VNode,该接口中定义了很多的属性,而最终vnode这个函数返回的VNode对象必须都要实现该接口中的这些属性。
下面可以看一下这些属性的含义
export interface VNode {  | 
最后看一下vnode这个函数,返回的就是一个js对象,该对象中包含了VNode这个接口中的属性,而这个js对象就行虚拟节点。而这个虚拟的节点是怎样转换成真实的DOM?后面会重点讲解这块内容。
5.3 复习h函数与Vnode函数应用
下面我们在通过一个案例,复习一下h函数与vnode函数。
假如,我们创建了如下的一个虚拟DOM
// 构造一个虚拟dom  | 
下面看一下上面的代码的执行流程。
注意:这边先执行的是先内部的调用,然后再依次往外执行调用。
因此首先调用和执行的代码是:
第一步: h('span', {style: {fontWeight: 'bold'}}, "my name is kongzhi"), 因此把参数传递到h函数后:sel: 'span', b = {style: {fontWeight: 'bold'}}, c = "my name is kongzhi";
首先判断 if (c !== undefined) {} 代码,然后进入if语句内部代码,如下:
if (c !== undefined) {  | 
因此 data = {style: {fontWeight: 'bold'}}; 然后判断 c 是否是一个数组,可以看到,不是,因此进入 else if语句,因此 text = "my name is zhangsan"; 从代码中可以看到,就直接跳过所有的代码了,最后执行 return VNode(sel, data, children, text, undefined); 了,因此会调用 snabbdom/vnode.js 代码如下:
/*  | 
因此 var key = data.key = undefined; 最后返回值如下:
{  | 
第二步:调用h('a', {props: {href: '/foo'}}, '我是张三');代码
同理:sel = 'a'; b = {props: {href: '/foo'}}, c = '我是张三'; 然后执行如下代码:
if (c !== undefined) {  | 
因此 data = {props: {href: '/foo'}}; text = '我是张三'; children = undefined; 最后也一样执行返回:
return VNode(sel, data, children, text, undefined);  | 
因此又调用 snabbdom/vnode.js 代码如下:
/*  | 
因此执行代码:var key = data.key = undefined; 最后返回值如下:
{  | 
第三步调用外层的代码,把参数传递进去,因此代码初始化变成如下:
var vnode = h('div#app',  | 
继续把参数传递进去,因此 sel = 'div#app'; b = {style: {color: '#000'}}; c 的值变为如下:
c = [  | 
首先看if判断语句,if (c !== undefined) {}; 因此会进入if语句内部代码;
if (c !== undefined) {  | 
因此 data = {style: {color: '#000'}}; c 是数组的话,就把c赋值给children; 因此 children 值为如下
children = [  | 
我们下面接着看 如下代码:
if (is.array(children)) {  | 
如上代码,判断如果 children 是一个数组的话,就循环该数组 children; 从上面我们知道 children 长度为3,因此会循环3次。进入for循环内部。判断其中一项是否是数字和字符串类型,因此只有 ' and xxxx' 符合要求,因此 children[1] = VNode(undefined, undefined, undefined, ' and xxxx'); 最后会调用 snabbdom/vnode.js 代码如下:
module.exports = function(sel, data, children, text, elm) {  | 
通过上面的代码可知,我们最后返回的是如下:
children[1] = {  | 
执行完成后,我们最后返回代码:return VNode(sel, data, children, text, undefined); 因此会继续调用snabbdom/vnode.js代码如下:
/*  | 
因此继续执行内部代码:var key = undefined; 最后返回代码:
return {  | 
因此最后构造一个虚拟dom返回的值为如下:
vnode = {  | 
接着往下执行如下代码:
// 初始化容器  | 
以上就是生成整个虚拟DOM的过程。








