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函数是编译生成的还是用户手写的决定。
由于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
。
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函数的时候,传入的 在patch函数中,我们传入的 接下来调用 接着再调用 最后调用insert方法把DOM插入到父节点中,因为是递归调用,子元素会优先调用insert,所以整个 insert逻辑很简单,调用一些 在 再回到patch方法,首次渲染我们调用了 最后,我们根据之前递归 这次例子中传入的是一个 编写组件的时候,通常都是创建一个普通对象,如 这里export的是一个对象,所以 可以看到这里定义的是 这样就把 明白了 然后对Sub这个对象本身扩展了一些属性,如 当我们去实例化Sub的时候,就会执行 整个 通过 通过 patch的过程中会调用 在 创建组件 所以子组件的实例化实际上就是在这个实际执行的,并且它会执行实例的 在 这些参数是在 由于组件初始化的时候是不传 在 执行完 还有就是用 因为JavaScript是单线程, 在 在 回到 之前我们分析过负责渲染生成DOM的函数是 由于这时候传入的 通过之前的分析, 无论哪种场景,都会执行实例的 不同场景对于options端的合并逻辑是不一样的,并且传入的options值也有非常大的不同,接下来我们分别来分析2中不同场景的options合并过程。 简单示例代码: 当执行 这里通过调用 首先通过 遍历之后, 最后通过 回到 其它属性的合并策略都可以在 通过执行 由于组件的构造函数是通过 这里的 接下来我们回忆一下子组件初始化的过程,代码定义在 在这个文件中, 接着又把实例化子组件传入的子组件父 这么看来, 在当前例子中,执行完合并配置之后 对于options的合并有2中方式,子组件初始化过程通过 vm.$el
对应的是例子中id为app
的DOM对象,这个也就是我们在index.html
模板中写的vm.$el
的赋值是在之前mountComponent
函数中做的,vnode
对应的是调用render函数的返回值,haydrating
在非服务端渲染情况下为false,removeOnly
为false。
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钩子并把vnode
push到insertedVnodeQueue
中。vnode
树节点的插入顺序是先子后父。insert方法,定义在src/core/vdom/patch.js
中nodeOps
把子节点插入到父节点中,这些辅助方法定义在src/platforms/web/runtime/node-ops.js
中。其实就是调用原生DOM的API
进行DOM操作。createElm
过程中,如果vnode
节点不包含tag,则它可能是一个注释或者纯文本节点,可以直接插入到父元素中。在前面的例子中,最内层就是一个文本的VNode
,它的text值取的就是之前的this.message
的值Hello Vue!
。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
}
}
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
}
options
、添加全局API
等;并且对配置中的props
和computed
做了初始化工作,最后对于Sub构造函数做了缓存,避免多次执行Vue.extend
的时候对同一个子组件重复构造。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节点。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
模块获取,并且作为参数传入的。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
。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
中。import Vue from 'vue'
let childComp={
template:'
外部调用场景
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
的内置组件目前有
组件,这也就是为什么我们在其他组件中使用这些内置组件的时候不需要注册的原因。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: '
initInternalComponent
方式要比外部初始化Vue
通过mergeOptions
的过程要快,合并完的结果保留在vm.$options
中。