Flow
flow 是facebook出品的javascript静态类型检查工具。Vue.js的源码用了Flow做了静态类型检查
Flow工作方式
通常类型检查分为2种方式
类型判断:通过变量的使用上下文来推断出变量类型,然后根据这些推断来检查类型
类型注释: 事先注释好我们期待的类型,Flow会基于这些注释来判断
1 | // @flow |
1 | // @flow |
1 | class Bar { |
Vue.js源码构建
Vue.js源码构建是基于rollup构ta 建,它的构建相关配置都在scripts目录下
从入口开始
Vue本身是一个函数,实现一个类,原型上挂载了很多方法
global-api:Vue上的静态属性
1 | Vue在instance index.js中定义 |
数据驱动
Vue.js一个核心思路式数据驱动。所谓数据驱动,是指视图是由数据驱动生成的,我们对视图的修改,不直接操作DOM,而是通过修改数据,DOM变成了数据的映射,我们所有的逻辑都是对数据的修改,而不用触碰DOM,这样的代码非常利于维护。
声明式的将数据渲染为DOM
new Vue发生了什么
1 | 执行了init方法 |
1 | Vue 初始化主要就干了几件事情,合并配置,初始化生命周期,初始化事件中心,初始化渲染,初始化 data、props、computed、watcher 等等。 |
Vue 实例挂载的实现
1 | 有render函数直接调用mount方法,没有将template转换成render函数 |
1 | 如果没有定义 render 方法,则会把 el 或者 template 字符串转换成 render 方法。这里我们要牢记,在 Vue 2.0 版本中,所有 Vue 的组件的渲染最终都需要 render 方法,无论我们是用单文件 .vue 方式开发组件,还是写了 el 或者 template 属性,最终都会转换成 render 方法,那么这个过程是 Vue 的一个“在线编译”的过程,它是调用 compileToFunctions 方法实现的,编译过程我们之后会介绍。最后,调用原先原型上的 $mount 方法挂载。 |
1 | 渲染watcher 当数据发生变化,调用updateComponent更新 |
render
1 | Vue 的 _render 方法是实例的一个私有方法,它用来把实例渲染成一个虚拟 Node。它的定义在 src/core/instance/render.js 文件中: |
1 | render (createElement) { |
1 | 我们在平时的开发工作中手写 render 方法的场景比较少,而写的比较多的是 template 模板,在之前的 mounted 方法的实现中,会把 template 编译成 render 方法,但这个编译过程是非常复杂的, |
1 | 实际上,vm.$createElement 方法定义是在执行 initRender 方法的时候,可以看到除了 vm.$createElement 方法,还有一个 vm._c 方法,它是被模板编译成的 render 函数使用,而 vm.$createElement 是用户手写 render 方法使用的, 这俩个方法支持的参数相同,并且内部都调用了 createElement 方法。 |
1 | vm._render 最终是通过执行 createElement 方法并返回的是 vnode,它是一个虚拟 Node。Vue 2.0 相比 Vue 1.0 最大的升级就是利用了 Virtual DOM。因此在分析 createElement 的实现前,我们先了解一下 Virtual DOM 的概念。 |
Virtual DOM
真正的 DOM 元素是非常庞大的,因为浏览器的标准就把 DOM 设计的非常复杂。当我们频繁的去做 DOM 更新,会产生一定的性能问题。
而 Virtual DOM 就是用一个原生的 JS 对象去描述一个 DOM 节点,所以它比创建一个 DOM 的代价要小很多。在 Vue.js 中,Virtual DOM 是用 VNode
这么一个 Class
去描述,它是定义在 src/core/vdom/vnode.js
中的。
1 | 其实 VNode 是对真实 DOM 的一种抽象描述,它的核心定义无非就几个关键属性,标签名、数据、子节点、键值等,其它属性都是都是用来扩展 VNode 的灵活性以及实现一些特殊 feature 的。由于 VNode 只是用来映射到真实 DOM 的渲染,不需要包含操作 DOM 的方法,因此它是非常轻量和简单的。 |
createElement
1 | export function createElement ( |
每个 VNode 有 children
,children
每个元素也是一个 VNode,这样就形成了一个 VNode Tree,它很好的描述了我们的 DOM Tree。
回到 mountComponent
函数的过程,我们已经知道 vm._render
是如何创建了一个 VNode,接下来就是要把这个 VNode 渲染成一个真实的 DOM 并渲染出来,这个过程是通过 vm._update
完成的,接下来分析一下这个过程。
vm._update
Vue 的 _update
是实例的一个私有方法,它被调用的时机有 2 个,一个是首次渲染,一个是数据更新的时候;由于我们这一章节只分析首次渲染部分,数据更新部分会在之后分析响应式原理的时候涉及。**_update
方法的作用是把 VNode 渲染成真实的 DOM**,它的定义在 src/core/instance/lifecycle.js
中:
1 | _update 的核心就是调用 vm.__patch__ 方法,这个方法实际上在不同的平台,比如 web 和 weex 上的定义是不一样的,因此在 web 平台中它的定义在 src/platforms/web/runtime/index.js 中: |
patch
方法,它接收 4个参数,oldVnode
表示旧的 VNode 节点,它也可以不存在或者是一个 DOM 对象;vnode
表示执行 _render
后返回的 VNode 的节点;hydrating
表示是否是服务端渲染;removeOnly
是给 transition-group
用的,之后会介绍。
1 | 将oldVnode (真实DOM) 转换成真实Vnode |
1 | 由于我们传入的 oldVnode 实际上是一个 DOM container,所以 isRealElement 为 true,接下来又通过 emptyNodeAt 方法把 oldVnode 转换成 VNode 对象,然后再调用 createElm 方法,这个方法在这里非常重要 |
最后调用 insert
方法把 DOM
插入到父节点中,因为是递归调用,子元素会优先调用 insert
,所以整个 vnode
树节点的插入顺序是先子后父。
其实就是调用原生 DOM 的 API 进行 DOM 操作
1 | patch 方法:实际上整个过程就是递归创建了一个完整的 DOM 树并插入到 Body 上。 |
Vue 初始化到最终渲染
new Vue => init => $mount => compile => render => vnode => patch => DOM
组件化
Vue.js 另一个核心思想是组件化。所谓组件化,就是把页面拆分成多个组件 (component),每个组件依赖的 CSS、JavaScript、模板、图片等资源放在一起开发和维护。组件是资源独立的,组件在系统内部可复用,组件和组件之间可以嵌套。
createComponent
1 | 组件Vnode的children为空 |
1 | new Vue({ |
上一章我们在分析 createElement
的实现的时候,它最终会调用 _createElement
方法,其中有一段逻辑是对参数 tag
的判断,如果是一个普通的 html 标签,像上一章的例子那样是一个普通的 div,则会实例化一个普通 VNode 节点,否则通过 createComponent
方法创建一个组件 VNode。
App 对象,它本质上是一个 Component
类型。直接通过 createComponent
方法来创建 vnode
。
1 | 1.构造子类构造函数 |
1 | 我们之前提到 Vue.js 使用的 Virtual DOM 参考的是开源库 snabbdom,它的一个特点是在 VNode 的 patch 流程中对外暴露了各种时机的钩子函数,方便我们做一些额外的事情,Vue.js 也是充分利用这一点,在初始化一个 Component 类型的 VNode 的过程中实现了几个钩子函数: |
patch
通过前一章的分析我们知道,当我们通过 createComponent
创建了组件 VNode,接下来会走到 vm._update
,执行 vm.__patch__
去把 VNode 转换成真正的 DOM 节点。这个过程我们在前一章已经分析过了,但是针对一个普通的 VNode 节点,接下来我们来看看组件的 VNode 会有哪些不一样的地方。
了解组件patch的整体流程
了解组件patch流程中的activeInstance、vm.$vnode、vm._vnode
了解嵌套组件的插入顺序
在完成组件的整个 patch
过程后,最后执行 insert(parentElm, vnode.elm, refElm)
完成组件的 DOM 插入,如果组件 patch
过程中又创建了子组件,那么DOM 的插入顺序是先子后父。(递归
)
1 | vm.$el = vm.__patch__() |
Patch整体流程:createComponent =》子组件初始化 =》子组件renfer =》子组件patch
合并配置
了解外部调用场景的配置合并
了解组件场景的配置合并
通过之前章节的源码分析我们知道,new Vue
的过程通常有 2 种场景,一种是外部我们的代码主动调用 new Vue(options)
的方式实例化一个 Vue 对象;另一种是我们上一节分析的组件过程中内部通过 new Vue(options)
实例化子组件。
无论哪种场景,都会执行实例的 _init(options)
方法,它首先会执行一个 merge options
的逻辑,相关的代码在 src/core/instance/init.js
中:
外部调用场景
当执行 new Vue
的时候,在执行 this._init(options)
的时候,就会执行如下逻辑去合并 options
:
1 | vm.$options = mergeOptions( |
这里通过调用 mergeOptions
方法来合并,它实际上就是把 resolveConstructorOptions(vm.constructor)
的返回值和 options
做合并,resolveConstructorOptions
的实现先不考虑,在我们这个场景下,它还是简单返回 vm.constructor.options
,相当于 Vue.options
,那么这个值又是什么呢,其实在 initGlobalAPI(Vue)
的时候定义了这个值,代码在 src/core/global-api/index.js
中:
1 | export function initGlobalAPI (Vue: GlobalAPI) { |
首先通过 Vue.options = Object.create(null)
创建一个空对象,然后遍历 ASSET_TYPES
,ASSET_TYPES
的定义在 src/shared/constants.js
中:
1 | export const ASSET_TYPES = [ |
所以上面遍历 ASSET_TYPES
后的代码相当于:
1 | Vue.options.components = {} |
接着执行了 Vue.options._base = Vue
,它的作用在我们上节实例化子组件的时候介绍了。
最后通过 extend(Vue.options.components, builtInComponents)
把一些内置组件扩展到 Vue.options.components
上,Vue 的内置组件目前有 <keep-alive>
、<transition>
和 <transition-group>
组件,这也就是为什么我们在其它组件中使用 <keep-alive>
组件不需要注册的原因,这块儿后续我们介绍 <keep-alive>
组件的时候会详细讲。
mergeOptions
主要功能就是把 parent
和 child
这两个对象根据一些合并策略,合并成一个新对象并返回。比较核心的几步,先递归把 extends
和 mixins
合并到 parent
上,然后遍历 parent
,调用 mergeField
,然后再遍历 child
,如果 key
不在 parent
的自身属性上,则调用 mergeField
。
这里有意思的是 mergeField
函数,它对不同的 key
有着不同的合并策略。
所以对于钩子函数,他们的合并策略都是 mergeHook
函数。这个函数的实现也非常有意思,用了一个多层 3 元运算符,逻辑就是如果不存在 childVal
,就返回 parentVal
;否则再判断是否存在 parentVal
,如果存在就把 childVal
添加到 parentVal
后返回新数组;否则返回 childVal
的数组。所以回到 mergeOptions
函数,一旦 parent
和 child
都定义了相同的钩子函数,那么它们会把 2 个钩子函数合并成一个数组。(先父后子)
组件场景
组件的构造函数是通过 Vue.extend
继承自 Vue
的
把实例化子组件传入的子组件父 VNode 实例 parentVnode
、子组件的父 Vue 实例 parent
保存到 vm.$options
中,另外还保留了 parentVnode
配置中的如 propsData
等其它的属性。
这么看来,initInternalComponent
只是做了简单一层对象赋值,并不涉及到递归、合并策略等复杂逻辑。
总结
那么至此,Vue 初始化阶段对于 options
的合并过程就介绍完了,我们需要知道对于 options
的合并有 2 种方式,子组件初始化过程通过 initInternalComponent
方式要比外部初始化 Vue 通过 mergeOptions
的过程要快,合并完的结果保留在 vm.$options
中。
生命周期
每个 Vue 实例在被创建之前都要经过一系列的初始化过程。例如需要设置数据监听、编译模板、挂载实例到 DOM、在数据变化时更新 DOM 等。同时在这个过程中也会运行一些叫做生命周期钩子的函数,给予用户机会在一些特定的场景下添加他们自己的代码。
源码中最终执行生命周期的函数都是调用 callHook
方法,它的定义在 src/core/instance/lifecycle
中
1 | export function callHook (vm: Component, hook: string) { |
在上一节中,我们详细地介绍了 Vue.js 合并 options
的过程,各个阶段的生命周期的函数也被合并到 vm.$options
里,并且是一个数组。因此 callhook
函数的功能就是调用某个生命周期钩子注册的所有回调函数。
beforeCreate & created
1 | init 过程中 |
可以看到 beforeCreate
和 created
的钩子调用是在 initState
的前后,initState
的作用是初始化 props
、data
、methods
、watch
、computed
等属性。那么显然 beforeCreate
的钩子函数中就不能获取到 props
、data
中定义的值,也不能调用 methods
中定义的函数。
在这俩个钩子函数执行的时候,并没有渲染 DOM,所以我们也不能够访问 DOM,一般来说,如果组件在加载的时候需要和后端有交互,放在这俩个钩子函数执行都可以,如果是需要访问 props
、data
等数据的话,就需要使用 created
钩子函数。之后我们会介绍 vue-router 和 vuex 的时候会发现它们都混合了 beforeCreate
钩子函数。
beforeMount & mounted
顾名思义,beforeMount
钩子函数发生在 mount
,也就是 DOM 挂载之前,它的调用时机是在 mountComponent
函数中,定义在 src/core/instance/lifecycle.js
中
在执行 vm._render()
函数渲染 VNode 之前(执行完了compie过程编译为render函数),执行了 beforeMount
钩子函数,在执行完 vm._update()
把 VNode patch 到真实 DOM 后,执行 mounted
钩子。注意,这里对 mounted
钩子函数执行有一个判断逻辑,vm.$vnode
如果为 null
,则表明这不是一次组件的初始化过程,而是我们通过外部 new Vue
初始化过程。那么对于组件,它的 mounted
时机在哪儿呢?
之前我们提到过,组件的 VNode patch 到 DOM 后,会执行 invokeInsertHook
函数,把 insertedVnodeQueue
里保存的钩子函数依次执行一遍,它的定义在 src/core/vdom/patch.js
中:
对于同步渲染的子组件而言,mounted
钩子函数的执行顺序是先子后父。
beforeUpdate & updated
beforeUpdate
的执行时机是在渲染 Watcher 的 before
函数中,我们刚才提到过
注意这里有个判断,也就是在组件已经 mounted
之后,才会去调用这个钩子函数。
update
的执行时机是在flushSchedulerQueue
函数调用的时候,它的定义在 src/core/observer/scheduler.js
中
我们之前提过,在组件 mount 的过程中,会实例化一个渲染的 Watcher
去监听 vm
上的数据变化重新渲染,这段逻辑发生在 mountComponent
函数执行的时候
1 | function callUpdatedHooks (queue) { |
beforeDestroy & destroyed
顾名思义,beforeDestroy
和 destroyed
钩子函数的执行时机在组件销毁的阶段,终会调用 $destroy
方法,它的定义在 src/core/instance/lifecycle.js
中:
包括从 parent
的 $children
中删掉自身,删除 watcher
,当前渲染的 VNode 执行销毁钩子函数等,执行完毕后再调用 destroy
钩子函数。
在 $destroy
的执行过程中,它又会执行 vm.__patch__(vm._vnode, null)
触发它子组件的销毁钩子函数,这样一层层的递归调用,所以 destroy
钩子函数执行顺序是先子后父,和 mounted
过程一样。
总结
这一节主要介绍了 Vue 生命周期中各个钩子函数的执行时机以及顺序,通过分析,我们知道了如在 created
钩子函数中可以访问到数据,在 mounted
钩子函数中可以访问到 DOM,在 destroy
钩子函数中可以做一些定时器销毁工作,了解它们有利于我们在合适的生命周期去做不同的事情。
组件注册
在 Vue.js 中,除了它内置的组件如 keep-alive
、component
、transition
、transition-group
等,其它用户自定义组件在使用前必须注册。
Vue.js 提供了 2 种组件的注册方式,全局注册和局部注册。接下来我们从源码分析的角度来分析这两种注册方式。
全局注册
要注册一个全局组件,可以使用 Vue.component(tagName, options)
。例如:
1 | Vue.component('my-component', { |
那么,Vue.component
函数是在什么时候定义的呢,它的定义过程发生在最开始初始化 Vue 的全局函数的时候,代码在 src/core/global-api/assets.js
中:
函数首先遍历 ASSET_TYPES
,得到 type
后挂载到 Vue 上 。ASSET_TYPES
的定义在 src/shared/constants.js
中:
1 | export const ASSET_TYPES = [ |
所以实际上 Vue 是初始化了 3 个全局函数,并且如果 type
是 component
且 definition
是一个对象的话,通过 this.opitons._base.extend
, 相当于 Vue.extend
把这个对象转换成一个继承于 Vue 的构造函数,最后通过 this.options[type + 's'][id] = definition
把它挂载到 Vue.options.components
上。
由于我们每个组件的创建都是通过 Vue.extend
继承而来,我们之前分析过在继承的过程中有这么一段逻辑:
1 | Sub.options = mergeOptions( |
也就是说它会把 Vue.options
合并到 Sub.options
,也就是组件的 options
上, 然后在组件的实例化阶段,会执行 merge options
逻辑,把 Sub.options.components
合并到 vm.$options.components
上。
我们在使用 Vue.component(id, definition)
全局注册组件的时候,id 可以是连字符、驼峰或首字母大写的形式。
局部注册
Vue.js 也同样支持局部注册,我们可以在一个组件内部使用 components
选项做组件的局部注册,例如:
1 | import HelloWorld from './components/HelloWorld' |
在组件的 Vue 的实例化阶段有一个合并 option
的逻辑,之前我们也分析过,所以就把 components
合并到 vm.$options.components
上,这样我们就可以在 resolveAsset
的时候拿到这个组件的构造函数,并作为 createComponent
的钩子的参数。
注意,局部注册和全局注册不同的是,只有该类型的组件才可以访问局部注册的子组件,而全局注册是扩展到 Vue.options
下,所以在所有组件创建的过程中,都会从全局的 Vue.options.components
扩展到当前组件的 vm.$options.components
下,这就是全局注册的组件能被任意使用的原因。
当我们使用到组件库的时候,往往更通用基础组件都是全局注册的,而编写的特例场景的业务组件都是局部注册的。
异步组件
在我们平时的开发工作中,为了减少首屏代码体积,往往会把一些非首屏的组件设计成异步组件,按需加载。Vue 也原生支持了异步组件的能力,如下:
1 | Vue.component('async-example', function (resolve, reject) { |
v1.5.1