Vue源码学习(一)
数据驱动
vue.js一个核心思想是数据驱动。
所谓数据驱动,是指视图是由数据驱动生成的,我们的视图修改,不会直接操作DOM,而是通过修改数据。它相比我们传统的前端开发,如使用jQuery等前端库直接修改DOM,大大简化了代码量。特别是复杂交互的时候,只关心数据的修改会让代码逻辑变得非常清晰,因为DOM变成了数据映射,我们所有的逻辑都是对数据的修改,而不直接操作DOM,这样的代码也比较利于维护。
new Vue发生了什么?
new关键字会实例化一个对象,而Vue本质是一个用function实现的类,它定义在src/core/instance/index.js中。
当使用new关键字初始化的时候,会调用this._init方法,该方法定义在src/core/instance/init.js中。
在init方法中,主要执行了合并配置、初始化生命周期、初始化事件中心、初始化渲染、初始化data、props、computed、watcher等。
在初始化的最后,检测到如果有el属性,在调用vm.$mount方法挂载vm实例,挂载的目的就是把模板渲染成最终的DOM。
Vue实例挂载的实现
Vue中我们是通过$mount实例方法去挂载vm实例的,$mount方法在多个文件中都有定义,因为$mount方法的实现是和平台、构建方式都相关的。
在src/platfrom/web/entry-runtime-with-compiler.js中定义了带compiler版本的$mount。
在这个定义中,首先缓存了Vue原型上的$mount,再重新定义该方法。
-
在重新定义中,首先对el做了限制,不能挂载在
body、html这样的根节点上。 -
接下来,如果没有render方法,会把el或者template字符串转换成render方法。
在
Vue 2.0中所有的Vue组件的渲染最终都需要render方法,无论是单文件.vue方式的组件,还是写了el或者template属性,最终都会转换成render方法。这个过程是调用compileToFunctions实现的。 -
最后,调用缓存的原型上的
$mount方法挂载。
原型上的$mount方法定义在src/platform/web/runtime/index.js中,这样的设计是为了被runtime only版本直接复用。
$mount方法支持传入两个参数,第一个是el,表示挂载的元素,可以是字符串,也可以是DOM对象,如果是字符串在浏览器环境下会调用query方法转换成DOM对象。第二个参数是和服务端渲染相关,在浏览器环境下不需要传第二个参数。$mount方法实际上会调用mountComponent方法,它定义在src/core/instance/lifecyle.js中。mountComponent核心就是先实例化一个渲染watcher,在它的回调中调用updateComponent方法,在updateComponent方法中,先调用vm._render方法生成虚拟Node,最后调用vm._update更新DOM。updateComponent函数最后判断为根节点的时候设置vm._isMounted为true,表示已挂载,同时执行mounted钩子函数。这里注意vm.$vonde表示Vue实例的父级虚拟Node,所以它为null则表示当前是根Vue实例。
render
Vue的_render方法是实例的一个私有方法,它用来把实例渲染成一个虚拟Node,它定义在src/core/instance/render.js中。
render函数的第一个参数是createElement,其实就是vm.$createElement方法。vm.$createElement方法定义是在执行initRender的时候创建的,除了它还有一个vm._c方法,该方法是被模板编译成的render函数使用,而vm.$createElement是用户手写render方法使用的,这两个方法支持的参数相同,内部都调用了createElement函数。
总结:vm._render最终通过执行createElement方法并返回vnode,他是一个虚拟Node。
Virtual DOM
真正的DOM元素是非常庞大的,因为浏览器的标准把DOM设计的非常复杂,当我们频繁的去操作DOM更新的时候,会产生一定的性能问题。
而Virtual DOM是用一个原生的JS对象去描述一个DOM节点,所以它比创建一个真实DOM的代价小得多。
createELement
createElement用来创建VNode,该方法定义在src/core/vdom/create-element.js中。
createElement方法实际上是对_createElement方法的封装,使其传入的参数更加灵活,在对参数进行处理之后,调用真正 创建VNode的函数_createElement函数。
_createElement方法有5个参数,context表示VNode的上下文环境,他是一个Component类型;tag表示标签,他可以是一个字符串,也可以是一个Component类型;data表示VNode的数据,它是一个VNodeData类型;children表示当前VNode的子节点,它是任意类型,所以要被处理成VNode数组;normalizationType表示节点规范的类型,类型不同规范的方法也就不一样,该参数主要是由render函数是编译生成的还是用户手写的决定。
children规范化
由于Virtual DOM实际上是一个树状结构,每一个VNode可能会有若干个子节点,这些子节点也应该是VNode的类型,所以需要把children规范成VNode类型的数组。
根据normalizationType的不同,调用了nomalizeChildren(children)和SimpleNomalizeChildren(children)方法,它们都定义在src/core/vdom/helpers/normalzie-children.js中。
simpleNormalizeChildren方法调用场景有2种,一个场景是render函数是用户手写的,当children只有一个节点的时候,允许用户把children写成基础类型用来创建一个简单的文本节点,这种情况会调用createTextVNode创建一个文本节点的VNode;另一个场景是当编译slot、v-for的时候会产生嵌套数组的情况,会调用normalizeArrayChildren方法。
normalizeArrayChildren接收2个参数,children表示要规范的子节点,nestedIndex表示嵌套的索引,因为单个child可能是一个数组类型。
normalizeArrayChildren的主要逻辑就是遍历children,获得单个节点c,然后对c进行类型判断,如果是数组,则递归调用normalizeArrayChildren;如果是基础类型,则通过createTextVNode方法转换成VNode类型;否则就已经是VNode类型了,如果children是一个列表并且列表还存在嵌套的情况,则根据nestIndex更新它的key。在遍历过程中,如果存在两个连续的text节点,会把它们合并成一个text节点。
经过对children的规范会,children就变成了一个类型为VNode的Array。
VNode 的创建
在createElement方法中,先对tag做判断,如果是String类型,则接着判断如果是内置的一些节点,则直接创建一个普通VNode,如果是已注册的组件名,则通过createComponent创建一个组件类型的Vnode,否则穿件一个未知标签的Vnode。
如果tag是一个Component类型,则直接调用createComponent创建一个组件类型的VNode。
update
Vue的_update是实例的一个私有方法,它被调用的时机有两个,一个是首次渲染,一个是数据更新的时候。_update方法的作用是把VNode渲染成真实的DOM,它定义在src/core/instance/lifecycle.js中。
_upadte的核心就是调用vm._patch_方法,这个方法在不同平台的定义是不一样的,在web平台中它的定义在src/platforms/web/rintime/index.js中。
在web平台上,是否是服务端渲染也会对这个方法产生影响。因为在服务端渲染中,没有真实的浏览器DOM环境,所以不需要吧VNode最终转换成DOM,因此是一个空函数。
在浏览器端渲染中,它指向了patch方法,patch方法定义在src/platforms/web/runtime/patch.js中。
path方法的定义是调用createPathFunction方法的返回值,在这里传入了一个对象,包含nodeOps参数和modules参数。其中nodeOps封装了一系列的DOM操作方法,modules定义了一些模块的钩子函数的实现。createPathFunction方法定义在src/core/vdom/path.js中。
createPathFunction内部定义了一系列的辅助方法,最终返回了一个path方法,这个方法就赋值给了vm._update函数里调用的vm._path_。
path方法本身,他接收4个参数,oldVnode表示旧的VNode节点,它也可以不存在或者是一个DOM对象;vnode表示执行_render后返回的VNode的节点;hydrating表示是否是服务端渲染;removeOnly是给transition-gruop用的。
var app=new Vue({
el:'#app',
render:function(createElement){
return createElemnet('div',{
attrs:{
id:'app'
}
},this.message)
},
data:{
message:'Hello Vue!'
}
})
然后再vm._update里调用path方法。
vm.$el=vm._patch_(vm.$el,vnode,haydrating,false/*removeOnly*/)
结合我们的例子,我们的场景是首次渲染,所以在执行patch函数的时候,传入的vm.$el对应的是例子中id为app的DOM对象,这个也就是我们在index.html模板中写的<div id='app'>,vm.$el的赋值是在之前mountComponent函数中做的,vnode对应的是调用render函数的返回值,haydrating在非服务端渲染情况下为false,removeOnly为false。
在patch函数中,我们传入的oldVnode实际上是一个DOM container,所以isRealElement为true,接下来又通过emptyNodeAt方法把oldVnode转换成VNode对象,然后在调用createElm方法。
createElm的作用是通过虚拟节点创建真实的DOM并插入到它的父节点中。createComponent方法的目的是尝试创建子组件,当前例子中,返回值为false;接下来判断vnode是否包含tag,如果包含,先简单对tag的合法性在非生产环境做校验,看是否是一个合法标签;然后再去调用平台DOM的操作去创建一个占位符元素。
接下来调用createChildren方法去创建子元素。
createChildren实际上是遍历子虚拟节点,递归调用createElm,这是一种常用的深度优先的遍历算法,在遍历过程中会把vnode.elm作为父容器的DOM节点占位符传入。
接着再调用invokeCreateHooks方法执行所有的create钩子并把vnodepush到insertedVnodeQueue中。
最后调用insert方法把DOM插入到父节点中,因为是递归调用,子元素会优先调用insert,所以整个vnode树节点的插入顺序是先子后父。insert方法,定义在src/core/vdom/patch.js中
insert逻辑很简单,调用一些nodeOps把子节点插入到父节点中,这些辅助方法定义在src/platforms/web/runtime/node-ops.js中。其实就是调用原生DOM的API进行DOM操作。
在createElm过程中,如果vnode节点不包含tag,则它可能是一个注释或者纯文本节点,可以直接插入到父元素中。在前面的例子中,最内层就是一个文本的VNode,它的text值取的就是之前的this.message的值Hello Vue!。
再回到patch方法,首次渲染我们调用了createElm方法,这里传入的parentElm是oldVnode.elm的父元素,在我们的例子是id为app的div的父元素,也就是body;实际上整个过程就是递归创建了一个完整的DOM并插入到body上。
最后,我们根据之前递归createElm生成的vnode插入顺序队列,执行相关的insert钩子函数。
组件化
vue.js的另一个核心思想是组件化。所谓组件化,就是把页面拆分成多个组件,每个组件依赖的css、JavaScript、模板、图片等资源放在一起开发维护。组件资源是独立的,组件系统内部可复用,组件和组件之间可以嵌套。
import Vue from 'vue'
import App from './App.vue'
var app=new VUe({
el:'#app',
// 这里的h就是createElement函数
render:h=>h(App)
})
createComponent
createElement函数中会调用_createElement方法,其中有一段逻辑是对参数tag判断,如果是一个普通的html标签,则会实例化一个普通VNode节点,否则通过createComponent方法创建一个组件VNode。
这次例子中传入的是一个App对象,他本质上是一个Component类型,那么会通过createComponent方法来创建VNode。createComponent方法定义在src/core/vdom/create-component.js中。
构造子类钩子函数
const baseCtor=context.$options._base
if(isObject(Ctor)){
Ctor=baseCtor.extend(Ctor)
}
编写组件的时候,通常都是创建一个普通对象,如App.vue代码如下:
import HelloWorld from './components/HelloWorld'
export default {
name:'app',
components:{
HelloWorld
}
}
这里export的是一个对象,所以createComponent里的代码逻辑会执行到baseCtor.extend(Ctor),在这里baseCtor实际上就是Vue,它的定义是在最开始初始化Vue的阶段,在src/core/global-api/index.js中的initGlobalAPI函数中有这样一段逻辑:
Vue.options._base=Vue
可以看到这里定义的是Vue.options,而我们的createComponent取的是contxt.$options,实际上在src/core/instance/init.js里Vue原型上的_init函数中有这么一段逻辑:
vm.$options=mergeOptions(
resolveContructorOptions(vm.constructor),
options || {},
vm
)
这样就把Vue上的一些option扩展到了vm.$options上,所以我们就能通过vm.$options._base拿到Vue构造函数。
明白了baseCtor指向Vue之后,再来分析一下Vue.extend函数的定义,在src/core/global-api/extend.js中。
Vue.extend的作用就是构造一个Vue的子类,它使用经典原型继承的方式把一个纯对象转换成一个继承于Vue的构造器Sub并返回。
Vue.extend=function(){
const Super=this
const Sub=function VueComponent(options){
this._init(options)
}
Sub.ptototype=Object.create(Super.prototype)
Sub.prototype.constructor=Sub
}
然后对Sub这个对象本身扩展了一些属性,如options、添加全局API等;并且对配置中的props和computed做了初始化工作,最后对于Sub构造函数做了缓存,避免多次执行Vue.extend的时候对同一个子组件重复构造。
当我们去实例化Sub的时候,就会执行this._init逻辑,也就是Vue实例的初始化逻辑。
安装组件钩子函数
insatallComponentHppks(data)
Vue.js使用的Virtual DOM参考的是开源库snabbdom,它的一个特点是在Vnode的path流程中对外暴露了各种时机的钩子函数,方便我们做一些额外的事情,Vue.js也是充分利用这一点,在初始化一个Component类型的VNode的过程中实现了几个钩子函数,如init、prepatch、insert、destroy等。
整个installComponentHoos的过程就是把componentVNodeHoos的钩子函数合并到data.hook中,在VNode执行patch的过程中执行相关的钩子函数。这里的合并策略是,如果某个时机的钩子函数已经在data.hook中,那么通过之星mergeHook函数做合并,它的逻辑很假单,就是在最终执行的时候,依次执行这两个钩子函数。
实例化VNode
通过new VNode实例化一个vnode并返回。需要注意的是和普通元素的vnode不同,组件的vnode是没有children的,这个很关键,在之后的patch会再次遇到。
patch
通过createComponent创建了组件VNode,接下来会走到vm._update,执行vm._patch_去把VNode转换成真正的DOM节点。
patch的过程中会调用createElm创建元素节点,它的定义在src/core/vdom/patch.js中。
createComponent
在createElm函数中会判断createComponent的返回值,如果为true直接结束。
createComponent函数中,首先对vnode.data做了判断,如果vnode是一个组件VNode,那么它就会有data属性,所以能够满足条件,并且得到i就是init钩子函数。
创建组件VNode的时候合并钩子函数中就包含init钩子函数,它定义在src/core/vdom/create-component.js中。
init钩子函数执行是通过createComponentInstanceForVnode创建一个Vue的实例,然后通过调用$mount方法挂载组件。
createComponentInstanceForVnode函数先构造一个内部参数options,然后执行new vnode.componentOptions.Ctor(options)。这里的vnode.componentOptions.Ctor对应的就是子组件的构造函数,它实际上是继承于Vue的一个构造器Sub,所以相当于执行的是new Sub(options)。在options参数中,_isComponent为true,表示它是一个组件,parent表示当前激活的组件实例。
所以子组件的实例化实际上就是在这个实际执行的,并且它会执行实例的_init方法。代码在src/core/instance/init.js中。
在init过程中有一些和new Vue方式实例化不同,首先是合并options的过程有变化,_isComponent为true,所以就走到initInteralComponent过程。在这个函数中,有几点要特别注意:
opts.parent = options.parent
opts._parentVnode = parentVnode
这些参数是在createComponentInstanceForVnode函数中通过传入的参数合并到内部选项$options中的。
_init函数最后执行的代码:
if(vm.$options.el){
vm.$mount(vm.$options.el)
}
由于组件初始化的时候是不传el的,因此组价是自己接管了$mount的过程,在组件的init过程中,componentVNodeHooks的init钩子函数,在完成实例化的_init之后,接着会执行child.$mount(hydrating ? vnode.elm : undefined, hydrating)。例子中考虑的是客户端渲染,所以这里相当于执行child.$mount(undefined, false),它最终会调用mountComponent方法,进而执行vm._render()方法。
在render函数中,_parentVnode就是当前组件的父VNode,而render函数生成的vnode是当前组件的渲染vnode,vnode的parent指向了_parentVnode,也就是vm.$vnode,它们是一种父子关系。
执行完vm._render生成VNode后,接下来就要执行vm._update去渲染VNode。vm._update的定义在src/core/instance/lifecycle.js中。
_update过程有几个关键代码,首先vm._vnode=vnode的逻辑,这个vnode是通过vm._render函数返回的组件渲染vnode,vm._vnode和vm.$vnode的关系就是一种父子关系,用代码表示就是vm._vnode.parent===vm.$vnode。
还有就是用activeInstance保持当前上下文的Vue实例,它是在lifecycle模块的全局变量,定义是在export let activeInstance:any=null,并且在之前调用createComponentInsatnceForVnode方法的时候从lifecycle模块获取,并且作为参数传入的。
因为JavaScript是单线程,Vue整个初始化是一个深度遍历的过程,在实例化子组件的过程中,它需要知道当前上下文的Vue实例是什么,并把它作为子组件的父Vue实例。之前提到过对子组件的实例化过程会先调用initInternalComponent(vm,options)合并options,把parent存储在vm.$options中,在$mount之前会调用initLifeCycle(vm)方法。
在initLifeCycle函数中,看到vm.$parent就是用来保留当前vm的父级实例,并且通过parent.$children.push(vm)来把当前的vm存储到父级实例的$children中。
在vm._update的过程中,把当前的vm赋值给activeInstance,同时通过const prevActiveInstance=activeInstance来保留上一次的activeInstance。实际上prevActiveInstance和当前vm是一个父子关系,当一个vm实例完成它所有子树的patch或者update过程后,activeInstance会回到它的父级实例,这样就保证了createComponentInsatnceForVnode整个深度遍历过程中,我们在实例化子组件的时候能传入当前组件的父Vue实例,并在_init的过程中,通过vm.$parent把这个父子关系保留。
回到_update,在这个函数的最后就是调用_patch_渲染VNode。
之前我们分析过负责渲染生成DOM的函数是createElm,在这里我们只传了2个参数,所以对应的parentElm是undefined,我们传入的vnode是组件渲染的vnode,也就是我们之前说的vm._vnode,如果组件的根节点是个普通元素,那么vm._vnode也是普通的vnode,这里createComponent(vnode,insertedVnodeQueue,parentElm,refElm)的的返回值是false。接下来就和上一章一样了,先创建一个父节点占位符,然后遍历所有子VNode递归调用createElm,在遍历过程中,如果遇到子VNode是一个组件的VNode,则重复本章开始的过程,这样通过递归的方式就可以完整地构建出整个组件树。
由于这时候传入的parentElm是空,所以对组件的插入,在createComponent中完成组件的整个patch过程后,最后执行insert(parentElm,vnode,elm,refElm)完成组件的DOM插入,如果组件patch过程中又创建了子组件,那么DOM的插入顺序是先子后父。
合并配置
通过之前的分析,new Vue的过程通常有2中场景,一种是外部我们的代码主动调用new Vue(options)的方式实例化一个Vue对象;另一种是我们前面分析的组件创建过程中内部通过new Vue(options)实例化子组件。
无论哪种场景,都会执行实例的_init(options)方法,该方法首先会执行mege options的逻辑,相关代码在src/core/instance/init.js中。
不同场景对于options端的合并逻辑是不一样的,并且传入的options值也有非常大的不同,接下来我们分别来分析2中不同场景的options合并过程。
简单示例代码:
import Vue from 'vue'
let childComp={
template:'<div>{{msg}}</div>',
created(){
console.log('child created')
},
mounted(){
console.log('child mounted')
},
data(){
return {
msg:'Hello Vue!'
}
}
}
Vue.mixin({
created(){
console.log('parent created')
}
})
let app=new Vue({
el:'#app',
render:h=>h(childComp)
})
外部调用场景
当执行new Vue的时候,在执行this._init(options)的时候,就会执行如下的逻辑进行options合并:
vm.$options=mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
这里通过调用mergeOptions方法来合并,该方法实际上就是把resolveConstructorOptions的返回值和options做合并,在我们的例子中resolveConstructorOptions返回的是vm.constructor.options,相当于vue.options,该值的定义是在initGlobalAPI(Vue)中,代码在src/core/global-api/index.js。
首先通过Vue.options=Object.create(null)创建一个空对象,然后遍历ASSET_TYPES,ASSET_TYPES的定义在src/shared/constants.js中。
遍历之后,Vue.options上就挂载了components、directives、filters属性。接着执行Vue.options._base=Vue,它的作用在前面实例化组件的时候介绍了。
最后通过extend(Vue.options.components,builtInComponents)把一些内置组件扩展到Vue.options.components上,Vue的内置组件目前有<keep-alive>、<transition> 、<transition-group>组件,这也就是为什么我们在其他组件中使用这些内置组件的时候不需要注册的原因。
回到mergeOptions函数,它定义在src/core/util/options.js中。
mergeOptions主要功能就是把parent和child这两个对象根据一些合并策略,合并成一个对象并返回。比较核心的几步就是,先递归把extends和mixins合并到parent上,然后遍历parent,调用mergeField,接着再遍历child,如果key不在parent的自身属性上,则调用mergeField。
mergeField函数,对不不同的key有这不同的合并策略。比如对于钩子函数,它们的合并策略都是mergeHook函数。在该函数中,用了一个多层3元运算符,逻辑是如果不存在childVal,就返回parentVal;否则再判断是否存在parentVal,如果存在就把childVal添加到parentVal后返回新数组;否则返回vhildVal的数组。所以,回到mergeOptions函数,一旦parent和child都定义了相同的钩子函数,那么会把两个钩子函数合并成一个数组。
其它属性的合并策略都可以在src/core/util/options.js中找到。
通过执行mergeField函数,把合并后的结果保存到options对象中,最终返回它。当前例子中,执行完合并之后:
vm.$options=mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
vm.$options的值差不多是如下这样的:
vm.$options={
components:{},
created:[
function created(){
console.log('parent created')
}
],
directives:{},
filters:{},
_base:function Vue(options){
...
},
el:'#app',
render:function(h){
...
}
}
组件场景
由于组件的构造函数是通过Vue.extend继承自Vue的,先回顾以下这个过程,代码定义在src/core/global-api/extend.js中。
这里的extendOptions对应的就是前面定义的组件对象,它会和Vue.options合并到Sub.options中。
接下来我们回忆一下子组件初始化的过程,代码定义在src/core/vdom/create-component.js中。
在这个文件中,vnode.componentOptions.Ctor就是指向Vue.extend的返回值Sub,所以执行new vnode.componentOptions.Ctor(options)接着执行this._init(options),因为options._isComponent为true,那么合并options的过程走到了initInternalComponent(vm,options)逻辑。它的代码实现,在src/core/instance/init.js中。
initInternalComponent方法首先执行const opts=vm.$options=Object.create(vm.constructor.options),这里的vm.constructor就是子组件的构造函数Sub,相当于vm.$options=Object.create(Sub.options)。
接着又把实例化子组件传入的子组件父VNode实例parentVnode、子组件的父Vue实例parent保存到vm.$options中,另外还保留了parentVnode配置中的如propsData等其它属性。
这么看来,initInternalComponent只是做了简单的一层对象赋值,并不涉及到递归、合并策略等复杂逻辑。
在当前例子中,执行完合并配置之后vm.$options的值差不多如下所示:
vm.$options = {
parent: Vue /*父Vue实例*/,
propsData: undefined,
_componentTag: undefined,
_parentVnode: VNode /*父VNode实例*/,
_renderChildren:undefined,
__proto__: {
components: { },
directives: { },
filters: { },
_base: function Vue(options) {
//...
},
_Ctor: {},
created: [
function created() {
console.log('parent created')
}, function created() {
console.log('child created')
}
],
mounted: [
function mounted() {
console.log('child mounted')
}
],
data() {
return {
msg: 'Hello Vue'
}
},
template: '<div>{{msg}}</div>'
}
}
对于options的合并有2中方式,子组件初始化过程通过 initInternalComponent 方式要比外部初始化Vue通过mergeOptions的过程要快,合并完的结果保留在vm.$options中。

被折叠的 条评论
为什么被折叠?



