Vue2.x | 虚拟DOM

1,048 阅读8分钟

Virtual DOM

什么是Virtual DOM

VueVirtual 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.xReact中,均使用了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的节点
  • classidHTML属性都被放在了data

VNode的作用

Vue会把模板编译成渲染函数,然后执行渲染函数会生成VNode

VNode的作用就是缓存上一个生成的VNode,然后和现在的VNode进行对比。通过对比新旧两个VNode,找出真正需要更新的节点,然后更新视图。

VNode的类型

VNode有以下几种不同的类型:

  • 注释节点
  • 文本节点
  • 元素节点
  • 克隆节点
  • 组件节点
  • 函数式组件

不同类型的VNode其实只是属性的不同。通过参数为实例设置属性,无效的属性会被置为undefinedfalse

注释节点

创建注释节点非常简单,只需要接收一个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中需要更改的部分。只有少量的排版与重绘。