Virtual DOM
什么是Virtual DOM
在Vue中Virtual DOM是一个非常重要的一部分。它是对真实DOM的一种抽象,在JavaScript中使用对象的形式来表示Virtual DOM树。由于它不依赖平台环境,所以它具备跨平台的能力。
为什么要使用Virtual DOM
在早期的Web中,状态管理还不是很复杂的,页面的交互也很简单。使用jQuery操作DOM,完全满足我们的需求。
但是随着时代的发展,页面功能越来越复杂,需要维护的状态越来越多,操作DOM也越来越频繁。在一个大型项目中,相当多的代码是在操作DOM。
这种命令式操作DOM的方式,虽然很方便,但是在业务越来越复杂的今天,会变得非常不利于维护。
如今,三大主流框架都是声明式操作DOM。只需要描述状态和DOM之间的映射关系是怎么样的,框架就会自动地将状态渲染为视图。得益于框架的改进,我们只需要关注状态的维护,而不需要再去频繁地操作DOM。
那么在程序在运行时,状态会不断的发生改变,只要状态发生改变,就需要重新渲染视图。一种简单粗暴的办法,就是将DOM重新生成,替换掉原来的DOM树。我们知道访问DOM是非常昂贵的,如果重新渲染视图是把原DOM全部删除并替换,那会造成极大的性能浪费。
因为修改状态只会影响部分DOM片段。因此,我们只需要知道状态影响了哪些DOM片段。再要把那些需要改变的DOM片段找出来进行替换,然后再重新进行渲染视图即可。
如何去找需要改变的DOM片段?在Vue2.x和React中,均使用了Virtual DOM作为解决方案,而Angular是脏检查。
Virtual DOM会根据状态生成一个虚拟节点(VNode),然后和上一次生成的VNode进行对比,只渲染它们之间不同的部分。
在Vue1.0的时候,每一个状态都会绑定一个watcher进行观察。当项目变得很大的时候,这个开销就会变得非常大。
所以在Vue2.x版本中取了一个折中的方案,为每个组件绑定一个watcher。状态发生改变的时候,只在组件内进行对比VNode,找出需要改变的VNode节点。
VNode
什么是VNode
在Virtual DOM中每一个节点被称为VNode(虚拟节点)。它本质上就是一个JavaScript对象。
真实DOM节点会有不同的类型,例如:元素节点,文本节点和注释节点。那么VNode也会有这些不同的类型,因为要用它来表示真实的DOM节点。
VNode是节点的描述对象,它描述了怎样去创建一个真实的DOM节点。所以真实DOM节点上的所有属性在VNode属性上都应该存在。
下面是一个简化版的VNode类:
class VNode {
constructor(tag, data, children, text, elm) {
this.tag = tag; // 标签名
this.data = data; // 数据信息(比如attr, props, directives)
this.children = children; // 子节点
this.text = text; // 节点的文本
this.elm = elm; // 对应的真实DOM节点
}
}
接着,假如我有这样一个组件:
<template>
<div id='root' v-show='isShow' class='app'>
<span>hello world</span>
</div>
</template>
将这个组件,转换成VNode节点:
{
tag: 'div', // 标签
data: {
// 属性
attr: {
id: 'root',
},
// 指令
directives: [
{
rawName: 'v-show',
expression: 'isShow',
name: 'show',
value: true,
},
],
staticClass: 'app', // 静态class
},
children: [
{
tag: 'span',
data: undefined,
children: [
{
tag: undefined,
data: undefined,
children: undefined,
text: 'hello world', // 文本
},
],
},
],
text: undefined,
};
在Vue的源码中VNode包括以下属性:
class VNode {
constrctor(
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag; // 标签
this.data = data; // 节点的数据(比如props, attr, directives)
this.children = children; // 子节点
this.text = text; // 文本
this.elm = elm; // 节点的真实DOM
this.ns = undefined; // 命名空间
this.context = context; // 节点的上下文
this.fnContext = undefined; // 函数化组件上下文
this.fnOptions = undefined; // 函数化组件配置项
this.fnScopeId = undefined; // 函数化组件ScopeId
this.key = data && data.key; // 子节点key属性
this.componentOptions = componentOptions; // 组件配置项
this.componentInstance = undefined; // 组件实例
this.parent = undefined; // 父节点
this.raw = false; // 是否是原生的HTML
this.isStatic = false; // 静态节点标志
this.isRootInsert = true; // 是否作为根节点插入
this.isComment = false; // 是否是注释节点
this.isCloned = false; // 是否是克隆的节点
this.isOnce = false; // 是否为v-once节点
this.asyncFactory = asyncFactory; // 异步工厂方法
this.asyncMeta = undefined; // 异步Meta
this.isAsyncPlaceholder = false; // 是否为异步占位
}
}
以下几点需要注意:
- 所有对象的
context选项都指向了Vue实例 elm属性则指向了其对应的真实DOM节点DOM中的文本内容被当做了一个只有text没有tag的节点- 像
class、id等HTML属性都被放在了data里
VNode的作用
Vue会把模板编译成渲染函数,然后执行渲染函数会生成VNode。
VNode的作用就是缓存上一个生成的VNode,然后和现在的VNode进行对比。通过对比新旧两个VNode,找出真正需要更新的节点,然后更新视图。
VNode的类型
VNode有以下几种不同的类型:
- 注释节点
- 文本节点
- 元素节点
- 克隆节点
- 组件节点
- 函数式组件
不同类型的VNode其实只是属性的不同。通过参数为实例设置属性,无效的属性会被置为undefined或false。
注释节点
创建注释节点非常简单,只需要接收一个text属性,然后将isComment置为true。
export const createEmptyVNode = (text) => {
const vnode = new VNode();
vnode.text = text;
vnode.isComment = true;
return vnode;
}
文本节点
文本节点和注释节点十分类似,它们区别就是有无isComment属性。
export const createTextVNode = (text) => {
const vnode = new VNode(undefined, undefined, undefined, String(text));
return vnode;
}
克隆节点
克隆节点就是将现有节点的属性复制到新节点中。它的作用是优化静态节点和插槽节点(slot node)。
以静态节点为例,因为静态节点只会在首次渲染的时候执行渲染函数生成VNode。所以使用克隆节点的方式将VNode拷贝一份。这样做可以提升一部分性能。
export const cloneVNode = (vnode, deep) => {
const cloned = new VNode(
vnode.tag,
vnode.data,
vnode.children,
vnode.text,
vnode.elm,
vnode.contet,
vnode.componentOptions,
vnode.asyncFactory
);
cloned.ns = vnode.ns;
cloned.isStatic = vnode.isStatic;
cloned.key = vnode.key;
cloned.isComment = vnode.isComment;
cloned.isCloned = true;
if (deep && vnode.children) {
cloned.children = cloneVNode(vnode.children, deep);
}
return cloned;
};
可以看出,克隆节点时,只要将现有节点属性复制到新节点即可。
克隆节点和被克隆节点的唯一区别就是isCloned属性被置为true了。
元素节点
元素节点通常存在以下4中有效属性:
- tag:标签名
- data:包含了节点上的一些数据,比如attrs、class和style等
- children:当前节点的子节点列表
- context:它是当前组件的Vue实例
上面代码的第一个例子就是一个元素节点。
{
tag: 'div',
data: {...},
children: {...},
context: {...}
}
组件节点
组件节点和元素节点十分类似,但是有以下两个独有的属性:
- componentOptions:组件节点的选项参数,其中包含propsData、tag和children等信息。
- componentInstance:组件的实例,也就是Vue实例。事实上,每个组件都是Vue的实例。
一个组件节点:
<child></child>
下面是VNode:
{
componentOptions: {...},
componentInstance: {...},
context: {...},
tag: 'child',
data: {...}
}
函数式组件
函数式组件和组件节点类似,它有两个独有的属性:functionalContext、functionalOptions。
通常是下面这样子:
{
functionalOptions: {...},
functionalContext: {...},
context: {...},
tag: 'div',
data: {...}
}
为啥虚拟DOM比操作DOM快
在讨论这个问题之前,我们需要清楚Vue为什么要引入虚拟DOM,它的作用是什么。
因为在Vue1.0中会为每一个状态绑定一个watcher,这样做的好处就是不需要对比,就能知道哪些地方发生了状态改变。但是这样做的话,颗粒度非常细。只要项目变得非常庞大,状态一多,那么肯定就会充斥大量的watcher,势必就会损耗非常多的内存。
所以在Vue2.0中就将颗粒度调整到了组件,只在组件上绑定watcher。只要状态发生改变,就会通知watcher去更新组件。虚拟DOM的作用就是缓存之前生成的VNode,然后在更新组件之前进行patch对比新旧两个VNode之间的不同
接着我们继续讨论为啥虚拟DOM比操作DOM快。
其实这不是绝对的。我们先来了解虚拟DOM是怎么来生成真实DOM的。
-
首先通过编译将模板编译成渲染函数
-
执行渲染函数生成VNode
-
与旧的VNode对比找出差异
-
最后渲染成真实DOM
这么一看,不是比直接操作DOM更复杂么,哪里快了。其实在DOM操作少的情况下,使用虚拟DOM确实会比直接操作DOM慢。
但是在大型的项目中,操作DOM十分的频繁,充斥着大量的回流和重绘。在这种情况下,虚拟DOM比直接操作DOM快相当多。
因为虚拟DOM生成真实DOM的前3步,只是在JavasSript层面去操作对象。最后一步才去一次性的修改真实DOM中需要改的部分。与直接操作DOM相比,只有少量的操作DOM,也只会引发较少的DOM的重绘和排版。这就是虚拟DOM快的原因。
总结:
虚拟DOM会找出与真实DOM之间的差异,然后一次性的修改真实DOM中需要更改的部分。只有少量的排版与重绘。