目录
本文的主要内容是详细地介绍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
。现在view
和model
层都有了,下面我们就要让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组件,它不仅负责管理视图层和业务层,还负责两者的同步。
我们来简单看一下一些常用配置的作用:
- el
根元素,该参数只能由根节点声明,表示当前Vue应用需要被挂载到页面的哪个DOM节点上。如上面的例子指定了根元素为#app
,那么该Vue实例生成的DOM就会直接替换id为app
的元素。 - name
组件的名字,主要用于全局注册组件,如:
import MyComponent from 'MyComponent';
Vue.component(MyComponent.name, MyComponent);
- components
声明当前组件的外部依赖,相当于局部注册组件,在编写单组件时,如果需要用到其他的项目内组件通常会提供该参数。 - props
来自父级组件的数据依赖,这个依赖是响应式的。 - data
业务数据,这个参数是model
层的核心,相关的业务逻辑都是围绕data展开的。 - computed
计算属性,定义一组变量,这组变量的值是基于一个或多个props、data计算而来,computed内变量的值会根据这些依赖的值变化而自动更新,并且会自动缓存上次的计算结果。 - watch
手动监控props、data或者computed的变化,定义变化时的回调函数。 - 生命周期方法
定义Vue组件在各个生命周期需要执行的回调函数,Vue在执行到对应的阶段时会调用它们。生命周期与Vue组件创建的细节是第二部分渲染原理的重点。 - 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自身初始化的全过程,与组件实例构造相关方法的实现,我们会在组件的生命周期管理阶段详细剖析,下面是它们大致的介绍。
首先initMixin
为Vue混入了_init
原型方法,它的作用是根据传入的options初始化Vue组件实例。具体的初始化过程是生命周期管理阶段的重点之一,下一部分会详细介绍。
接着stateMixin
为Vue混入了$data、$props、$set、$delete和$watch
这5个与组件状态有关的原型方法或属性:$data
和$props
是_data
和_props
(这两个属性是初始化Vue实例时由_init添加到组件对象上的)的只读版本;$set
和$delete
是Vue提供的全局响应式方法,我们知道,由于JavaScript的限制,直接为已有对象添加或删除属性时,该属性不会被响应式系统观测到,$set
和$delete
就是响应式地新增或删除属性的全局方法;$watch
与watch
配置的作用是一致的,只是它可以通过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,初始化组件实例。下面是初始化阶段的整个过程输出:
整个过程的关键点为:
- 初始化
$options
,这一步就是把组件配置options
直接保存为实例的$options
属性,以供后面的各种初始化使用。 - 调用
initProxy
方法初始化proxy
代理。如果浏览器支持proxy,Vue会为当前实例生成一个代理对象,以它作为render函数的调用者,以提高性能,如果不支持,则该代理就是当前实例自身。 - 调用
initLifecycle
初始化组件生命周期。这里主要是初始化一些与生命周期相关的实例属性,如$children、_watcher、_isMounted
等。它们暂时只是空值,会在进入特定的生命周期时被赋予特定的值。 - 调用
initEvents
初始化组件事件属性。主要是定义_events
属性,该属性后面将用于存储与当前组件有关的事件监听,目前它的值是空的,挂载阶段才会为其赋值。 - 调用
initRender
初始化与渲染相关的实例属性和方法。包括初始化_vnode、$slots、_c、$attrs、$listeners
等,_vnode
将在挂载阶段保存当前组件对应的虚拟节点;$slots
用于保存插槽内容;_c
是渲染真实DOM的方法(配置render: h => h(App)
的函数h指的正是_c
),在浏览器环境下,它主要基于document.createElement
实现;$attrs和$listeners
用于保存来自父组件的属性和监听函数注入。 - 执行到这里,与组件状态无关的配置都已经初始化完毕,
beforeCreate
生命周期钩子函数被调用。 - 调用
initInjections
初始化注入。它要解析的是依赖注入模式下当前组件从外部注入的变量,关于依赖注入模式,这里暂不详解,请参考Vue官网。 - 调用
initState
初始化组件状态。这里分别又调用了initProps、initMethods、initData、initComputed和initWatch
来初始化配置中的props、methods、data、computed和watch
。它们都是与组件的业务逻辑息息相关的配置,执行完毕后,它们都以实例属性或方法的形式直接添加到了组件上。比如,当执行完initData
后,你就可以直接用this.message
来访问data中的message变量了,其他配置同理。值得一提的是,这一步骤的主要作用是构建响应式系统,比如initData
不仅仅是将变量添加到组件上,而且为其生成了一个Observer观察者对象,这样Vue就可以对该变量的变化进行观测,关于响应式系统的实现,我们后面会继续讲到。 - 调用
initProvide
初始化provide
,这是依赖注入模式的provide部分,与injections是对应的,感兴趣的可以参考Vue官网了解它的用法。 - 现在组件实例已经初始化完毕,执行
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转化为响应式,也就是搭建响应式系统。响应式系统包括三个核心对象:Observer
、Dep
和Watcher
。
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的渲染过程有一个全局的了解,从而能够更深入地思考实际项目中出现的一些问题。