加载中...
Vue2 源码学习 loading

Flow

flow 是facebook出品的javascript静态类型检查工具。Vue.js的源码用了Flow做了静态类型检查

Flow工作方式

通常类型检查分为2种方式

类型判断:通过变量的使用上下文来推断出变量类型,然后根据这些推断来检查类型

类型注释: 事先注释好我们期待的类型,Flow会基于这些注释来判断

1
2
3
4
5
6
// @flow
function split (str){
return str.split(' ')
}

split('11')
1
2
3
4
5
6
// @flow
function add (x: number, y: number): number {
return x + y
}

add(12, 11)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Bar {
x: string;
y: string | number;
z: boolean;

constructor(x: string, y: string | number, z: boolean) {
this.x = x
this.y = y
this.z = z
}
}

let bar: Bar = new Bar('hello', 4)
let obj: {a: string, b: number, c: Array<string>, d: Bar} = {
a: 'hello',
b: 11,
c: ['hello', 'world'],
d: new Bar('hello', 3)
}

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
2
3
4
5
6
7
8
9
执行了init方法
function initData (vm) {
let data = vm.$options.data
observe(data, true /* asRootData */) // 做响应式处理
}
const props = vm.$options.props
const methods = vm.$options.methods
将data数据代理到vm上
如访问data.x => this.x (this._data.x)
1
2
3
4
5
Vue 初始化主要就干了几件事情,合并配置,初始化生命周期,初始化事件中心,初始化渲染,初始化 data、props、computed、watcher 等等。
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
在初始化的最后,检测到如果有 el 属性,则调用 vm.$mount 方法挂载 vm,挂载的目标就是把模板渲染成最终的 DOM,那么接下来我们来分析 Vue 的挂载过程。

Vue 实例挂载的实现

1
2
3
4
有render函数直接调用mount方法,没有将template转换成render函数 
render 生成 VNode
template = getOuterHTML(el)
Vue 不能挂载在 body、html 这样的根节点上。
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
2
3
4
5
6
7
render (createElement) {
return createElement('div', {
attrs: {
id: '#app1'
}
}, this.message)
} // 挂载的元素替换掉原有元素
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
2
3
其实 VNode 是对真实 DOM 的一种抽象描述,它的核心定义无非就几个关键属性,标签名、数据、子节点、键值等,其它属性都是都是用来扩展 VNode 的灵活性以及实现一些特殊 feature 的。由于 VNode 只是用来映射到真实 DOM 的渲染,不需要包含操作 DOM 的方法,因此它是非常轻量和简单的。
Virtual DOM 除了它的数据结构的定义,映射到真实的 DOM 实际上要经历 VNode 的 create、diff、patch 等过程。那么在 Vue.js 中,VNode 的 create 是通过之前提到的 createElement 方法创建的,我们接下来分析这部分的实现。

createElement

1
2
3
4
5
6
7
8
9
10
11
12
13
export function createElement (
context: Component,
tag: any,
data: any,
children: any,
normalizationType: any,
alwaysNormalize: boolean
): VNode | Array<VNode> {
if (Array.isArray(data) || isPrimitive(data)) {
normalizationType = children
children = data
data = undefined
} // 对参数个数不一致的处理,前移

每个 VNode 有 childrenchildren 每个元素也是一个 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
2
3
4
_update 的核心就是调用 vm.__patch__ 方法,这个方法实际上在不同的平台,比如 web 和 weex 上的定义是不一样的,因此在 web 平台中它的定义在 src/platforms/web/runtime/index.js 中:
Vue.prototype.__patch__ = inBrowser ? patch : noop
可以看到,甚至在 web 平台上,是否是服务端渲染也会对这个方法产生影响。因为在服务端渲染中,没有真实的浏览器 DOM 环境,所以不需要把 VNode 最终转换成 DOM,因此是一个空函数,而在浏览器端渲染中,它指向了 patch 方法,它的定义在 src/platforms/web/runtime/patch.js中:

patch 方法,它接收 4个参数,oldVnode 表示旧的 VNode 节点,它也可以不存在或者是一个 DOM 对象;vnode 表示执行 _render 后返回的 VNode 的节点;hydrating 表示是否是服务端渲染;removeOnly 是给 transition-group 用的,之后会介绍。

1
2
将oldVnode (真实DOM) 转换成真实Vnode
insert 新的DOM 删除旧的DOM 相当节点替换
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
2
3
4
new Vue({
// router,
render: createElement => createElement(App) // 传入的是组件
}).$mount('#app')

上一章我们在分析 createElement 的实现的时候,它最终会调用 _createElement 方法,其中有一段逻辑是对参数 tag 的判断,如果是一个普通的 html 标签,像上一章的例子那样是一个普通的 div,则会实例化一个普通 VNode 节点,否则通过 createComponent 方法创建一个组件 VNode。

App 对象,它本质上是一个 Component 类型。直接通过 createComponent 方法来创建 vnode

1
2
3
1.构造子类构造函数
2.安装组件钩子函数
3.实例化 vnode
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
2
3
4
5
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)

这里通过调用 mergeOptions 方法来合并,它实际上就是把 resolveConstructorOptions(vm.constructor) 的返回值和 options 做合并,resolveConstructorOptions 的实现先不考虑,在我们这个场景下,它还是简单返回 vm.constructor.options,相当于 Vue.options,那么这个值又是什么呢,其实在 initGlobalAPI(Vue) 的时候定义了这个值,代码在 src/core/global-api/index.js 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
export function initGlobalAPI (Vue: GlobalAPI) {
// ...
Vue.options = Object.create(null)
ASSET_TYPES.forEach(type => {
Vue.options[type + 's'] = Object.create(null)
})

// this is used to identify the "base" constructor to extend all plain-object
// components with in Weex's multi-instance scenarios.
Vue.options._base = Vue

extend(Vue.options.components, builtInComponents)
// .

首先通过 Vue.options = Object.create(null) 创建一个空对象,然后遍历 ASSET_TYPESASSET_TYPES 的定义在 src/shared/constants.js 中:

1
2
3
4
5
export const ASSET_TYPES = [
'component',
'directive',
'filter'
]

所以上面遍历 ASSET_TYPES 后的代码相当于:

1
2
3
Vue.options.components = {}
Vue.options.directives = {}
Vue.options.filters = {}

接着执行了 Vue.options._base = Vue,它的作用在我们上节实例化子组件的时候介绍了。

最后通过 extend(Vue.options.components, builtInComponents) 把一些内置组件扩展到 Vue.options.components 上,Vue 的内置组件目前有 <keep-alive><transition><transition-group> 组件,这也就是为什么我们在其它组件中使用 <keep-alive> 组件不需要注册的原因,这块儿后续我们介绍 <keep-alive> 组件的时候会详细讲。

mergeOptions 主要功能就是把 parentchild 这两个对象根据一些合并策略,合并成一个新对象并返回。比较核心的几步,先递归把 extendsmixins 合并到 parent 上,然后遍历 parent,调用 mergeField,然后再遍历 child,如果 key 不在 parent 的自身属性上,则调用 mergeField

这里有意思的是 mergeField 函数,它对不同的 key 有着不同的合并策略。

所以对于钩子函数,他们的合并策略都是 mergeHook 函数。这个函数的实现也非常有意思,用了一个多层 3 元运算符,逻辑就是如果不存在 childVal ,就返回 parentVal;否则再判断是否存在 parentVal,如果存在就把 childVal 添加到 parentVal 后返回新数组;否则返回 childVal 的数组。所以回到 mergeOptions 函数,一旦 parentchild 都定义了相同的钩子函数,那么它们会把 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export function callHook (vm: Component, hook: string) {
// #7573 disable dep collection when invoking lifecycle hooks
pushTarget()
const handlers = vm.$options[hook]
const info = `${hook} hook`
if (handlers) {
for (let i = 0, j = handlers.length; i < j; i++) {
invokeWithErrorHandling(handlers[i], vm, null, vm, info)
}
}
if (vm._hasHookEvent) {
vm.$emit('hook:' + hook)
}
popTarget()
}

在上一节中,我们详细地介绍了 Vue.js 合并 options 的过程,各个阶段的生命周期的函数也被合并到 vm.$options 里,并且是一个数组因此 callhook 函数的功能就是调用某个生命周期钩子注册的所有回调函数

beforeCreate & created

1
2
3
4
5
6
7
8
9
init 过程中
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')

可以看到 beforeCreatecreated 的钩子调用是在 initState 的前后,initState 的作用是初始化 propsdatamethodswatchcomputed 等属性。那么显然 beforeCreate 的钩子函数中就不能获取到 propsdata 中定义的值,也不能调用 methods 中定义的函数。

在这俩个钩子函数执行的时候,并没有渲染 DOM,所以我们也不能够访问 DOM,一般来说,如果组件在加载的时候需要和后端有交互,放在这俩个钩子函数执行都可以,如果是需要访问 propsdata 等数据的话,就需要使用 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
2
3
4
5
6
7
8
9
10
11
function callUpdatedHooks (queue) {
let i = queue.length
while (i--) {
const watcher = queue[i]
const vm = watcher.vm
// watcher是渲染watcher
if (vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'updated')
}
}
}

beforeDestroy & destroyed

顾名思义,beforeDestroydestroyed 钩子函数的执行时机在组件销毁的阶段,终会调用 $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-alivecomponenttransitiontransition-group 等,其它用户自定义组件在使用前必须注册。

Vue.js 提供了 2 种组件的注册方式,全局注册和局部注册。接下来我们从源码分析的角度来分析这两种注册方式。

全局注册

要注册一个全局组件,可以使用 Vue.component(tagName, options)。例如:

1
2
3
Vue.component('my-component', {
// 选项
})

那么,Vue.component 函数是在什么时候定义的呢,它的定义过程发生在最开始初始化 Vue 的全局函数的时候,代码在 src/core/global-api/assets.js 中:

函数首先遍历 ASSET_TYPES,得到 type 后挂载到 Vue 上 。ASSET_TYPES 的定义在 src/shared/constants.js 中:

1
2
3
4
5
export const ASSET_TYPES = [
'component',
'directive',
'filter'
]

所以实际上 Vue 是初始化了 3 个全局函数,并且如果 typecomponentdefinition 是一个对象的话,通过 this.opitons._base.extend, 相当于 Vue.extend 把这个对象转换成一个继承于 Vue 的构造函数,最后通过 this.options[type + 's'][id] = definition 把它挂载到 Vue.options.components 上。

由于我们每个组件的创建都是通过 Vue.extend 继承而来,我们之前分析过在继承的过程中有这么一段逻辑:

1
2
3
4
Sub.options = mergeOptions(
Super.options,
extendOptions
)

也就是说它会把 Vue.options 合并到 Sub.options,也就是组件的 options 上, 然后在组件的实例化阶段,会执行 merge options 逻辑,把 Sub.options.components 合并到 vm.$options.components 上。

我们在使用 Vue.component(id, definition) 全局注册组件的时候,id 可以是连字符、驼峰或首字母大写的形式。

局部注册

Vue.js 也同样支持局部注册,我们可以在一个组件内部使用 components 选项做组件的局部注册,例如:

1
2
3
4
5
6
7
import HelloWorld from './components/HelloWorld'

export default {
components: {
HelloWorld
}
}

在组件的 Vue 的实例化阶段有一个合并 option 的逻辑,之前我们也分析过,所以就把 components 合并到 vm.$options.components 上,这样我们就可以在 resolveAsset 的时候拿到这个组件的构造函数,并作为 createComponent 的钩子的参数。

注意,局部注册和全局注册不同的是,只有该类型的组件才可以访问局部注册的子组件,而全局注册是扩展到 Vue.options 下,所以在所有组件创建的过程中,都会从全局的 Vue.options.components 扩展到当前组件的 vm.$options.components 下,这就是全局注册的组件能被任意使用的原因。

当我们使用到组件库的时候,往往更通用基础组件都是全局注册的,而编写的特例场景的业务组件都是局部注册的。

异步组件

在我们平时的开发工作中,为了减少首屏代码体积,往往会把一些非首屏的组件设计成异步组件,按需加载。Vue 也原生支持了异步组件的能力,如下:

1
2
3
4
5
6
Vue.component('async-example', function (resolve, reject) {
// 这个特殊的 require 语法告诉 webpack
// 自动将编译后的代码分割成不同的块,
// 这些块将通过 Ajax 请求自动下载。
require(['./my-async-component'], resolve)
})
上一篇:
原生JS实现—打地鼠游戏
下一篇:
JS实现时钟效果
来发评论吧~
Powered By Valine
v1.5.1
本文目录
本文目录