本文的主要内容是详细地介绍Vue的内部渲染原理,从而帮助大家深入掌握关于Vue Options、生命周期等概念。为了帮助Vue使用经验较少的同学快速理解Vue,我们先从Vue的简介开始,第二部分再详细介绍Vue渲染原理。

一、Vue简介

1. MVVM、MVP和MVC

MVVM,即model、view、view-model,业务层、视图层以及两者的绑定层Vue的设计参考了MVVM架构,但不完全是一个MVVM框架,因为它没有严格意义上的绑定层。

MVVM要求开发者将业务层和视图层分开:业务层负责管理数据;视图层负责页面渲染;绑定层负责双向绑定,即视图层操作通过绑定层影响业务数据,业务数据的变化通过绑定层影响视图渲染,这三层是完全解耦的:
在这里插入图片描述
举个例子,假如我们的页面有一个h1标题,它要渲染的是js中变量title的值:

<h1>这是标题</h1>

<script>
  let title = '这是标题';
</script>

这里h1的文本内容就是由view层管理的;而model层负责的是管理业务数据title。现在viewmodel层都有了,下面我们就要让h1的文本内容和title的内容保持同步,这就是view-model层要做的事。假设我们有这样一个xml文件:

<h1>{{title}}</h1>

它表示h1的文本和变量title的值是绑定的,当一个发生变化时,另一个应该同步变化。

如果我们能够编写一个框架,自动根据一个值,更新另一个值,那么实际上就是实现了view-model层,我们的框架就可以称为一个MVVM框架。以后只要我们定义好视图和业务逻辑,并用一个xml文件描述两者的绑定关系,就可以实现视图和数据的同步了,这也是谷歌的Data Binding的基本实现思路。

MVVM模式参考自MVP模式,而两者都是借鉴自经典的MVC模式。先来说说MVVM和MVP的差异。

MVP的全写是Model-View-Presenter,即业务层、视图层和控制层。这里的控制层Presenter与view-model层的作用是完全一样的,就是负责对视图层和业务层进行同步。但不同的是,Presenter的实现较为复杂,它要求开发者必须手动封装两者的同步逻辑,如jQuery框架就可以看做一个MVP模式的实现:

<h1></h1>

<script>
  let title = '这是标题';

  $('h1').text(title);
</script>

开发者需要定义当变量变化时如何更新视图,以及获取到用户输入时如何更新变量,这两者加起来就是它的Presenter层实现。这种方式也可以实现视图和业务逻辑的同步,但显然,MVP的控制层逻辑要比MVVM的声明式绑定写起来复杂得多,所以MVP模式基本上已经被MVVM代替。

而MVC是上述两个模式的鼻祖,也曾是java中最经典的模式之一,它的全写是Model-View-Controller。model和view层与上述两个模式一致,controller层与MVP的Presenter层一样,也被称作控制层。不过,MVC中的controller功能很弱,它实际上只是一个路由层,真正实现视图与业务数据同步的是model层的service,controller的作用就是找到对应的service而已。controller层的功能过于薄弱使得model层变得很复杂,所以目前MVC模式已经很少使用。

Vue之所以不是一个MVVM框架,是因为它没有真正的view-model层。在Vue中,view-model是通过模板语法间接实现的,Vue通过编译模板,可以解析出视图层和业务层的绑定关系,通过响应式系统和虚拟DOM来实现两者的同步,详细的过程后面会加以介绍。

2. Vue的基本配置

由于讲解Vue配置不是本文的重点,这里我们只是简单地概括一下,需要详细学习这部分内容的可以阅读Vue的官方文档:Vue官方网站

为了简单,我们先以一个cdn版本的Vue为例:

<script type="text/javascript" src="https://unpkg.com/vue"></script>

<div id="app"></div>

<script>
  let app = new Vue({
    el: '#app',
    data: { title: '标题' },
    template: '<h1>{{title}}</h1>',
    methods: {
      changeTitle (title) { this.title = title; }
    }
  });

  setTimeout(function () {
    app.changeTitle('新标题');
  }, 1000);
</script>

执行完script脚本对应的框架代码后,window上会新增一个构造函数Vue,用于构建Vue实例。我们向new Vue传入了一个配置对象,这个对象包含如el、data、template、methods等属性,用于为Vue实例添加属性和方法。Vue会根据这些配置,生成一个可以自动生成视图的响应式的Vue组件,它不仅负责管理视图层和业务层,还负责两者的同步。

我们来简单看一下一些常用配置的作用:

  1. el
    根元素,该参数只能由根节点声明,表示当前Vue应用需要被挂载到页面的哪个DOM节点上。如上面的例子指定了根元素为#app,那么该Vue实例生成的DOM就会直接替换id为app的元素。
  2. name
    组件的名字,主要用于全局注册组件,如:
import MyComponent from 'MyComponent';
Vue.component(MyComponent.name, MyComponent);
  1. components
    声明当前组件的外部依赖,相当于局部注册组件,在编写单组件时,如果需要用到其他的项目内组件通常会提供该参数。
  2. props
    来自父级组件的数据依赖,这个依赖是响应式的。
  3. data
    业务数据,这个参数是model层的核心,相关的业务逻辑都是围绕data展开的。
  4. computed
    计算属性,定义一组变量,这组变量的值是基于一个或多个props、data计算而来,computed内变量的值会根据这些依赖的值变化而自动更新,并且会自动缓存上次的计算结果。
  5. watch
    手动监控props、data或者computed的变化,定义变化时的回调函数。
  6. 生命周期方法
    定义Vue组件在各个生命周期需要执行的回调函数,Vue在执行到对应的阶段时会调用它们。生命周期与Vue组件创建的细节是第二部分渲染原理的重点。
  7. methods
    组件的工具方法集。methods定义了一组工具方法,可以在computed、watch、生命周期方法或者其他工具方法中调用。

有了这些基本知识的铺垫,下面我们就开始详细介绍Vue的渲染过程。

二、Vue渲染原理

我们先来打通HTML与Vue模板的关系。

1. HTML与模板

下面是一个常见的Vue例子:
在这里插入图片描述
整个Vue应用被挂载到页面上id为app的节点上,传入的模板字符串是<App/>Vue会解析组件App的模板来替换该标签。在解析App的模板时发现它又引入了另一个组件MyComponent,于是Vue继续解析MyComponent的模板,将解析结果替换到App组件模板内。全部解析之后会得到这样一个模板:

<template>
  <div id="a">
    <p>111</p>
    
    <div id="comp">
      <h1>222</h1>
      <p>333</p>
    </div>
    
  </div>
</template>

注意,这并不是HTML代码,它仍然是Vue模板(只是这里没有定义数据绑定而已)。Vue会用纯JavaScript来描述上述结构,类似下面这样(这不是真正的内部表示,后面我们会看到Vue的真实内部表示):
在这里插入图片描述
这里最外部id为app的节点实际上是不存在的,Vue在生成DOM时会替换掉该元素。

我们看到,Vue用一个JavaScript对象描述了编译出来的模板(如果有数据绑定,它还会描述模板与数据的绑定关系)。接下来只需要调用原生的DOM方法依次创建这里的每一个节点,然后将它们挂载成一棵DOM子树,并插入页面,就可以得到真正的HTML。我们一般把这个树状JavaScript对象称为虚拟DOM树。下面是上面的JavaScript对象对应的DOM结构:
在这里插入图片描述
也就是说,通过模板可以得到真实HTML的JavaScript对象表示,然后调用原生的DOM方法,借助这个JavaScript对象去生成真实的HTML。不仅如此,在这个过程中,Vue还注入了响应式系统,可以根据数据变化自动更新视图,以及根据视图自动更新数据,下面我们来讲解具体的实现过程。

2. Vue组件的完整渲染过程

Vue的执行过程主要分两大阶段:Vue自身的初始化阶段和实例的生命周期管理阶段。

当通过<script>脚本或者import Vue from 'vue'引入Vue时,Vue框架本身的代码会被执行,这一阶段的作用是对框架自身进行初始化。简单来说,就是定义构造函数function Vue,并为其添加大量的原型方法(以及一些工具方法),下面是一个说明示例:

(function(){
  ...
  // 定义构造函数
  function Vue (options) {
    this._init(options);
  }
  // 定义原型方法
  Vue.prototype._init = function (options) { ... }
  Vue.prototype._update = function () { ... }
  ...
  window.Vue = Vue;
})();

而在执行new Vue({ ... })语句时,就进入了实例的生命周期管理阶段。这一阶段是调用上述构造函数,构造和初始化Vue实例,并且管理它的整个生命周期。

下面我们就具体来看看这两个阶段都做了什么。

(1). Vue自身的初始化阶段

打开Vue源码的src > core > instance > index.js文件,可以看到以下代码:
在这里插入图片描述
实际上这就是主要的初始化过程,包括定义Vue构造函数,和调用5个mixin方法为Vue混入大量的原型方法。了解Vue自身初始化的关键就是探究这5个mixin函数究竟为Vue混入了哪些原型方法,下面是一个简单的例子:

<!DOCTYPE html>
  <html>
  <head>
       <script src="vue-2.6.10-learning.js"></script>
  </head>
  <body>
    <div id="app"></div>
    <script>
      var app = new Vue({
         el: '#app',
         template: "<div><h1>标题</h1><p>{{ message }}</p></div>",
         data () { return { message: 1 } },
         mounted () { 
              console.log(this.$data);
              setTimeout(() => { this.message = 2; }, 1000);
              setTimeout(() => { this.$destroy(); }, 5000);
         }
      })
    </script>
  </body>
  </html>

这个vue-2.6.10-learning.js是我下载到本地的一个Vue代码文件,我在文件内各个关键位置打上了console输出,以此来显式观察Vue的执行过程,下面是输出结果(以$开头的是直接暴露给开发者的接口,以_开头的是框架内部方法,不推荐开发者使用):
在这里插入图片描述
这里就是Vue自身初始化的全过程,与组件实例构造相关方法的实现,我们会在组件的生命周期管理阶段详细剖析,下面是它们大致的介绍。

首先initMixinVue混入了_init原型方法,它的作用是根据传入的options初始化Vue组件实例。具体的初始化过程是生命周期管理阶段的重点之一,下一部分会详细介绍。

接着stateMixinVue混入了$data、$props、$set、$delete和$watch这5个与组件状态有关的原型方法或属性:$data$props_data_props(这两个属性是初始化Vue实例时由_init添加到组件对象上的)的只读版本;$set$deleteVue提供的全局响应式方法,我们知道,由于JavaScript的限制,直接为已有对象添加或删除属性时,该属性不会被响应式系统观测到,$set$delete就是响应式地新增或删除属性的全局方法;$watchwatch配置的作用是一致的,只是它可以通过js来手动调用,而不用提前在options中声明。

下面eventsMixin混入了$on、$once、$off、$emit这四个与事件相关的原型方法。$on用于向实例注册事件监听;$once则是注册一个只会被调用一次的事件监听;$off用于取消某个或某类事件监听;$emit用于触发某个事件。

然后lifecycleMixin则向Vue混入了_update、$forceUpdate和$destroy这4个与实例生命周期相关的原型方法。_update负责组件的更新;$forceUpdate用于强制更新组件,一般是由于某些编码bug导致数据与视图不同步时手动调用;$destroy用于销毁组件。

最后,renderMixin会向Vue混入$nextTick和_render这两个与组件渲染相关的原型方法。$nextTick用于将一段代码逻辑推入微任务队列,以保证视图更新后才会执行;_render负责渲染组件,它的主要实现逻辑是调用组件的render函数(render函数由模板编译而来,也可以手工编写)生成DOM,然后挂载到页面上。

上面的方法位于Vue的原型对象上,对任何一个Vue组件都是通用的,执行完上述代码后,内存中的Vue结构是这样的:
在这里插入图片描述
可以看到,Vue构造函数和原型对象都初始化完毕了。但是由于还没有执行new Vue,所以暂时还没有生成可用的Vue组件实例。

(2). 组件实例的生命周期管理阶段

a. 实例初始化阶段

这一阶段开始的标志就是调用new vue()来构造一个Vue组件实例。自该语句开始,一个Vue应用正式被构建。该阶段大致又可分为两个阶段,分别是初始化阶段和挂载(销毁)阶段。当初始化完成时,如果el配置存在,则立即进入挂载阶段,否则将等待手动调用$mount才会进入挂载阶段。

我们回顾一下Vue构造函数的实现:

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

真正有效的就只有一行代码:this._init(options),即调用原型上的_init方法,传入options,初始化组件实例。下面是初始化阶段的整个过程输出:
在这里插入图片描述
整个过程的关键点为:

  1. 初始化$options,这一步就是把组件配置options直接保存为实例的$options属性,以供后面的各种初始化使用。
  2. 调用initProxy方法初始化proxy代理。如果浏览器支持proxy,Vue会为当前实例生成一个代理对象,以它作为render函数的调用者,以提高性能,如果不支持,则该代理就是当前实例自身。
  3. 调用initLifecycle初始化组件生命周期。这里主要是初始化一些与生命周期相关的实例属性,如$children、_watcher、_isMounted等。它们暂时只是空值,会在进入特定的生命周期时被赋予特定的值。
  4. 调用initEvents初始化组件事件属性。主要是定义_events属性,该属性后面将用于存储与当前组件有关的事件监听,目前它的值是空的,挂载阶段才会为其赋值。
  5. 调用initRender初始化与渲染相关的实例属性和方法。包括初始化_vnode、$slots、_c、$attrs、$listeners等,_vnode将在挂载阶段保存当前组件对应的虚拟节点;$slots用于保存插槽内容;_c是渲染真实DOM的方法(配置render: h => h(App)的函数h指的正是_c),在浏览器环境下,它主要基于document.createElement实现;$attrs和$listeners用于保存来自父组件的属性和监听函数注入。
  6. 执行到这里,与组件状态无关的配置都已经初始化完毕,beforeCreate生命周期钩子函数被调用。
  7. 调用initInjections初始化注入。它要解析的是依赖注入模式下当前组件从外部注入的变量,关于依赖注入模式,这里暂不详解,请参考Vue官网。
  8. 调用initState初始化组件状态。这里分别又调用了initProps、initMethods、initData、initComputed和initWatch来初始化配置中的props、methods、data、computed和watch。它们都是与组件的业务逻辑息息相关的配置,执行完毕后,它们都以实例属性或方法的形式直接添加到了组件上。比如,当执行完initData后,你就可以直接用this.message来访问data中的message变量了,其他配置同理。值得一提的是,这一步骤的主要作用是构建响应式系统,比如initData不仅仅是将变量添加到组件上,而且为其生成了一个Observer观察者对象,这样Vue就可以对该变量的变化进行观测,关于响应式系统的实现,我们后面会继续讲到。
  9. 调用initProvide初始化provide,这是依赖注入模式的provide部分,与injections是对应的,感兴趣的可以参考Vue官网了解它的用法。
  10. 现在组件实例已经初始化完毕,执行create生命周期钩子函数。

初始化完毕后的内存图是这样的:
在这里插入图片描述
_init函数的最末尾,Vue会检查el属性是否存在,如果存在,将进入挂载阶段:

Vue.prototype._init = function (options) {
  ...
  if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
}

如果没有el属性,则需要等到手动调用$mount方法时才会进行挂载。

在讲解挂载阶段之前,我们再回头探讨一下响应式系统。我们知道,响应式系统的核心对象是data,所以响应式系统主要是在initData中构建起来的(props、computed等都间接地依赖data,因此它们的响应式本质上都来自于data的响应式特性),我们剥离出initData最关键的一行代码:

function initData () {
  ...
  // 调用observe观测data
  observe(data, true /* asRootData */)
}

observe函数用于将data转化为响应式,也就是搭建响应式系统。响应式系统包括三个核心对象:ObserverDepWatcher

Observer以__ob__的属性的形式存在与数据对象上,用于观测对象属性的变化。Dep以dep属性的形式存在于__ob__属性内,负责帮助Observer收集和通知订阅者。而Watcher就是订阅者,它存在于dep属性的subs数组属性内,负责在数据发生变化时执行某些操作(如更新视图或执行回调)。三者的结构如下:

// initData执行完毕后组件的_data属性
// 包含__ob__属性证明它已经是响应式的
this._data = {
    message: ‘’,
    __ob__: {
        dep: {
             subs: [watcher,]  // 组件外部watcher
        }
    },
    get message (): {}  // 调用get时,依赖会被收集
    set message (): {    // 内部包含对该属性的观察者对象
        // 这里包含组件内对message的订阅者(watcher)
    }
}

调用observe观测data时,Vue会为它添加一个Observer类型的__ob__属性,这个过程中使用Object.defineProperty递归地修改data每个属性的get和set,同时__ob__属性还会初始化一个dep属性,用于管理相关依赖,这些依赖(即watchers)被保存在dep属性的subs数组内。调用new Watcher生成一个订阅者时,它会自动进入该数据对象的订阅者队列,而当数据变化时,Observer会通知Dep,Dep则依次调用每个watcher提供的run方法,执行对应的回调,以此实现响应式系统。具体的过程可参考我之前关于响应式系统的介绍:Vue源码笔记之响应式系统。

b. 组件挂载、更新和销毁阶段

组件初始化完毕后,如果el属性存在,就可以进行挂载以生成真正的DOM了。下面是整个挂载、更新和销毁过程:
在这里插入图片描述
以下是挂载阶段的流程图表示:
在这里插入图片描述
首先是检查render函数是否存在。对于完整版本的Vue,如果render函数不存在,那么它将调用自身的模板编译器对template进行编译;对于运行时版本,如果render函数不存在否则直接抛出异常。整个的编译过程较为复杂,我们直接给出编译前后的效果:
模板:

<template>
  <div id="app">
     <ul>
       <li v-for=“item in items”>
          itemid: {{ item.id }}
       </li>
    </ul>
  </div>
</template>

渲染函数:

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 ")]
             )}
          )
       )]
     )}
  }

上述模板与下面的渲染函数完全等价,可以相互转换。渲染函数里的_c、_l、_v、_s等都是Vue定义的辅助渲染函数,用于解析模板中不同的部分。如_c用于创建DOM,它主要基于document.createElement;_l用于解析列表,如v-for列表;_v用于解析标签文本;_s用于解析变量的值,辅助渲染函数还有很多,这里暂不一一详述。

有了渲染函数,接下来就是定义一个用于渲染和更新组件的函数:updateComponent,它的大致实现如下:

const updateComponent = () => {
  vm._update(vm._render());
}

我们来看它的作用。vm._render()内部会调用上述render函数,新生成一个对DOM的虚拟描述,以下就是调用上述渲染函数生成的JavaScript对象:
在这里插入图片描述
我们把这个对象称为虚拟节点(vnode),它对应一个组件的结构。对于一个Vue应用来说,所有的虚拟节点会组成一整棵树状结构,也就是我们所说的虚拟DOM树。

这个虚拟DOM就是我们最终要渲染到页面上的HTML的js版本,它被传递给组件的_update方法执行渲染。这里所说的渲染包括首次绘制和更新,_update内部会根据旧的vnode是否存在来判断是首绘还是更新。_update的实现大致如下:

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

当旧的vnode不存在,说明这是首次绘制,__patch__将依据虚拟DOM生成真实DOM并绘制到页面。如果旧的vnode是存在的,说明当前组件已经被绘制到页面上了,这时候__patch__将负责比对两个vnode,然后判断如何最高效地更新真实DOM,最后去更新视图。__patch__过程较为复杂,如果感兴趣,可以参考我之前关于虚拟DOM的博客:Vue源码笔记之虚拟DOM,里面有详细的patch过程和图解。

也就是说,调用updateComponent时,如果组件尚未渲染,则依据vnode渲染组件(该过程主要就是用document.createElement创建真实DOM标签,然后用appendChild添加到页面上);如果组件已经存在,则比对vnode,产生高效更新算法,用原生的DOM方法去操作真实DOM,完整视图更新。

显然,定义这个函数是为了在数据变化时自动调用以更新视图,也就是说它必须接入到响应式系统才有意义。接下来的代码就是将其接入响应式系统:

function mountCompnent () {
  ...
  // 上述函数
  const updateComponent = () => {
    vm._update(vm._render());
  }
  // 将updateComponent接入响应式系统
  new Watcher(vm, updateComponent, noop, {
    before () {
      callhook('beforeUpdate');
    }
  })
}

还记得watcher的作用吗,它是数据对象的订阅者,负责在数据变化时执行某些操作。上面的代码为当前组件实例构造了一个watcher,初始化watcher的过程中会触发data属性的get方法,因此这个watcher就会被Dep收集起来,传入的回调函数正是它的updateComponent方法。当数据变化时,Observer会通知Dep,Dep依次调用订阅者watcher的run方法,run里面会执行上述回调函数(即updateComponent),于是视图得到更新。这样就实现了修改数据之后自动更新视图。再次看一下此时data的结构:

this._data = {
    message: ‘’,
    __ob__: {
        dep: {
             subs: []
        }
    },
    get message (): {}  // 调用get时,依赖会被收集
    set message (): {    // 内部包含对该属性的观察者对象
        // 这里包含组件内对message的订阅者(watcher)
    }
}

注意,_data内的__ob__.dep.subs保存的并不是当前组件内的watcher,而是外部watcher。如果当前模板中这样绑定了data内的message:<div>{{message}}</div>,那么组件对应的watcher就会以闭包的形式保存在set message () { ... }内。当message值变化时,set方法就会调用,它会继而触发依赖收集者dep的notify方法通知订阅者,该方法会依次调用subs中每个watcher的run方法,依次调用它们提供的回调函数。对于用于更新组件的watcher来说,这个回调函数就是它的updateComponent方法。

而每当updateComponent被调用前,Vue都会调用callHook('beforeUpdate'),执行该生命周期钩子函数,因为视图即将被更新。当然,当updateComponent执行完毕后,Vue又会调用callHook('updated'),执行更新完毕的生命周期钩子函数。

最后是组件的销毁过程。当手动调用this.$destroy(),或由于v-if属性等原因导致组件必须被销毁时,Vue主要执行了以下过程:
在这里插入图片描述
当触发$destroy方法时,首先是调用beforeDestroy生命周期钩子函数。接着主要是清除组件的依赖关系,以及销毁watcher等。此时组件已经失去了响应能力,相当于它的状态被销毁了,因此Vue调用destroyed生命周期钩子函数。最后注销组件的事件监听,清除一些附属参数,组件彻底被销毁(对于Vue组件来说,一旦状态被销毁,它就被认为是销毁了,所以destroyed是在事件被销毁前调用的)。

最后附赠本文的示例代码和完整的console输出供大家学习:

<!DOCTYPE html>
  <html>
  <head>
       <script src="vue-2.6.10-learning.js"></script>
  </head>
  <body>
    <div id="app"></div>
    <script>
      var app = new Vue({
         el: '#app',
         template: "<div><h1>标题</h1><p>{{ message }}</p></div>",
         data () { return { message: 1 } },
         mounted () { 
              console.log(this.$data);
              setTimeout(() => { this.message = 2; }, 1000);
              setTimeout(() => { this.$destroy(); }, 5000);
         }
      })
    </script>
  </body>
  </html>

在这里插入图片描述

总结

本文主要讲解了Vue组件的完整渲染过程,如果能结合源码看本文,效果会更好。通过本文,我希望能够帮助读者对Vue的渲染过程有一个全局的了解,从而能够更深入地思考实际项目中出现的一些问题。

Logo

基于 Vue 的企业级 UI 组件库和中后台系统解决方案,为数万开发者服务。

更多推荐