概述
本文主要分两部分:
- Vue构造函数及原型对象的构造。主要是关于如何向Vue的原型prototype添加原型方法。
- Vue实例的初始化。即new Vue({ … })时执行的操作。
Vue构造函数及原型对象的构造
1. 定义构造函数
当我们像下面一样引入Vue.js文件时:
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
Vue首先定义大量的工具函数,如isDef(是否已定义)、isTrue(是否为true)、isObject(是否是对象)等等,
function isDef (v) { ... }
function isTrue (v) { ... }
function isObject (obj) {... }
......
这些工具函数与Vue自身的构造无关,完全是作为工具使用,可以暂时忽略。然后就是下面这个核心构造函数(位于src/core/instance/index.js中,打包后的vue.js位于5067行):
......
function Vue (options) {
//判断是否使用new关键字调用了Vue构造函数
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
//根据传入的options初始化当前的Vue实例this
this._init(options)
}
可以看到,Vue构造函数只有一行实质性的代码,就是this._init(options),表示调用Vue的原型方法_init初始化当前的Vue实例。这里的this就是我们刚创建的Vue实例,但在初始化前没有实例属性和方法,只有原型方法可以使用。这个_init方法是如何添加到Vue原型上的呢?
注意:在探究这个问题之前,我们要先明白一点,向一个构造函数原型上添加的方法可以直接用该构造函数的实例来调用,比如:
//声明一个构造函数People
function People(){
...
}
//向People的原型上添加eat方法
People.prototype.eat = function(){ ... }
//现在p是一个People实例
let p = new People();
//可以直接在p上面调用原型方法eat
p.eat();
2. 向Vue的原型混入方法
了解了以上原理后,我们继续来看紧跟在Vue构造函数后面的几行代码:
initMixin(Vue) //混入初始化方法,即_init
stateMixin(Vue) //混入状态相关属性和方法
eventsMixin(Vue) //混入事件相关属性和方法
lifecycleMixin(Vue) //混入生命周期相关属性和方法
renderMixin(Vue) //混入渲染相关属性和方法
......
定义完构造函数之后(注意:现在引擎只是在执行Vue的源码,还没有执行到我们自定义的new Vue()语句,因此构造函数中的this._init(options)并没有执行),立即通过五个mixin(混入,一种编程方法)向Vue的原型添加了很多的属性和方法。其中第一句initMixin(Vue)就是向Vue原型上添加_init方法,即Vue.prototype._init = function(Vue){ … }。那么什么是mixin呢?
mixin通常翻译为“混入”,假如若干个组件有多个相同的配置,可以将这些相同的配置提取出来到一个单独的文件中,再通过“混入”的方式导入到各个组件中。举个例子来理解:
// 模态框
const Modal = {
template: '#modal',
data() {return {isShowing: false}},
methods: {toggleShow() {this.isShowing = !this.isShowing;}},
}
// 提示框
const Tooltip = {
template: '#tooltip',
data() {return {isShowing: false}},
methods: {toggleShow() {this.isShowing = !this.isShowing;}},
}
我们看到,对于Modal和Tooltip来说,除了template属性外,其余两项data和methods是完全一样的,在定义两个组件时分别写一遍并没有什么必要。我们就可以将公共的属性单独提取出来,通过混入注入到组件上:
//将公共部分抽取出来
const public = {
data() {return {isShowing: false}},
methods: {toggleShow() {this.isShowing = !this.isShowing;}},
}
//定义template,然后混入公共部分
const Modal = {
template: '#modal',
mixins: [public],
};
//定义template,然后混入公共部分
const Tooltip = {
template: '#tooltip',
mixins: [public],
};
相同的部分通过mixin引入,不同的部分单独定义,这是mixin最重要的用途。在很多情况下,上面这种设计思路可以节省大量代码,并且结构更加清晰。另外,如果组件本身含有与公共部分相同的属性,将以组件自身的属性优先,即:
const Modal = {
template: '#modal',
mixins: [public],
//这个data的优先级高于通过混入引入的data
data(){return { isShowing: true}}
};
理解了mixin的原理后,我们来看这五个mixin都做了什么。我们将他们最核心的代码抽取出来:
//来自src/core/instance/init.js
function initMixin(Vue){
Vue.prototype._init = function(Vue){ ... }
}
//来自src/core/instance/state.js
function stateMixin(Vue){
......
Object.defineProperty(Vue.prototype, '$data', dataDef)
Object.defineProperty(Vue.prototype, '$props', propsDef)
Vue.prototype.$set = set
Vue.prototype.$delete = del
Vue.prototype.$watch = function (){ ... }
}
//来自src/core/instance/events.js
function eventsMixin = function(Vue){
Vue.$on = function(Vue){ ... }
Vue.$once = function(Vue){ ... }
Vue.$off = function(Vue){ ... }
Vue.$emit = function(Vue){ ... }
}
//来自src/core/instance/lifecycle.js
function lifecycleMixin(Vue){
Vue.prototype._update = function(Vue){ ... }
Vue.prototype.$forceUpdate = function(Vue){ ... }
Vue.prototype.$destroy= function(Vue){ ... }
}
//来自src/core/instance/render.js
function renderMixin(Vue){
installRenderHelpers(Vue.prototype)
Vue.prototype.$nextTick = function(Vue){ ... }
Vue.prototype._render = function(Vue){ ... }
}
上面的五个函数分别来自五个文件,即src/core/instance下面的init.js、state.js、events.js、lifecycle.js和render.js。在index.js中调用上述函数,即可向Vue的原型上添加与初始化、状态、事件、生命周期和渲染相关的各种原型方法。这些原型方法将对任何一个Vue实例对象可用。下面来分别简单介绍这五个mixin混入的原型方法的作用和原理。
提示:我们可能注意到,Vue的原型方法通常是以$或者_开头,这是Vue对方法用途的一种内部约定。以$开头的方法表示该方法将暴露给开发者使用,如$set、$watch等;而以_开头的方法表示这是Vue内部使用的方法,不推荐开发者使用,如_update、_render等。我们在向Vue添加原型方法时,需要避免与这些方法重名。
下面分别来看这些函数所做的工作。
1. initMixin
混入了Vue.prototype._init方法,用于初始化Vue实例(即构造函数中调用的_init方法)。我们将其中最重要的部分抽取出来(完整代码请查看src/coreinstance/init.js):
function initMixin(Vue){
Vue.prototype._init = function(options){
const vm = this; //将当前实例保存为vm(view-model的缩写)
vm._uid = uid++; //为当前实例打上唯一标记
...
if(options && options._isComponent){
//如果是个组件,则采用组件专有的初始化方法,效率更高
initInternalComponent(vm, options);
} else {
//将传入的options与vm构造函数的默认options合并,得到完整的options
vm.$options = mergeOptions(
resolveContructorOptions(vm.constructor),
options || {}, vm
)
}
...
vm._self = vm
initLifecycle(vm) //初始化生命周期
initEvents(vm) //初始化事件
initRender(vm) //初始化渲染函数
callHook(vm, 'beforeCreate') //调用beforeCreate钩子函数
initInjections(vm)//初始化注入
initState(vm) //初始化状态
initProvide(vm) //初始化依赖
callHook(vm, 'created') //调用created生命周期钩子函数
...
//如果options存在el属性,就执行挂载
//(否则需要使用vm.$mount手动挂载)
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}
我们给当前Vue实例起了个别名,叫vm(即view-model,因为Vue的设计参考了MVVM,因此习惯以vm指代Vue实例)。在向vm添加了几个简单的属性后,接着就进行options归并(对于没有传入的配置项,Vue有一些默认的值,将两者归并可以得到完整的配置列表)。根据vm是根节点还是组件,归并策略略有不同,这里不再详述。
接着就执行了一系列的初始化操作,包括初始化生命周期、事件、渲染函数、注入、状态和依赖,并在特定阶段调用相应的生命周期钩子函数(官网上的生命周期图就是依据这里钩子函数的调用时机绘制的)。这些初始化函数所做的事,就是本文第二部分要介绍的初始化过程。执行完初始化之后,如果options中存在el,就调用$mount进行挂载(否则需要手动挂载)。
2. stateMixin
混入了$data、$props、$set、$delete、$watch等与实例的生命周期相关的属性和方法。摘要如下:
function stateMixin(Vue){
...
Object.defineProperty(Vue.prototype, '$data', dataDef)
Object.defineProperty(Vue.prototype, '$props', propsDef)
Vue.prototype.$set = set
Vue.prototype.$delete = del
Vue.prototype.$watch = function(expOrFn, cb, options){
const vm = this
//如果传入的cb是一个对象(一般为函数,即属性变化时的
//回调函数),则需要从中进一步解析出回调函数
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
...
//创建一个watcher来监听目标属性
const watcher = new Watcher(vm, expOrFn, cb, options)
...
//返回关闭监听的方法
return function unwatchFn () {
watcher.teardown()
}
}
}
其中$data和$props只是_data和_props的只读版本。在初始化实例时,Vue会根据传入的data和props参数为实例添加_data和_props并在内部使用(初始化过程中会讲到),但这些数据也需要暴露给开发者使用,因此封装了一个只读版本(Vue不允许开发者通过只读的属性修改数据)。
$set和$delete的值set和delete是从src/core/observer/index.js中直接引入的,他们属于响应式系统的方法,由于Vue无法监测如arr[index] = xx(通过索引修改数组项的值)以及delete obj.xx(删除对象的某个属性)这类数据变化,因此提供了两个响应式方法来供开发者使用。具体的实现会在响应式系统中讲解。
$watch是一个Vue向开发者提供的手动监听对象属性变化的方法。简单的使用如:
this.$watch(message.title, function(){ ... });
当message的title属性变化时就会触发回调。实现过程主要是借助响应式系统的watcher(订阅者,它被用于在监听的属性变化时执行一些操作)。当我们执行了上述语句后,Vue就会创建一个与message.title对应的watcher,一旦message.title变化了,watcher就会执行我们传入的回调函数。
最后$watch返回一个关闭监听的句柄,我们可以使用该句柄来关闭对该属性的监听。$watch的实现完全依赖于响应式系统的watcher,我们会在响应式系统中详细讨论watcher的设计。
3. eventsMixin
混入了事件相关的原型方法,包括$on、$once、$off、$emit。各个方法的摘要如下:
function eventsMixin(Vue){
Vue.prototype.$on = function(event, fn){
const vm = this;
//同时监听多个事件时,依次对每一项执行$on
if(Array.isArray(event)){
for (let i = 0, l = event.length; i < l; i++) {
vm.$on(event[i], fn)
}
} else {
//将对应的fn添加到vm._events[event]中,该属性用于保存事件监听
(vm._events[event] || (vm._events[event] = [])).push(fn)
...
}
return vm
}
Vue.prototype.$once = function(event, fn){
const vm: Component = this
//这是一个拦截器,它在执行回调之前会先移除该回调,
//保证该回调只能执行一次
function on () {
vm.$off(event, on)
fn.apply(vm, arguments)
}
on.fn = fn
vm.$on(event, on)
return vm
}
//这里不进行详述,作用是从vm._events中移除对应的监听
Vue.prototype.$off = function(event, fn){
...
}
//不进行详述,作用是触发对应的事件
Vue.prototype.$emit = function(event){
...
}
}
这四个方法都是基于对vm._events的操作,Vue为每个实例对象都添加了一个_events属性来保存所有的事件和对应的回调,通常它应该是这样的:
//Vue中的事件包括原生事件和自定义事件,都在这里保存
vm._events = {
click: [fn1, fn2, ...], //原生事件
open: [fn3, fn4, ...], //自定义事件
...
}
4. lifecycleMixin
混入了三个生命周期相关的方法:_update、$forceUpdate、$destroy。其中最重要的是_update方法,它定义了如何更新当前组件,三个方法的摘要如下:
function lifycycleMixin(Vue){
Vue.prototype._update = function(vnode, hydrating){
const vm = this
...
const prevVnode = vm._vnode //旧的vnode
vm._vnode = vnode;
//如果旧的vnode不存在,则说明该组件是首次渲染
if(!vnode){
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
//如果旧的vnode存在,则通过__patch__修补虚拟节点vnode
//__patch__是虚拟DOM模块提供的方法
vm.$el = vm.__patch__(prevVnode, vnode)
}
}
//强制更新DOM的方法,不再详述
Vue.prototype.$forceUpdate = function(){
...
}
//销毁组件的方法,主要移除与该组件相关的资源,请自行查阅
Vue.prototype.$destroy = function(){
...
}
}
这里要明确一个问题,一个Vue实例就是对一个组件的描述,两者在某种意义上是等价的。在Vue实例中有一个_vnode(含义为virtual node,虚拟节点)属性来描述该组件的DOM结构。这个属性的值是一个JavaScript对象,借助这个属性,Vue可以将组件渲染为真实的DOM节点,因此该属性被称为虚拟节点。vnode的结构将在虚拟DOM部分进行探讨。
_update方法最重要的就是借助虚拟DOM模块提供的__patch__方法来修补vnode:如果之前该vnode不存在,说明是首次渲染,需要传入$el进行DOM生成和渲染;否则直接修补vnode,然后进行视图更新。
5. renderMixin
混入了与渲染相关的属性和方法。首先为Vue.prototype添加了许多辅助函数,如解析slot用的renderSlot,解析props用的bindObjectProps等等。他们以简写的形式添加到Vue的原型对象上(最终生成的渲染函数会用到这些辅助函数),如下(来自src/core/instance/render-helpers/index.js):
export function installRenderHelpers (target: any) {
target._o = markOnce
target._n = toNumber
target._s = toString
target._l = renderList
target._t = renderSlot
target._q = looseEqual
target._i = looseIndexOf
target._m = renderStatic
target._f = resolveFilter
target._k = checkKeyCodes
target._b = bindObjectProps
target._v = createTextVNode
target._e = createEmptyVNode
target._u = resolveScopedSlots
target._g = bindObjectListeners
target._d = bindDynamicKeys
target._p = prependModifier
}
在src/core/instance/render.js中这样使用上面的函数:
import { installRenderHelpers } from './render-helpers/index';
...
export function renderMixin(Vue){
installRenderHelpers(Vue.prototype);
Vue.prototype.$nextTick = function(fn){
return nextStick(fn, this);
}
Vue.prototype._render = function(){
const vm = this;
const {render, _parentVnode} = vm.$options;
...
vnode = render.call(vm._renderProxy, vm.$createElement);
...
return vnode;
}
}
首先将render-helpers中的一系列工具方法添加到Vue原型对象上,然后添加$nextTick和_render方法。
$nextTick与Vue的更新队列有关。当Vue的数据发生变化时,Vue不会立即更新视图,而是暂时存入队列中,当数据全部更新完成后统一执行,这样可以避免一些不必要的视图更新。如果某项操作需要在视图更新后才能进行,就需要放在$nextTick函数中。
_render是Vue定义的渲染函数,它使用当前Vue实例的渲染函数render来生成虚拟节点vnode(如果只提供了template,Vue会提前将其编译成渲染函数)。
以上就是Vue自身的构造过程。这个过程中,Vue创建了自身的构造函数,然后通过“混入”向其原型对象上添加了许多原型方法。这些方法将会参与到Vue实例的创建、初始化、更新和销毁的全部过程。
Vue实例的初始化
当我们在代码中执行new Vue({ … })时,就是在调用Vue的构造函数来构造一个Vue实例。比如:
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
//在Vue工程内使用vue
//import Vue from 'vue';
//new Vue语句将会调用function Vue来初始化一个Vue实例
new Vue({
el: "#app",
template: "...",
//render: function(h){ ... },
data: { ... },
methods: { ... },
...
})
执行上面的语句后,Vue会调用构造函数来创建一个Vue实例。前面说过,Vue的构造函数中只有一行实质性的代码,就是
function Vue(options){
...
this._init(options); //调用初始化函数来初始化当前实例
}
下面我们重新来看_init的定义,探讨一下Vue实例是如何初始化的。代码来自src/core/instance/init.js:
function initMixin(Vue){
Vue.prototype._init = function(options){
const vm = this; //将当前实例保存为vm(view-model的缩写)
vm._uid = uid++; //为当前实力打上唯一标记
...
if(options && options._isComponent){
//如果是个组件,则采用组件专有的初始化方法,效率更高
initInternalComponent(vm, options);
} else {
//将传入的options与vm构造函数的默认options合并,得到完整的options
vm.$options = mergeOptions(
resolveContructorOptions(vm.constructor),
options || {}, vm
)
}
...
vm._self = vm
initLifecycle(vm) //初始化生命周期
initEvents(vm) //初始化事件
initRender(vm) //初始化渲染函数
callHook(vm, 'beforeCreate') //调用beforeCreate钩子函数
initInjections(vm)//初始化注入
initState(vm) //初始化状态
initProvide(vm) //初始化依赖
callHook(vm, 'created') //调用created生命周期钩子函数
...
//如果options存在el属性,就执行挂载
//(否则需要使用vm.$mount手动挂载)
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}
该部分代码已在initMixin中简单介绍过,这里我们把目光聚焦在中间的若干个初始化函数上:
...
initLifecycle(vm) //初始化生命周期
initEvents(vm) //初始化事件
initRender(vm) //初始化渲染函数
callHook(vm, 'beforeCreate') //调用beforeCreate钩子函数
initInjections(vm)//初始化注入
initState(vm) //初始化状态
initProvide(vm) //初始化依赖
callHook(vm, 'created') //调用created生命周期钩子函数
...
在执行这些函数之前,vm已经是一个Vue实例了,但它本身只有uid、$options、_self等几个实例属性,这些初始化函数就是为了向vm添加更多实例属性和方法。按照初始化的顺序依次来看:
1. initLifecycle
添加与生命周期相关的实例属性,摘要如下(来自src/core/instance/lifecycle.js):
function initLifecycle (vm: Component) {
const options = vm.$options
//获取第一个非抽象父组件(抽象组件包括template、
//keep-alive、transition、transition-group)
let parent = options.parent
if (parent && !options.abstract) {
while (parent.$options.abstract && parent.$parent) {
parent = parent.$parent
}
parent.$children.push(vm)//将当前组件添加到非非抽象父组件的$children
}
vm.$parent = parent
vm.$root = parent ? parent.$root : vm
vm.$children = [] //子组件列表
vm.$refs = {} //保存通过ref注册的子节点
vm._watcher = null //订阅者,包含了更新当前组件
//的方法,响应式系统会用到
vm._inactive = null
vm._directInactive = false //是否已失活
vm._isMounted = false //是否已挂载
vm._isDestroyed = false //是否已销毁
vm._isBeingDestroyed = false //正在被销毁
}
以上初始化只是添加了与生命周期相关的一些实例属性,因为还没有进行state的初始化,因此大部分值都是空的(用途参见注释)。
2. initEvents
添加与事件相关的实例属性。摘要如下(来自src/core/instance/events.js):
export function initEvents (vm: Component) {
vm._events = Object.create(null)
vm._hasHookEvent = false
// init parent attached events
const listeners = vm.$options._parentListeners
if (listeners) {
updateComponentListeners(vm, listeners)
}
}
这里也很简单,就是添加了_events和_hasHookEvent两个属性,然后检查父组件有没有注册监听事件,如果有,就注册到当前组件中。
_events是用来保存事件回调的对象,属性名是事件名,属性值是对应的回调函数数组。_hasHookEvent表示该组件是否注册了生命周期钩子函数。
3. initRender
初始化与渲染相关的实例属性。代码如下(来自src/core/instance/render.js):
export function initRender(vm){
vm._vnode = null;
vm._staticTrees = null;
...
vm.$slots = resolveSlots(options._renderChildren, renderContext);
vm.$scopedSlots = emptyObject;
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false);
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true);
...
defineReactive(vm, '$attrs', ...)
defineReactive(vm, '$listeners', ...)
}
定义了与渲染相关的实例属性。_vnode用于保存执行渲染函数后生成的虚拟节点对象,$slots保存插槽,$scopedSlot保存作用域插槽,_c用于在渲染函数中生成标签元素对象,最后初始化$attrs和$listeners。
4. initInjections
初始化注入。在执行该步骤之前先调用“beforeCreate”的生命周期钩子函数,即当前组件已经进行到了“beforeCreate”这个阶段。
要理解什么是注入,首先需要了解什么是依赖注入模式。举个例子,假设我们定义了以下几个组件,嵌套关系如下:
现在假设Child1.vue需要用到root.vue组件中的数据message。由于两者没有直接关系,root.vue需要先将message通过:message="message"传递给Parent.vue,然后Parent.vue再将数据以同样的方式传递给Child1.vue。也就是说,即使Parent.vue完全用不到该数据,也要参与到数据的传递。可想而知,如果嵌套级别特别深,位于中间的组件需要参与所有子组件可能用到的数据的传递,整个系统会变得非常臃肿。
依赖注入采用的策略是,提供数据的祖先组件不再将数据传递给特定的子组件,而是以下面的方式提供给所有子组件:
...
provide: {
message: '',
...
}
现在祖先组件完全不在乎是谁使用这个message,它只负责暴露出该数据给可能用到的子组件。然后在任意一个子组件中通过下面的方式获取该数据:
inject: ['message'],
现在子组件就得到了祖先组件暴露的数据,并且没有经过任何中间组件的传递。
initInjections就是为inject的解析进行初始化。简单看一下实现:
export function initInjections (vm: Component) {
const result = resolveInject(vm.$options.inject, vm)
if (result) {
toggleObserving(false)
Object.keys(result).forEach(key => {
...
defineReactive(vm, key, result[key])
})
toggleObserving(true)
}
}
主要是从inject中解析出数据,然后将他们全部转化为响应式的。
5. initState
初始化状态。这里所做的工作很多,我们只大致分析一下整个的过程。
export function initState (vm: Component) {
vm._watchers = [] //订阅者队列,订阅者收到数据变化的消息后
//执行相应的操作,包括更新视图或执行回调等
const opts = vm.$options
if (opts.props) initProps(vm, opts.props) //初始化props
if (opts.methods) initMethods(vm, opts.methods)//初始化methids
if (opts.data) {
initData(vm) //初始化data
} else {
observe(vm._data = {}, true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)//初始化computed
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch) //初始化watch
}
}
initState中完成了对props、methods、data、computed和watch等选项的初始化。其中对props、data和computed的初始化是借助响应式系统实现的,主要是将这些数据转化为响应式的,转化过程在响应式系统中介绍。对methods的初始化就是直接将这些方法添加到vm实例上。对watch的初始化就是借助响应式系统,对目标属性添加监听。
6.initProvide
初始化依赖。即上述依赖注入中的provide,代码如下:
export function initProvide (vm: Component) {
const provide = vm.$options.provide
if (provide) {
vm._provided = typeof provide === 'function'
? provide.call(vm)
: provide
}
}
可以看到,只是把传入的provide参数保存在实例的_provided属性中。如果传入的是函数,则先调用这个函数。
执行完上述六个初始化函数,vm就已经是一个完整的Vue实例了,我们传给构造函数的所有配置项现在都已经被设置到了vm实例上。
总结
本文主要分析了Vue构造函数及原型对象的构造以及Vue实例的初始化过程。前一个阶段定义了Vue构造函数,并向其原型上添加了关于初始化、状态、事件、生命周期和渲染相关的原型方法。后一个阶段为初始化阶段,为构造出来的Vue实例(对应一个组件)初始化与生命周期、事件、渲染、依赖注入、状态相关的属性。
经过这两个阶段,一个Vue实例就具备了渲染为真正的DOM节点,以及进行DOM更新和销毁的能力。下面要做的就是将该实例挂载到真实DOM上,然后渲染页面了。这里将会在后面虚拟DOM部分进行探讨。
下文将会介绍Vue的响应式原理。它是Vue自动更新视图的基础。
文章链接
Vue源码笔记之项目架构
Vue源码笔记之初始化
Vue源码笔记之响应式系统
Vue源码笔记之编译器
Vue源码笔记之虚拟DOM