Vue源码笔记之虚拟DOM

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/qq_41694291/article/details/100884306

什么是虚拟DOM?

简单来说,虚拟DOM就是真实DOM树的一种JavaScript表达。

之前在浏览器(基于Chromium)运行机制剖析这篇文章介绍过,浏览器通过HTML引擎来解析HTML文档,然后生成一棵DOM树。JavaScript可以通过DOM接口操作这棵DOM树,从而修改页面结构。

但是DOM树本身并不是由JavaScript来描述的,而且它的体积通常十分庞大,再加上操作DOM经常导致网页重绘和重排,因此操作DOM树的代价相对比较昂贵。也正因为这样,在jQuery时代,尽量节省DOM操作对网页性能优化有着至关重要的作用。

为了提升网页性能,Facebook的React团队做了一次大胆的尝试,它将真实DOM树以JavaScript对象的形式保存在内存中(使用JSX语法正是为了便于React生成这个对象,它像真实DOM一样是个树结构)。当数据发生变化需要操作DOM更新视图时,React会首先重新生成这个对象,通过diff算法(一种比对算法)比对两个树对象前后的变化,根据这个变化来判断如何以最小的代价去操作真实DOM,最后再更新真实DOM,这样可以避免一些不必要的DOM操作。比如我们有如下的HTML结构:

<ul>
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ul>

假如我们现在要移除第三项,随后我们又在尾部添加一项<li>4</li>,然后上面的结构就会变成下面这样:

<ul>
  <li>1</li>
  <li>2</li>
  <li>4</li>
</ul>

通常来说,这需要两次DOM操作(在没有关注如何高效更新DOM的情况下)。而如果使用虚拟DOM,它会将上述结构描述为一个JavaScript对象,并在数据改变后重新生成该对象。通过diff算法比对两个对象,发现唯一变化的是第三项li的内容(因为两者都是li,仅仅内容不同,所以React认为它就是同一个li),因此它会直接更新第三项li的内容,这样就把上面两个DOM操作压缩成了一个。

很显然,如果我们仔细观察,同样可以发现直接修改第三项的内容性能更好(这就是一次前端页面优化)。但是在复杂的实际开发中,我们往往很难发现更新DOM的最优办法。而如果使用虚拟DOM进行比对,多数情况下都可以找到高效更新DOM的方法(这正是虚拟DOM存在的意义)。因为JavaScript对象是常驻内存的,对它操作的代价远小于对DOM操作的代价,因此引入虚拟DOM往往能带来性能上的提升(不过当diff算法的执行成本高于节约DOM操作带来的收益,或者你总是知道如何高效操作DOM时,虚拟DOM就显得有些鸡肋了)。这个用于描述真实DOM的JavaScript对象,就是虚拟DOM。Vue的虚拟DOM也是借鉴了React的虚拟DOM设计。

总的来说,虚拟DOM的设计目的就是通过低成本的js操作,来减少昂贵的DOM操作。当然还有另外一个显而易见的好处,就是所有的DOM操作都由框架完成,开发者就可以把目光专注于业务逻辑了。

下面就来看Vue中的虚拟DOM是如何实现的,以及它有什么用途。

Vue中的虚拟DOM

在React中,虚拟DOM是整个框架自动更新视图的基础。但是Vue引入虚拟DOM的最初目的却是为了降低内存。在Vue源码笔记之响应式系统一文中我们说到,Vue自动更新视图的基础是响应式系统,即使没有虚拟DOM,响应式系统也足以实现视图的自动更新(早期的Vue就是这样做的)。在响应式系统中,当数据发生变化时,订阅者watcher会自动更新DOM,比如:

<template>
  <div>{{ title }}</div>
</template>

这里div的内容绑定到了变量title。在早期Vue的版本中,它会专门生成一个watcher,用于在title的值变化时,更新div的内容。这样做不需要依赖虚拟DOM,而且DOM更新的效率很高。但问题是,在一个大型项目中,会存在大量的数据绑定,如果为每个绑定都生成一个watcher,框架占用的内存会急剧上升,最终可能导致浏览器内存溢出,网页卡死。

为了解决这个问题,Vue的作者引入了虚拟DOM。它把每个Vue组件的每个元素都用一个虚拟节点对象VNode来描述,这样整个组件就会变成一个由虚拟节点VNode构成的树形结构,也就是我们所说的虚拟DOM树(这是组件级别的虚拟DOM,实际上所有的组件在一起也会形成应用级别的虚拟DOM,不过前者在Vue中的实际意义更大)。虚拟节点包括很多不同的类别,如元素节点(对应HTML元素)、文本节点、注释节点和组件节点等。

我们先来看一下VNode大致的结构(为了直观理解VNode,这里省略了一些属性):

class VNode {
  tag: string | void;    //元素节点的标签名,非元素节点时为undefined
  data: VNodeData | void; //节点数据
  children: ?Array<VNode>;  //子节点数组,每个子节点也是VNode
  text: string | void;   //节点的文本内容,文本和注释节点有值
  elm: Node | void;      //节点对象
  key: string | number | void;   //节点标识
  parent: VNode | void; // component placeholder node
  ...  //省略了某些组件属性
  
  // 只在内部使用的属性
  isStatic: boolean; // 是否为静态节点
  isComment: boolean; // 是否为注释节点
  isCloned: boolean; // 是否为克隆节点
  isOnce: boolean; // 是否带v-once属性,带v-once的节点不需要更新
  ...  //省略了一些服务端渲染及函数式组件相关的属性

假如某组件的结构如下:

<template>
  <div id="app">
    <h1>{{ title }}</h1>
    <p>正文</p>
  </div>
</template>

调用组件的渲染函数后,就会得到下面这个虚拟节点,它会被添加到组件实例的_vnode属性上:

vm._vnode = {
  tag: "div",
  data: {attrs: {id: "app"}},
  children: [
    {tag: "h1", data: {}, ...}, //h1对应的VNode
    //h1后面的换行符会被解析为一个文本节点,文本内容是一个空格
    {tag: undefined, data: undefined, text: " "},
    {tag: "p", data: {}, ...}, //p对应的VNode
  ],
  text: undefined,
  elm: div#app,    //这是div元素对应的真实DOM对象
  key: undefined,       //节点标识
  parent: undefined,   //父节点,未定义
  
  isStatic: false,    //不是静态节点
  isComment: false,   //不是注释节点
  isCloned: false,    //不是克隆节点
  isOnce: false;      //不带有v-once指令
  ...   //暂时不考虑其他属性
}

借助_vnode对象,Vue就可以将当前组件渲染为真实DOM。渲染的过程被封装成了patch方法。DOM的首次渲染以及数据变化时更新DOM的工作都在patch方法中完成。

了解了VNode的结构后,我们看一下响应式系统与虚拟DOM是如何配合工作的。假设现在有下面的结构:
在这里插入图片描述
我们知道,在引入虚拟DOM前,Vue会为每个组件内部所有的数据绑定都生成一个watcher,来负责更新某个对应的DOM节点。在引入了虚拟DOM之后,绑定的粒度就不再是单个节点,而是整个组件。也就是说,Vue会为每个组件生成一个watcher,来负责该组件的视图更新,至于这个组件内部的节点是如何更新的,完全交由虚拟DOM来负责。

以Parent.vue组件为例,假如它内部依赖了title的值,而现在title的值发生了变化,由于降低了绑定的粒度,Vue无法直接更新该组件内部的DOM节点(因为组件内的节点现在没有对应的watcher)。不过组件本身是有对应的watcher的,它将收到这个通知,然后调用虚拟DOM提供的方法,比对新旧节点,从而更新视图。

我们发现,对于响应式系统来说,组件对应的VNode变成了一个黑盒。响应式系统不再考虑如何更新组件内部的内容,而是只会在数据变化时通知该组件对应的watcher,更新过程交由组件自身来负责。前文Vue源码笔记之编译器我们说到,Vue的渲染函数就是用来生成VNode的。基于这一点,以及上面介绍的虚拟DOM更新原理,我们可以大致想到组件更新的过程:

  1. 在数据变化时,调用组件的渲染函数重新生成VNode
  2. 通过diff算法比对新旧VNode,判断哪里发生了变化
  3. 更新真实DOM

这样视图就完成了更新。与React不同的是,Vue可以在数据变化时知道哪个组件需要更新(得益于响应式系统,它比React的比对粒度更细;作为代价,响应式系统有一定的资源消耗),所以diff算法执行得更快。

下面就通过阅读Vue源码,来理解虚拟DOM是如何工作的。

Vue中虚拟DOM的实现

我们来一步步看,Vue是如何实现虚拟DOM的。

抛开模板编译不谈(它生成的渲染函数是用来生成虚拟DOM节点 - VNode的,因此算是前期工作),虚拟DOM的真正实现是从mount(挂载)事件开始的。在执行mount之前,我们一直在做的都是构建vue实例,如实例的初始化、响应式系统的构建、模板编译等,它们并没有将vue实例和DOM(包括虚拟DOM和真实DOM)联系起来。直到在mount阶段,Vue调用了一个名为mountComponent的方法时,虚拟DOM才开始出现。

顾名思义,mountComponent就是要挂载组件(即把组件挂载到DOM树上)。我们知道,Vue挂载组件是调用的$mount方法,那么这两个方法是什么关系呢?很简单,$mount调用mountComponent实现组件挂载,并且添加了一些平台相关的处理逻辑,所以mountComponent定义了真正的挂载逻辑。我们来看一下mountComponent的大致结构:

//执行该函数前,已经在入口文件中执行了模板编译,即渲染函数理论上已经存在了
export function mountComponent (vm, el, hydrating){
  vm.$el = el;
  if(!el.$options.render){
    //如果渲染函数不存在,并且template存在,那么可能是错误地引入了
    //不含编译器的运行时版本,要给出警告
  }

  callHook(vm, 'beforeMount');   //到达beforeMount生命周期
  //这里是简写,源码中还包括测试挂载性能的相关代码
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }
  //为vm生成一个watcher,这样当vm内的任何数据变化,都会调用
  //updateComponent来更新组件
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  
  callHook(vm, 'mounted')   //挂载完毕

  return vm;
}

这里最关键的两步就是定义了updateComponent函数以及执行了new Watcher。前者是定义了一个可以生成和更新DOM(包括虚拟DOM和真实DOM)的函数;后者则通过生成一个watcher,将更新DOM的函数注册到响应式系统。现在,一旦响应式系统中的数据发生了变化,这里的watcher就会得到通知,然后调用这个updateComponent方法来更新当前组件对应的DOM。这样就完成了对视图的更新。

下面我们分别详细解析这两步的实现过程。

1. updateComponent的定义

updateComponent是一个可以更新当前组件的函数(大致过程为先生成新的VNode,然后执行patch进行新旧节点的比对,最后根据比对结果更新真实DOM树)。它的定义很简单:

vm._update(vm._render(), hydrating)

就是调用_update方法,传入vm._render()(第二个参数是服务端渲染相关,可以暂时忽略)。这里的vm._render()正是在调用vm的渲染函数生成新的VNode。假设vm的渲染函数如下(引自上一篇介绍编译器的一个例子):

vm._render = function(){
    with(this){
    return 
       _c('div',{
         attrs:{"id":"app"}
       },
       [_c('ul',_l((items),function(item){
         return _c('li',
           [_v("\n itemid:"+_s(item.id)+"\n ")]
         )}
        )
       )]
     )}
  }

调用上面的函数,我们就可以得到下面的一个对象:

{
    "type": 1,
    "tag": "div",
    "attrsList": [
        {
            "name": "id",
            "value": "app"
        }
    ],
    "attrsMap": {
        "id": "app"
    },
    "children": [
        {
            "type": 1,
            "tag": "ul",
            "attrsList": [],
            "attrsMap": {},
            "parent": {
                "$ref": "$"
            },
            "children": [
                {
                    "type": 1,
                    "tag": "li",
                    "attrsList": [],
                    "attrsMap": {
                        "v-for": "item in items"
                    },
                    "parent": {
                        "$ref": "$[\"children\"][0]"
                    },
                    "children": [
                        {
                            "type": 2,
                            "expression": "\"\\n      itemid:\"+_s(item.id)+\"\\n    \"",
                            "tokens": [
                                "\n      itemid:",
                                {
                                    "@binding": "item.id"
                                },
                                "\n    "
                            ],
                            "text": "\n      itemid:{{item.id}}\n    "
                        }
                    ],
                    "for": "items",
                    "alias": "item",
                    "plain": true
                }
            ],
            "plain": true
        }
    ],
    "plain": false,
    "attrs": [
        {
            "name": "id",
            "value": "\"app\""
        }
    ]
}

这个对象就是一个VNode,即虚拟DOM节点。它以JavaScript对象的形式描述了当前组件的结构,并且保留了将组件渲染为DOM的所有参数。现在我们就把这个对象作为参数传递给_update方法。

_update方法是Vue原型上定义的一个方法,作用就是接受一个VNode,然后修补这个VNode,最后更新DOM。我们来看_update的实现:

Vue.prototype._update = function (vnode, hydrating) {
  ...
  if (!prevVnode) {
      // initial render
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    ...
}

核心代码就是这个if和else语句,if语句负责首次渲染,else语句负责修补虚拟DOM,更新DOM树。这两个功能都是通过实例上的__patch__方法来实现的。而这个__patch__的实现,就是虚拟DOM的重头戏了!

在详细探究__patch__的实现之前,我们先来了解一下这个函数到底是做什么的。patch本身有修复、修补的含义,这里表示把之前旧的DOM修补成新的DOM(换句话说就是更新视图)。

假如当前DOM树还未存在(即当前组件还未被渲染到页面上),那么patch需要根据VNode进行首次渲染;假如当前DOM已经存在了,那就表示是视图绑定的某些数据发生了变化,这时就需要更新页面上某部分的内容。前者称为initial render,后者称为updates。

首次渲染的逻辑就是,使用当前平台(之所以说是当前平台,是因为Vue所支持的weex平台提供的创建DOM节点的方法与浏览器略有不同,Vue将他们封装成了跨平台的nodeOps类)提供的创建DOM节点的方法(如createElement),依据虚拟DOM的结构,依次向真实DOM树插入DOM节点,形成完整的真实DOM树。更新的逻辑就是,将数据更新前生成的VNode,和数据更新后重新生成的VNode作比对,一旦发现两者的某个节点发生了变化,就使用insertBefore、createElement等方法,按照新的VNode的结构,来更新真实DOM(注意,在更新前,真实DOM的结构与旧的VNode是一致的,因为它是由旧的VNode生成的)。这样一边比对,一边更新DOM,当比对完成后,DOM树的结构就和新的VNode完全一致了。

这里所说的比对算法,就是diff算法,它是虚拟DOM中最重要的算法,也是虚拟DOM性能的关键所在。实际上,如果这个diff算法的速度很慢,即使最终通过虚拟DOM方案节省了一些真实DOM操作,从总的时间来看,虚拟DOM也并不能带来性能上的提升。所幸,由于采用了同级比较策略并进行了一些优化,虚拟DOM的diff算法大多数情况下性能较佳。

这里所说的同级比较,就是指diff算法永远只做同级节点之间的比对。举个例子,假设有下面的新旧两个VNode:
在这里插入图片描述
Vue会按照节点的层级,一级一级地进行比较,而不会跨级比较(比如左侧第一级的节点,永远不会和右边第二层的节点进行比对)。这个问题在你向第二棵树的第二层新增一个层级时就会非常明显。此时右侧变为四层结构,从我们的角度看来,除了新增一层外,新的虚拟DOM没有任何改变,但是Vue经过比对发现两者的第二层完全不同,于是会更新第二层节点,整棵树除了根节点外,全部发生了更新。之所以采用这种策略,是因为树的跨级比较复杂度很高,严重影响了diff算法的性能。

下面我们再来看,Vue的diff算法是如何进行同级比较的。

在探讨这个问题之前,我们必须了解Vue是如何判定两个节点是同一个节点的。Vue把这个逻辑包装在sameNode函数中:

function sameVnode (a, b) {
  return (
    //条件1:key相同
    a.key === b.key && (
      ( 
        a.tag === b.tag &&    //条件2:标签名相同
        //条件3:isComment相同,isComment表示当前节点是否为注释节点
        a.isComment === b.isComment &&  
        //条件4:两者的data同时存在,或同时不存在(但不去判断data是否相同)
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)   //条件5:是相同的input类型
      ) || (
        //这是针对input元素而言的,不作详解
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

通常情况下,判断原则有5个:

  1. key相同(未定义时都是undefined,因此也判定为相同)
  2. 标签名相同
  3. 同为注释节点,或都不是注释节点
  4. data同时存在,或同时不存在
  5. input类型相同(即input的type属性)

当新旧两个节点元素满足上述5个条件,Vue就认为它们是一个节点。

整个的VNode的比较过程是从VNode的根节点开始的,如果当前组件的根节点发生了变化,那么说明整个组件都发生了变化,因此直接渲染当前组件即可。如果根节点没有发生变化,那么就需要检查根节点的子节点有没有发生变化。这时会调用updateChildren方法来比对根节点的子节点,当发现发生了变化时就更新节点。

比较原始的diff算法遵循的思路是:用for循环遍历新的VNode中当前节点的所有子节点,然后依次检查旧的VNode中有没有对应的节点存在(前提是同一级)。如果存在的话,就把旧的VNode中对应的节点移动到与当前节点对应的位置,由于真实DOM是依据旧的VNode的结构生成的,因此真实DOM也需要这样的操作。如果不存在,说明当前节点是新增的,需要使用insertBefore方法将该节点插入到所有未被处理过的节点的前面。

但是这样的算法执行起来效率往往比较低下,因为两个循环是嵌套的,所以算法的复杂度是O(n2)。由于通常情况下,DOM树上的节点变化是有一定规律的,比如对于下面的结构:

<ul>
  <li key="1">1</li>
  <li key="2">2</li>
  <li key="3">3</li>
  <li key="4">4</li>
</ul>

以第一个li为例,它在大多数情况下仍会位于原来的位置,少数情况下会位于最后面(如倒序排列时),极少数情况下,它会被插入到列表的任意位置。如果我们比对新旧子节点时,优先比对两端的节点,在大多数情况下,都可以迅速查找到相同的节点(如果全部匹配失败,才进行暴力遍历,如果遍历完毕仍然找不到,那就代表该节点是新增的)。

基于上面的理论,还可以进一步优化算法。假设有下面的例子:

我们现在正在比较根节点的所有子元素,左侧是旧的VNode,右侧是数据改变后新生成的VNode。经过比较,我们发现两者的第一个子元素完全一致,因此不需要更新。之后我们做比对时将完全不考虑这个比对过的节点。

接着需要比对剩下的子元素。源码中将旧VNode中未比对的子元素的第一个的索引值保存在oldStartIdx,最后一个的索引值保存为oldEndIdx,因此我们可以暂且将这两个节点称为“旧前”和“旧后”,表示旧VNode中未比较的节点中的第一个和最后一个。同理,新VNode的这两个节点称为“新前”和“新后”。一次的节点比对需要进行6次判断:

  1. 判断oldStartVnode是否存在,如果不存在,说明该位置对应的节点已经与新VNode中的某个节点匹配上,移动到了左侧已处理的节点队列中,因此直接跳过该节点。
  2. 判断oldEndVnode是否存在,如果不存在,原因与上述类似,表示该位置对应的节点已经被移动到右侧的已处理列表中。
  3. 判断“旧前”与“新前”是否相同,即旧VNode中第一个未比较节点与新VNode中第一个未比较的节点是否相同。如果相同,则匹配成功,更新节点内容(实际上从Vue的判断原则来看,即使被判定为同一个节点,也不能保证它的内容完全相同,因为检查内容是否相同的性能损耗较大),然后将oldStartIdx和newStartIdx加1,表示从前面处理了一个节点。然后进行下个节点的比对。
  4. 如果失败则继续比较“旧后”和“新后”。如果相同,则直接更新该节点,并将oldEndIdx和newEndIdx减1。
  5. 如果上述比较失败,则比较“旧后”与“新前”是否为同一个节点(列表倒序时就会出现这种情况)。如果比对成功,则将匹配到的节点使用insertBefore接口插入到当前未匹配节点的最前面,然后更新节点内容。然后将oldStartIdx和newStartIdx加1。
  6. 如果上述比较失败就继续比较“旧前”和“新后”。如果相同,则将“旧前”插入到后方所有未匹配节点的最前面。同时oldEndIdx和newEedIdx加1。表示从后面处理了一个节点
  7. 如果上面四步的比对全部失败,表示子节点序列可能已经完全乱序了,这时就只能用for循环,循环检查旧VNode的子节点中是否存在与当前第一个节点匹配的节点,如果找到了,就把它移动到最前面,如果没有找到,说明当前节点是个新节点,这时需要调用document.createElement来创建这个节点并插入DOM。

从上面的步骤可以看出,比对的过程是从两侧向中间进行的。实际上绝大部分的视图更新都可以被前6步处理,少数情况下才需要第7步用for循环来遍历查找。因此该算法在大多数情况下的复杂度是O(n),执行效率相对比较高。用四个图来表示上面的几个步骤:
步骤3,比较“旧前”与“新前”:
在这里插入图片描述
步骤4,比较“旧后”与“新后”:
在这里插入图片描述
步骤5,比较“旧前”与“新后”:
在这里插入图片描述
步骤6,比较“旧后”与“新前”:
在这里插入图片描述
上述比较过程只在两个队列都还有子节点时才会进行,一旦某个队列为空了,就退出循环。如果此时发现旧的队列中还有节点,那么很显然,这些节点已经被删除了(因为渲染页面是以新的VNode为依据的,如果这些节点在新VNode中不存在,那就表示被移除了);如果此时新的VNode还有未处理的节点,那么它们就是新增的节点,直接使用createElement创建并插入到DOM中即可。

递归进行上述过程就可以实现所有节点的比较,一旦整棵树遍历完成,DOM也就更新完成了。更新实际DOM所进行的操作与节点比较时出现的情况是对应的:当发现有新增节点,就使用insertBefore插入节点;当发现节点被移除,就使用removeChild移除节点;当发现节点位置移动,就先将其插入正确位置,再删除原位置的节点。此外对于所有节点,都需要使用patchVnode对节点的内容进行进一步的更新。

上面就是VNode的整个patch过程,经过该过程,DOM树就得到了更新(即页面得到了更新)。

下面我们来理解一下,为什么Vue推荐在使用v-for时为每个节点绑定key。

上面说到,Vue通过sameNode函数来判定两个节点是否为同一个节点,它共有五个判断原则。但是从这些原则可以看出,Vue并不会在比较节点时去判断节点的内容是否一致,也就是说,下面的两个节点在任何情况下都被判定为一个节点:

<li>1</li>

<li>2</li>

那么假如存在这种情况:

  <li>1</li>
  <li>2</li>
  <li>3</li>
  <li>4</li>

//更新为:
  <li>4</li>
  <li>1</li>
  <li>2</li>
  <li>3</li>

非常不幸,Vue会认为这两组li的节点本身没有发生任何变化,发生变化的仅仅是每个节点的内容。在我们看来,上面的例子只是把第四个节点转移到了第一的位置,只需要一次移动(具体过程为一次插入加一次删除)即可。但是Vue却认为四个节点都没变,只是四个节点的内容都发生了变化,因此会执行四次内容的修改。但这完全不是问题所在,因为实际上四次内容更新的操作速度比两次移动还要快(因为前者不会触发重绘,而后者会导致重绘)。真正的问题在于,如果你为这个列表添加了过渡动画(也就是在列表变化时动态看到节点的移动),你会发现,无论你如何修改列表顺序,动画始终不会触发。因为Vue并没有移动节点!

如果你为每个节点绑定一个key,上面的问题就得到了解决。因为Vue判定节点相同的第一个条件就是key相同,如果节点发生了移动,Vue可以准确判定节点的位置,从而执行过渡动画。

当然了,官网也说到,如果你不在乎节点的移动效果,而是希望借助该机制提升性能,那么也可以刻意地不设置key,否则在任何情况下,Vue都推荐给v-for内的元素绑定唯一的key。

2. 将虚拟DOM与响应式系统挂接

介绍完了Vue更新虚拟DOM的过程,下面就来看Vue是如何将虚拟DOM与响应式系统联系起来的。这个过程就是借助watcher实现的:

new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)

上一部分说到,这个updateComponent方法具备更新真实DOM的能力。但是我们知道,只有在数据变化时DOM才需要更新,而数据的变化是由响应式系统来监测的。那么自然可以想到,我们需要在响应式系统监听到数据变化时调用updateComponent方法来更新视图。这里生成的watcher实例就负责这个工作。

在响应式系统一文中我们讲到,Vue通过Observer监听数据变化,在数据变化后通知Dep,然后由Dep触发依赖者列表中的watcher执行回调函数。上面的语句生成的watcher就是针对vm实例的data的,它以更新DOM的updateComponent方法作为回调函数。一旦data发生任何变化,就调用这个回调函数来更新视图。通过将虚拟DOM的updateComponent方法注册到响应式系统内的一个watcher对象内,Vue就把响应式系统和虚拟DOM成功挂接起来。

到这里为止还有一个问题,就是Vue何时进行首次渲染?

我们前面说到,updateComponent本身具备首次渲染DOM的能力。但问题在于,它只有在数据发生变化时才会触发,而当数据没变时,updateComponent还从来没有被调用,那页面第一次是如何被渲染出来的呢?答案就在这个new Watcher内部。

打开watcher的构造函数:

class Watcher{
  ...
  constructor(vm, expOrFn, cb, options, isRenderWatcher){
    ...
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    }
    ...
    this.value = this.lazy
      ? undefined
      : this.get()
    
  }
  get () {
    ...
    value = this.getter.call(vm, vm)
    ...
  }
}

这里省略了其他语句。从new Watcher语句中可以看到,我们的updateComponent是作为第二个参数传进来的,也就是被接收为expOrFn。然后它被赋值给this.getter,随后调用了this.get()方法,在get方法中,就调用了this.getter。这也就意味着,在执行new Watcher构造watcher实例时,就已经第一次调用了updateComponent(上面的value = this.getter.call(vm, vm)就是在调用它),因此Vue可以完成首次渲染。

总结

到这里Vue的虚拟DOM就介绍完毕了。我们来回顾一下:虚拟DOM本身就是对真实DOM的一种JavaScript描述,每当数据更新时,它就通过diff算法来比对之前的和新生成的两个VNode,根据差异来更新真实DOM。由于在React中没有响应式系统,当数据发生变化时,React不知道哪个组件受到了影响,因此需要对完整的虚拟DOM进行diff,从而更新页面。而借助响应式系统,只要数据变化,Vue就能立即定位到是哪个组件的DOM需要更新,因此它的diff算法是组件级别的,效率更高(当然,构建响应式系统本身必然有一定的资源消耗,但这种消耗是值得的)。

到这里,Vue最核心的三个模块都已经介绍完了。本来还打算再写一篇关于Vue全局API(如Vue.use/extends/mixin等)的文章,但是我发现,其实理解了这三个模块的运行机制和关系后,全局API就已经不那么重要了,所以关于Vue源码的笔记也就到此结束。对全局API感兴趣的话可以翻开源码,看看它们的实现,相对于三大模块来说,它们相对要简单一些。

文章链接

Vue源码笔记之项目架构
Vue源码笔记之初始化
Vue源码笔记之响应式系统
Vue源码笔记之编译器
Vue源码笔记之虚拟DOM

猜你喜欢

转载自blog.csdn.net/qq_41694291/article/details/100884306