Vue源码分析

作为一名日常使用Vue开发的前端开发,Vue的源码是有意义去了解的。今天的分享主要是Vue2.x源码的部分概念,包括以下三个内容:Vue的初始化、挂载与渲染流程、响应式系统的构建。

1. Vue的初始化

在所有以Vue为开发框架的项目里,都必定会执行new Vue语句来形成Vue的根实例。那么在源码里都干了些什么事呢?

在源码里,Vue是一个非常简单的以Function实现的类。在src/core/instance/index.js里定义了它。并且在这里执行了初始化所需要的所有方法函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Vue (options) {
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

Vue源码都在src目录下,并且它的目录结构是非常清晰的,概括如下。

1
2
3
4
5
6
7
src
├── compiler # 编译相关
├── core # 核心代码
├── platforms # 不同平台的支持
├── server # 服务端渲染
├── sfc # .vue 文件解析
├── shared # 共享代码

这里可以看出,代码保证了Vue只能通过new关键字进行初始化,一系列的mixin方法分别定义了原型上与数据,事件,生命周期,渲染相关的静态属性与方法,也就是以$开头的那些属性与方法。而_init方法来自initMixin中,其内部也是进行一系列初始化操作,包括选项的规范校验与合并,初始化生命周期,初始化事件中心,构建数据响应式系统等。最后最关键的根据我们填写的el参数,来进行模版的渲染与挂载。

选项的合并是一个很关键的点,因为我们平时写的组件代码都是通过配置的方式传入并且执行Vue内部的方法Vue.extend去创建一个子类的,那么子类的选项必定会与父类的选项产生合并行为,大致分为常规选项合并,自带资源的合并(‘compoent’,’directive’,’filter’),生命周期函数的合并。其中,为什么在new Vue里data选项能写成Object形式,组件里要写成Functino的原因也在这里。(因为源代码里写死了,不是Function就会报错,哈哈,当然最终目的还是为了保证组件的复用,数据不互相影响)

在所有配置初始化完成后,如果我们配置el参数存在,会执行Vue的内部方法$mount。

1
2
3
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}

而$mount函数定义的地方会与我们选择的平台和构建版本有关。比如在platforms文件夹里有web和weex俩个平台,web里有一些入口文件js,为了保证性能以及代码体积更小,我们使用的都是runtime only的构建版本(基本上就是除去编译器外的完整代码),而编译的工作都交给了webpack的vue-loader了。但是为了更好分析Vue源码,我们要看runtime + compiler的版本,所以这时候我们要找的$mount方法在src/platforms/web/entry-runtime-with-compiler里,但是$mount的核心方法是mountComponent方法。

mountComponent方法定义在src/core/instance/lifecycle.js里,其主要作用就是进行模版的挂载、执行beforeMount,mounted生命周期函数、实例化一个组件只有一个的渲染Watcher。

2. 挂载与渲染流程

在Vue的内部方法$mount里有着挂载和渲染过程的一切,大概流程图如下。

根据流程图我们可以得知以下这么几个点:

  1. 确定挂载的DOM,这个DOM不能是boyd,html标签
  2. template模版的写法会被编译,首先会被编译成字符串模板会转换为抽象的语法树(AST),最终被编译成render函数。(在runtime-only版本中这一步交给插件了)
  3. 无论是template模板还是手写render函数,最终都将进入mountComponent过程,这个阶段会实例化一个渲染watcher。
  4. 渲染wathcer的莫一个参数是updateComponent函数,其内部通过_render方法将render函数生成为虚拟DOM树(一个以VNode类生成的Virtual Dom),_update方将虚拟DOM生成真实的DOM。
1
2
3
updateComponent = () => {
vm._update(vm._render(), hydrating)
}

其中编译的过程Vue源码里有一个名为compiler的文件夹来处理,不用去看懂,我们只需要知道编译的流程是怎样的即可。compiler的入口为一个名为createCompiler的函数。

  1. 因为不同平台对Vue的编译过程是不一样的,所以传给createCompiler的配置是不一样的,比如Vue内置的只有web和weex俩个平台,像市场上比较流行Vue跨端开源项目,如mpVue,uni-app等都会在platform下添加一个对应的文件夹。
  2. 内部经过一系列的处理会生成三种东西。
    • AST抽象树,一个对象,里面放了各种属性,方法的描述
    • render 一个以函数封装好的with语句
    • staticRenderFns 以数组形式存在的静态render

有了render函数后,接下来就是将它解析成虚拟DOM,在Vue中,就是用VNode这个构造函数去描述一个真实DOM节点,但并不会把真实DOM所有的东西都描述出来,因为真实DOM包括了自身的属性描述,大小位置,浏览器事件等,东西太多了。源码中VNode定义了的属性差不多有20个左右。定义的路径为src/core/vdom/vnode。在Vue2.x的版本中,VNode有三中类型:注释节点,普通节点,文本节点。

_render()生成虚拟DOM的过程中,代码里也会做很多事情,比如:数据的规范性检测、特殊属性key的规范性检测、子节点children的规范化、遇到用户自定义组件对其进行组件初始化。

有了虚拟DOM后,最后就是执行_update方法将其渲染成真实DOM,其中核心内容就是通过调用操作真实DOM的方法来生成真实DOM,比如调用createElm方法创建节点,插入子节点,经过递归创建后成为一个完整的DOM树并插入body中,并且在发生了数据变化影响真实DOM的阶段,会有diff算法来判断前后VNode的差异,以求最小化变化改变真实DOM。

当响应式数据发生了频繁的修改,会引起整个DOM树的频繁的重绘和重排,这是及其消耗性能的,如何优化这一渲染过程,Vue源码中给出俩个思路。

  1. 将多次修改推到一个队列中,在下一个tick去执行视图更新。
  2. 使用diff算法,将需要修改的数据进行比较,并只渲染必要的DOM。

而diff算法本质上就是进行新旧节点的对比,如果新旧节点的根节点不是同一个节点,则直接替换节点。(只进行同层节点的比较,节点不一致,直接用新节点及其子节点替换旧节点),如果是同一节点会进行如下的比较

  1. 节点相同,且节点除了拥有文本节点外没有其他子节点,直接替换文本内容。
  2. 新节点没有子节点,旧节点有子节点,则删除旧节点所有子节点。
  3. 旧节点没有子节点,新节点有子节点,则用新的所有子节点去更新旧节点。
  4. 新旧都存在子节点,则对比子节点内容做操作(最复杂的一步)。

在_update执行的过程中,如果碰到了自定义组件时,会去调用子组件init方法,开始进行该组件的合并配置,初始化生命周期,初始化事件中心,初始化渲染的过程。实例挂载又会执行$mount过程。

3. 响应式系统的构建

Vue作为数据驱动为特点的一个框架,响应式系统是其非常核心的一个概念。

首先我们要先了解,Vue源码中和响应式系统的构建相关的类为以下三种:

  1. Observer类,实例化一个Observer类会通过Object.defineProperty对数据的getter,setter方法进行改写,在getter阶段进行依赖的收集,在数据发生更新阶段,触发setter方法进行依赖的更新。
  2. Watcher类,实例化Watcher类相当于创建一个依赖,简单的理解是数据在哪里被使用就需要产生了一个依赖。当数据发生改变时,会通知到每个依赖进行更新。
  3. Dep类,既然Watcher理解为每个数据需要监听的依赖,那么对这些依赖的收集和通知则需要另一个类来管理,这个类便是Dep,Dep需要做的只有两件事,收集依赖和派发更新依赖。

在这张图里我们可以看到这三个类的关系

在最前面的初始化的时候,执行_init函数的时候,响应式系统的构建也已经同步开始了。经过初始化后的数据,我们在控制台打印this._data或者引用类型的数据时,我们会发现在其原型里会有一个不可枚举的__ob__字段,标示这是经过Observer类实例化后。

3.1 数据初始化

  1. data初始化:对data进行Observer类实例化,添加响应式对象标志__ob__, 执行walk函数(其核心内容为defineReactive函数),该函数主要作用是对每个属性实例化一个Dep类,即为每个数据都创建一个依赖的管理,如果遇到深层次对象(属性为一个对象),则会递归调用实例化Observer类,让其也转换为响应式对象。defineReactive函数就是利用Object.defineProperty重写getter,setter方法。数据被访问时,通过dep.depend收集被访问时的依赖Watcher。数据被修改时,通过dep.notify通知收集到的Watcher进行相应的更新。
  2. computed初始化:computed初始化和data类似,但是computed是直接通过Object.defineProperty设置set与get的,还有几点不同之处。1、如果我们写的computed是function类型的时候,set函数是为空函数的。2、与computed涉及的data会收集当前computed的watcher,方便后面data更改时,来通知computed的更新。3、computedWatcher的标志为{lazy: true},并且不会立刻执行依赖的更新操作,通过一个dirty设置为true进行标记,访问computed的时候如果dirty为true会重新计算值。4、computed不会收集渲染watcher,computed更新的时候视图会更新是为其涉及的data收集了渲染watcher。
  3. props初始化:遍历定义的props配置。遍历的过程主要做两件事情:一是调用Observer类里的defineReactive方法把每个prop对应的值变成响应式,二是使用Proxy为props做了一层代理,用户通过vm.XXX可以代理访问到vm._props上的值。
  4. watch初始化:当传入的选项里有watch选项时,会执行watch的初始化内容,其核心为createWatcher,无论传入watch是什么形式最终都会调用实例的$watch方法,$watch的核心是创建一个user watcher,其中{user: true}是当前用户定义watcher的标志。

3.2 依赖收集

既然数据的初始化完成以后,就是等待数据被访问,收集当前的依赖了,我们可以先看下Watcher的定义,和上面几种类型Watcher实例化时传了哪些参数。

Watcher基本定义

1
2
3
4
5
6
7
8
9
10
11
export default class Watcher {
...
constructor (
vm: Component, // vue实例
expOrFn: string | Function, // 用于收集依赖的方法
cb: Function, // 回调函数
options?: ?Object, // 自定义参数,比如自定义watch传入的depp,immediate
isRenderWatcher?: boolean // 是否为渲染Watcher
)
....
}

渲染Watcher实例化

1
2
3
4
5
6
7
8
9
10
11
12
new Watcher(
vm,
updateComponent, // 渲染模版更新DOM的方法
noop, // 没有回调,空函数
{
before () {
// 数据更新之前执行beforeUpdate生命周期
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)

计算属性Watcher实例化

1
2
3
4
5
6
watchers[key] = new Watcher(
vm,
getter || noop, // getter是我们写的computed函数或者是自定义的get函数
noop, // 空函数
computedWatcherOptions // { lazy: true } 标示自己是计算属性Watcher
)

用户自定义Watcher实例化

1
2
3
4
5
6
const watcher = new Watcher(
vm,
expOrFn, // watch key值
cb, // 用户写的函数
options // depp,immediate之类的
)

在Watcher类定义的地方,构造函数内有这么一段逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn) // parsePath是一个工具函数,返回一个访问vm.expOrFn的执行函数
if (!this.getter) {
this.getter = noop
}
}
this.value = this.lazy
? undefined
: this.get();

// get 函数内部
value = this.getter.call(vm, vm)

意味着expOrFn参数是否为函数,都会在Watcher实例化的时候执行。所以可以解释各个依赖都是在哪些地方第一次被收集的。

  1. 渲染Watcher:updateComponent作为expOrFn参数传入的,也就是进行模版渲染的过程中有地方访问到了写在模版里的数据,更详细的讲是_render方法中,生成的render渲染函数fuction(){width(){}}的width语句中访问到了相应数据,该数据就会收集到渲染Watcher。
  2. 计算属性Watcher:执行我们写入的computed函数,访问到了相关数据,相关数据收集计算属性Watcher。
  3. 用户自定义Watcher:expOrFn传入的是我们写的watch key,在执行this.getter的时候会访问到vm.[key],所以是在被定义的时候就被对应的数据收集了。

3.3 更新视图

无论是在哪收集到的watcher,数据更新的时候最终的目的还是要更新视图。

比如我们data里的数据被更新了,会触发set方法,其主要干了以下几件事:

  1. 判断数据更改前后是否一致,如果数据相等则不进行任何派发更新操作。
  2. 新值为对象时,会对该值的属性进行依赖收集过程
  3. 通知该数据收集的watcher依赖,遍历每个watcher进行数据更新(调用dep.notify方法进行更新的派发,该方法内通过调用watcher类的update方法进行更新数据操作)
  4. 更新时将每个watcher推到队列中,等待下一个tick(事件循环)到来时取出每个watcher进行run操作。

update函数执行的过程很复杂,其内部会执行Vue自定义的nextTick函数,nextTick会缓冲多个数据处理过程,等到下一个事件循环tick中再去执行DOM操作,原理是利用事件循环的微任务队列实现异步更新。当tick到来时,还会对各个依赖进行排序,因为依赖有优先级关系(自定义watcher优先于渲染watcher),组件也有父子关系(父的渲染watcher优先于子的渲染watcher更新)。

对于渲染Watcher来说,run函数就是更新DOM的地方,也是执行我们执行实例化Watcher时传入的expOrFn参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
run () {
if (this.active) {
const value = this.get() // 重新求值
if (
value !== this.value ||
isObject(value) ||
this.deep
) {
// set new value
const oldValue = this.value
this.value = value
if (this.user) {
try {
this.cb.call(this.vm, value, oldValue)
} catch (e) {
handleError(e, this.vm, `callback for watcher "${this.expression}"`)
}
} else {
this.cb.call(this.vm, value, oldValue)
}
}
}
}

根据依赖收集的地方我们得知,thi.get内会执行this.getter,而此时的getter就是updateComponent方法。

3.4 思考

为什么Vue官方文档中提到以下几种方式的赋值是构不成响应式数据的呢?又为什么通过数组的splice、push、shift等方法,或者通过this.$set的方法进行赋值就能构成响应式数据了呢?

在Vue2.x的文档中有提到如果给直接修改数组的length属性,或者利用索引修改数组长度时,对象属性的添加或者删除,Vue不能检测到变动。这些都是与Object.defineProperty的特新有关。

  1. 首先Object.defineProperty的get,set方法只能检测到对象属性的变化,对数组的变化无能为力。之所以能通过数组方法进行更改,是因为Vue在保留原数组功能的前提下,重新定义了数组部分方法(主要是增删改查的方法,push,pop,shift,unshift,splice,sort,reverse)。在src/core/observer/array.js里放这数组改写的相关代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    const arrayProto = Array.prototype;
    export const arrayMethods = Object.create(arrayProto);

    const methodsToPatch = [
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
    ]

    /**
    * Intercept mutating methods and emit events
    */
    methodsToPatch.forEach(function (method) {
    // cache original method
    const original = arrayProto[method]
    // def函数用于快捷设置属性为不可枚举
    def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
    case 'push':
    case 'unshift':
    inserted = args
    break
    case 'splice':
    inserted = args.slice(2)
    break
    }
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    return result
    })
    })

    首先先会调用原始的数组方法进行运算,保证与原始数组类型的方法一致。之后取出ob(Observer实例),调用ob.dep.notiify()进行依赖的派发更新。通过inserted来标志数组是否增加了元素,如果增加的元素时候数组对象类型,则触发observeArray方法对每个元素进行依赖收集。

  2. Vue在对对象进行依赖收集的时候,会为对象的每个属性都进行收集依赖,而直接通过object.key添加的新属性并没有依赖收集的过程,因此当之后数据key发生改变时也不会进行依赖的更新。$set的方法定义在src/core/observer/index.js里。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    function set (target, key, val) {
    //target必须为非空对象
    if (isUndef(target) || isPrimitive(target)
    ) {
    warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target))));
    }
    // 数组场景,调用重写的splice方法,对新添加属性收集依赖。
    if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key);
    target.splice(key, 1, val);
    return val
    }
    // 新增对象的属性存在时,直接返回新属性,触发依赖收集
    if (key in target && !(key in Object.prototype)) {
    target[key] = val;
    return val
    }
    // 拿到目标源的Observer 实例
    var ob = (target).__ob__;
    // _isVue为Vue实例的标志
    if (target._isVue || (ob && ob.vmCount)) {
    warn(
    'Avoid adding reactive properties to a Vue instance or its root $data ' +
    'at runtime - declare it upfront in the data option.'
    );
    return val
    }
    // 目标源对象本身不是一个响应式对象,则不需要处理
    if (!ob) {
    target[key] = val;
    return val
    }
    // 手动调用defineReactive,为新属性设置getter,setter
    defineReactive(ob.value, key, val);
    ob.dep.notify();
    return val
    }

    主要做了以下几点事。

    • 目标对象必须为非空的对象,可以是数组,否者抛出异常
    • 如果目标对象是数组时,调用数组的splice方法,进而调用ob.observeArray(inserted)对数组新增的元素收集依赖
    • 新增的属性值在原对象已经存在,则手动的访问该属性值,此操作会触发依赖收集
    • 新的属性值在原对象不存在时,手动定义新属性的getter,setter方法,并通过notify触发依赖更新。

如果你觉得以上知识点还不过瘾~附赠本人学习时画的思维导图,希望对你有帮助^_^

vue源码解析思维导图.xmind

参考资料: 深入剖析Vue源码

参考资料: Vue.js技术揭秘

我想吃鸡腿!