# vue源码阅读笔记

# 数据驱动

Vue.js 一个核心思想是数据驱动。所谓数据驱动,是指视图是由数据驱动生成的,我们对视图的修改,不会直接操作 DOM,而是通过修改数据。

数据驱动还有一部分是数据更新驱动视图变化,这一块内容我们也会在之后的章节分析,这一章我们的目标是弄清楚模板和数据如何渲染成最终的 DOM。

# new Vue 发生了什么?

Vue 实际上就是一个用 Function 实现的类,我们只能通过 new Vue 去实例化它。

Vue 只能通过 new 关键字初始化,然后会调用 this._init 方法

vm._self = vm
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')

// ...

if (vm.$options.el) {
  vm.$mount(vm.$options.el)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Vue 初始化主要就干了几件事情,合并配置,初始化生命周期,初始化事件中心,初始化渲染,初始化 data、props、computed、watcher 等等

其中,在 initState 中会判断 $options 的配置来初始化 props,methods,data,computed,watch 等,并会通过不同的方法挂载在 vm 上,例如: data 就会通过 proxy(vm,_data, key) ,将 data 对象挂载在 vm._data.xxx 上,本质上访问 this.xxx (这里的 xxx 指 data 里面的属性)就是访问 this._data.xxx,挂载后会通过 observe(data, true /* asRootData */) 对 data 进行响应式处理。

在初始化的最后,检测到如果有 el 属性,则调用 vm.$mount 方法挂载 vm,挂载的目标就是把模板渲染成最终的 DOM,那么接下来我们来分析 Vue 的挂载过程。

# Vue 实例挂载的实现

代码首先缓存了原型上的 $mount 方法,再重新定义该方法,我们先来分析这段代码。首先,它对 el 做了限制,Vue 不能挂载在 body、html 这样的根节点上。接下来的是很关键的逻辑 —— 如果没有定义 render 方法,则会把 el 或者 template 字符串转换成 render 方法。这里我们要牢记,在 Vue 2.0 版本中,所有 Vue 的组件的渲染最终都需要 render 方法,无论我们是用单文件 .vue 方式开发组件,还是写了 el 或者 template 属性,最终都会转换成 render 方法,那么这个过程是 Vue 的一个“在线编译”的过程,它是调用 compileToFunctions 方法实现的。最后,调用原先原型上的 $mount 方法挂载。

// public mount method
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}
1
2
3
4
5
6
7
8

$mount 方法实际上会去调用 mountComponent 方法,mountComponent 核心就是先实例化一个渲染 Watcher,在它的回调函数中会调用 updateComponent 方法,在此方法中调用 vm._render 方法先生成虚拟 Node,最终调用 vm._update 更新 DOM。

Watcher 在这里起到两个作用,一个是初始化的时候会执行回调函数,另一个是当 vm 实例中的监测的数据发生变化的时候执行回调函数,这块儿我们会在之后的章节中介绍。

# render

Vue 的 _render 方法是实例的一个私有方法,它用来把实例渲染成一个虚拟 Node。

我们在平时的开发工作中手写 render 方法的场景比较少,而写的比较多的是 template 模板,在之前的 mounted 方法的实现中,会把 template 编译成 render 方法。

vm._render 最终是通过执行 createElement 方法并返回的是 vnode,它是一个虚拟 Node。

# Virtual DOM

其实 VNode 是对真实 DOM 的一种抽象描述,它的核心定义无非就几个关键属性,标签名、数据、子节点、键值等,其它属性都是用来扩展 VNode 的灵活性以及实现一些特殊 feature 的。由于 VNode 只是用来映射到真实 DOM 的渲染,不需要包含操作 DOM 的方法,因此它是非常轻量和简单的。

Virtual DOM 除了它的数据结构的定义,映射到真实的 DOM 实际上要经历 VNode 的 create、diff、patch 等过程。那么在 Vue.js 中,VNode 的 create 是通过之前提到的 createElement 方法创建的,我们接下来分析这部分的实现。

这里可以回答一个问题,vue 初始化的时候为什么不能把根元素挂在在 body 或者 html 上?

 if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
    )
    return this
  }
1
2
3
4
5
6

其实是因为,挂在的根元素会给 Virtual DOM 重新生成的 DOM 全部替换掉了,我们不能把整个 html 或者 body 给替换掉,不然就不是 html 了。你可以这样测试:

import app = new Vue({
  el: '#app',
  render(createElement) {
	return createElement('div', {
	  attrs: {
		id: '#app1'
	  }
	}, this.message)
  },
  data() {
	return {
	  message: 'Hellow Vue!'
	}
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# createElement

Vue.js 利用 createElement 方法创建 VNode,createElement 方法实际上是对 _createElement 方法的封装,它允许传入的参数更加灵活,在处理这些参数后,调用真正创建 VNode 的函数 _createElement。

createElement 函数的流程略微有点多,主要有 2 个重点的流程 —— children 的规范化以及 VNode 的创建。children 的规范化将children 变成了一个类型为 VNode 的 Arr,规范化 children 后,接下来会去根据不同情况创建一个 VNode 的实例。

每个 VNode 有 children,children 每个元素也是一个 VNode,这样就形成了一个 VNode Tree,它很好的描述了我们的 DOM Tree。

回到 mountComponent 函数的过程,我们已经知道 vm._render 是如何创建了一个 VNode,接下来就是要把这个 VNode 渲染成一个真实的 DOM 并渲染出来,这个过程是通过 vm._update 完成的,接下来分析一下这个过程。

# update

Vue 的 _update 是实例的一个私有方法,它被调用的时机有 2 个,一个是首次渲染,一个是数据更新的时候;_update 方法的作用是把 VNode 渲染成真实的 DOM 。

_update 的核心就是调用 vm.patch 方法,这个方法实际上在不同的平台,比如 web 和 weex 上的定义是不一样的,至在 web 平台上,是否是服务端渲染也会对这个方法产生影响。

Vue.prototype.__patch__ = inBrowser ? patch : noop
1

patch 方法的定义是调用 createPatchFunction 方法的返回值,createPatchFunction 内部定义了一系列的辅助方法,最终返回了一个 patch 方法,这个方法就赋值给了 vm._update 函数里调用的 vm.patch

例子:

var app = new Vue({
  el: '#app',
  render: function (createElement) {
    return createElement('div', {
      attrs: {
        id: 'app'
      },
    }, this.message)
  },
  data: {
    message: 'Hello Vue!'
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13

我们在 vm._update 的方法里是这么调用 patch 方法的:

// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
1
2

结合我们的例子,我们的场景是首次渲染,所以在执行 patch 函数的时候,传入的 vm.$el 对应的是例子中 id 为 app 的 DOM 对象,这个也就是我们在 index.html 模板中写的

, vm.$el 的赋值是在之前 mountComponent 函数做的,vnode 对应的是调用 render 函数的返回值,hydrating 在非服务端渲染情况下为 false,removeOnly 为 false。

确定了这些入参后,我们回到 patch 函数的执行过程,看几个关键步骤。

由于我们传入的 oldVnode 实际上是一个 DOM container,所以 isRealElement 为 true,接下来又通过 emptyNodeAt 方法把 oldVnode 转换成 VNode 对象,然后再调用 createElm 方法。createElm 的作用是通过虚拟节点创建真实的 DOM 并插入到它的父节点中(递归创建)。

# 总结

那么至此我们从主线上把模板和数据如何渲染成最终的 DOM 的过程分析完毕了,我们可以通过下图更直观地看到从初始化 Vue 到最终渲染的整个过程。

new-vue

我们这里只是分析了最简单和最基础的场景,在实际项目中,我们是把页面拆成很多组件的,Vue 另一个核心思想就是组件化。那么下一章我们就来分析 Vue 的组件化过程。

# 组件化

Vue.js 另一个核心思想是组件化。所谓组件化,就是把页面拆分成多个组件 (component),每个组件依赖的 CSS、JavaScript、模板、图片等资源放在一起开发和维护。组件是资源独立的,组件在系统内部可复用,组件和组件之间可以嵌套。

那么在这一章节,我们将从源码的角度来分析 Vue 的组件内部是如何工作的,只有了解了内部的工作原理,才能让我们使用它的时候更加得心应手。

接下来我们会用 Vue-cli 初始化的代码为例,来分析一下 Vue 组件初始化的一个过程。

# createComponent

我们分析了 createComponent 的实现,了解到它在渲染一个组件的时候的 3 个关键逻辑:构造子类构造函数,安装组件钩子函数和实例化 vnode。createComponent 后返回的是组件 vnode,它也一样走到 vm._update 方法,进而执行了 patch 函数,我们在上一章对 patch 函数做了简单的分析,那么下一节我们会对它做进一步的分析。

# patch

当我们通过 createComponent 创建了组件 VNode,接下来会走到 vm._update,执行 vm.patch 去把 VNode 转换成真正的 DOM 节点。这个过程我们在前一章已经分析过了,但是针对一个普通的 VNode 节点,接下来我们来看看组件的 VNode 会有哪些不一样的地方。

  • patch 的整体流程: createComponent -> 子组件初始化 -> 子组件 render -> 子组件 patch

  • activeInstance 为当前激活的 vm 实例;vm.$vnode 为组件的占位 vnode; vm._vnode 为组件的渲染 vnode

  • 嵌套组件的插入顺序是先子后父,对应 vue 的生命周期也是子组件先 mounted

那么到此,一个组件的 VNode 是如何创建、初始化、渲染的过程也就介绍完毕了。在对组件化的实现有一个大概了解后,接下来我们来介绍一下这其中的一些细节。我们知道编写一个组件实际上是编写一个 JavaScript 对象,对象的描述就是各种配置,之前我们提到在 _init 的最初阶段执行的就是 merge options 的逻辑,那么下一节我们从源码角度来分析合并配置的过程。

# 合并配置

通过之前章节的源码分析我们知道,new Vue 的过程通常有 2 种场景,一种是外部我们的代码主动调用 new Vue(options) 的方式实例化一个 Vue 对象;另一种是我们上一节分析的组件过程中内部通过 new Vue(options) 实例化子组件。

无论哪种场景,都会执行实例的 _init(options) 方法,它首先会执行一个 merge options 的逻辑。不同场景对于 options 的合并逻辑是不一样的,并且传入的 options 值也有非常大的不同。

  • 外部调用场景下合并配置是通过 mergeOption,并遵循一定的合并策略

  • 组件合并是通过 initInternalComponent,它的合并更快

  • 库、框架的设计几乎都是类似的,自身定义了一些默认配置,同时又可以在初始化阶段传入一些定义配置,然后去 merge 默认配置,来达到定制化不同需求的目的

那么至此,Vue 初始化阶段对于 options 的合并过程就介绍完了,我们需要知道对于 options 的合并有 2 种方式,子组件初始化过程通过 initInternalComponent 方式要比外部初始化 Vue 通过 mergeOptions 的过程要快,合并完的结果保留在 vm.$options 中。

# 生命周期

beforeCreate & created

beforeCreate 和 created 函数都是在实例化 Vue 的阶段,在 _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 函数中。

在执行 vm._render() 函数渲染 VNode 之前,执行了 beforeMount 钩子函数,在执行完 vm._update() 把 VNode patch 到真实 DOM 后,执行 mounted 钩子。

注意,这里对 mounted 钩子函数执行有一个判断逻辑,vm.$vnode 如果为 null,则表明这不是一次组件的初始化过程,而是我们通过外部 new Vue 初始化过程。那么对于组件,它的 mounted 时机在哪儿呢?

之前我们提到过,组件的 VNode patch 到 DOM 后,会执行 invokeInsertHook 函数,把 insertedVnodeQueue 里保存的钩子函数依次执行一遍,该函数会执行 insert 这个钩子函数,insert 钩子函数中会执行 callHook(componentInstance, 'mounted')

我们可以看到,每个子组件都是在这个钩子函数中执行 mounted 钩子函数,并且我们之前分析过,insertedVnodeQueue 的添加顺序是先子后父,所以对于同步渲染的子组件而言,mounted 钩子函数的执行顺序也是先子后父。

beforeUpdate & updated

顾名思义,beforeUpdate 和 updated 的钩子函数执行时机都应该是在数据更新的时候。

beforeUpdate 的执行时机是在渲染 Watcher 的 before 函数中,注意这里有个判断,也就是在组件已经 mounted 之后,才会去调用这个钩子函数。

update 的执行时机是在 flushSchedulerQueue 函数调用的时候,flushSchedulerQueue 函数我们之后会详细介绍,可以先大概了解一下,updatedQueue 是更新了的 wathcer 数组,那么在 callUpdatedHooks 函数中,它对这些数组做遍历,只有满足当前 watcher 为 vm._watcher 以及组件已经 mounted 这两个条件,才会执行 updated 钩子函数。

beforeDestroy & destroyed

beforeDestroy 和 destroyed 钩子函数的执行时机在组件销毁的阶段。

beforeDestroy 钩子函数的执行时机是在 $destroy 函数执行最开始的地方,接着执行了一系列的销毁动作,包括从 parent 的 $children 中删掉自身,删除 watcher,当前渲染的 VNode 执行销毁钩子函数等,执行完毕后再调用 destroy 钩子函数。

在 $destroy 的执行过程中,它又会执行 vm.patch(vm._vnode, null) 触发它子组件的销毁钩子函数,这样一层层的递归调用,所以 destroy 钩子函数执行顺序是先子后父,和 mounted 过程一样。

activated & deactivated

activated 和 deactivated 钩子函数是专门为 keep-alive 组件定制的钩子,我们会在介绍 keep-alive 组件的时候详细介绍。

总结

  • vue.js 的生命周期函数就是再初始化及数据更新过程各个阶段执行不同的钩子函数

  • 在 created 钩子函数中可以访问到数据,在 mounted 钩子函数中可以访问到 DOM,在 destroyed 钩子函数中可以做一些定时器销毁工作

# 组件注册

在 Vue.js 中,除了它内置的组件如 keep-alive、component、transition、transition-group 等,其它用户自定义组件在使用前必须注册。Vue.js 提供了 2 种组件的注册方式,全局注册和局部注册。

要注册一个全局组件,可以使用 Vue.component(tagName, options)。那么,Vue.component 函数是在什么时候定义的呢,它的定义过程发生在最开始初始化 Vue 的全局函数的时候。

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

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

通常组件库中的基础组件建议全局注册,而业务组件建议局部注册。

# 异步组件

Vue 的异步组件又 3 种异步组件的实现方式,并且看到高级异步组件的实现是非常巧妙的,它实现了 loading、resolve、reject、timeout 4 种状态。

异步组件实现的本质是 2 次渲染(通常是 2 次),除了 0 delay 的高级异步组件第一次直接渲染成 loading 组件外(总共三次渲染),其它都是第一次渲染生成一个注释节点,当异步获取组件成功后,再通过 forceRender 强制重新渲染,这样就能正确渲染出我们异步加载的组件了。

# 深入响应式原理

前面 2 章介绍的都是 Vue 怎么实现数据渲染和组件化的,主要讲的是初始化的过程,把原始的数据最终映射到 DOM 中,但并没有涉及到数据变化到 DOM 变化的部分。而 Vue 的数据驱动除了数据渲染 DOM 之外,还有一个很重要的体现就是数据的变更会触发 DOM 的变化。

那么 Vue 是如何在我们对数据修改后自动做这些事情呢,接下来我们将进入一些 Vue 响应式系统的底层的细节。

# 响应式对象

Vue.js 实现响应式的核心是利用了 ES5 的 Object.defineProperty,这也是为什么 Vue.js 不能兼容 IE8 及以下浏览器的原因。

Object.defineProperty

Object.defineProperty 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。

Object.defineProperty(obj, prop, descriptor)
1

obj 是要在其上定义属性的对象;prop 是要定义或修改的属性的名称;descriptor 是将被定义或修改的属性描述符。

比较核心的是 descriptor,它有很多可选键值,具体的可以去参阅它的文档。这里我们最关心的是 get 和 set,get 是一个给属性提供的 getter 方法,当我们访问了该属性的时候会触发 getter 方法;set 是一个给属性提供的 setter 方法,当我们对该属性做修改的时候会触发 setter 方法。

一旦对象拥有了 getter 和 setter,我们可以简单地把这个对象称为响应式对象。那么 Vue.js 把哪些对象变成了响应式对象了呢,接下来我们从源码层面分析。

initState

在 Vue 的初始化阶段,_init 方法执行的时候,会执行 initState(vm) 方法,initState 方法主要是对 props、methods、data、computed 和 wathcer 等属性做了初始化操作。这里我们重点分析 props 和 data,对于其它属性的初始化我们之后再详细分析。

props 的初始化主要过程,就是遍历定义的 props 配置。遍历的过程主要做两件事情:一个是调用 defineReactive 方法把每个 prop 对应的值变成响应式,可以通过 vm._props.xxx 访问到定义 props 中对应的属性。对于 defineReactive 方法,我们稍后会介绍;另一个是通过 proxy 把 vm._props.xxx 的访问代理到 vm.xxx 上,我们稍后也会介绍。

data 的初始化主要过程也是做两件事,一个是对定义 data 函数返回对象的遍历,通过 proxy 把每一个值 vm._data.xxx 都代理到 vm.xxx 上;另一个是调用 observe 方法观测整个 data 的变化,把 data 也变成响应式,可以通过 vm._data.xxx 访问到定义 data 返回函数中对应的属性,observe 我们稍后会介绍。

可以看到,无论是 props 或是 data 的初始化都是把它们变成响应式对象,这个过程我们接触到几个函数,接下来我们来详细分析它们。

proxy

首先介绍一下代理,代理的作用是把 props 和 data 上的属性代理到 vm 实例上,这也就是为什么比如我们定义了如下 props,却可以通过 vm 实例访问到它。

export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}
1
2
3
4
5
6
7
8
9

proxy 方法的实现很简单,通过 Object.definePropertytarget[sourceKey][key] 的读写变成了对 target[key] 的读写。

observe 和 Observer

observe 的功能就是用来监测数据的变化,observe 方法的作用就是给非 VNode 的对象类型数据添加一个 Observer,如果已经添加过则直接返回,否则在满足一定条件下去实例化一个 Observer 对象实例。

Observer 是一个类,它的作用是给对象的属性添加 getter 和 setter,用于依赖收集和派发更新:

bserver 的构造函数逻辑很简单,首先实例化 Dep 对象,这块稍后会介绍,接着通过执行 def 函数把自身实例添加到数据对象 value 的 ob 属性上,

def 函数是一个非常简单的 Object.defineProperty 的封装,这就是为什么我在开发中输出 data 上对象类型的数据,会发现该对象多了一个 ob 的属性。

回到 Observer 的构造函数,接下来会对 value 做判断,对于数组会调用 observeArray 方法,否则对纯对象调用 walk 方法。可以看到 observeArray 是遍历数组再次调用 observe 方法,而 walk 方法是遍历对象的 key 调用 defineReactive 方法,那么我们来看一下这个方法是做什么的。

defineReactive

defineReactive 的功能就是定义一个响应式对象,给对象动态添加 getter 和 setter,defineReactive 函数最开始初始化 Dep 对象的实例,接着拿到 obj 的属性描述符,然后对子对象递归调用 observe 方法,这样就保证了无论 obj 的结构多复杂,它的所有子属性也能变成响应式的对象,这样我们访问或修改 obj 中一个嵌套较深的属性,也能触发 getter 和 setter。最后利用 Object.defineProperty 去给 obj 的属性 key 添加 getter 和 setter。而关于 getter 和 setter 的具体实现,我们会在之后介绍。

总结

这一节我们介绍了响应式对象,核心就是利用 Object.defineProperty 给数据添加了 getter 和 setter,目的就是为了在我们访问数据以及写数据的时候能自动执行一些逻辑:getter 做的事情是依赖收集,setter 做的事情是派发更新,那么在接下来的内容我们会重点对这两个过程分析。

# 依赖收集

Vue 会把普通对象变成响应式对象,响应式对象 getter 相关的逻辑就是做依赖收集。

之前我们介绍当对数据对象的访问会触发他们的 getter 方法,那么这些对象什么时候被访问呢?

vm._update(vm._render(), hydrating)
1

先执行 vm._render() 方法,因为之前分析过这个方法会生成 渲染 VNode,并且在这个过程中会对 vm 上的数据访问,这个时候就触发了数据对象的 getter。

那么每个对象值的 getter 都持有一个 dep,在触发 getter 的时候会调用 dep.depend() 方法,也就会执行 Dep.target.addDep(this)。

这时候会做一些逻辑判断(保证同一数据不会被添加多次)后执行 dep.addSub(this),那么就会执行 this.subs.push(sub),也就是说把当前的 watcher 订阅到这个数据持有的 dep 的 subs 中,这个目的是为后续数据变化时候能通知到哪些 subs 做准备。

所以在 vm._render() 过程中,会触发所有数据的 getter,这样实际上已经完成了一个依赖收集的过程。那么到这里就结束了么,其实并没有,在完成依赖收集后,还有几个逻辑要执行。

考虑到 Vue 是数据驱动的,所以每次数据变化都会重新 render,那么 vm._render() 方法又会再次执行,并再次触发数据的 getters,所以 Watcher 在构造函数中会初始化 2 个 Dep 实例数组,newDeps 表示新添加的 Dep 实例数组,而 deps 表示上一次添加的 Dep 实例数组。

在执行 cleanupDeps 函数的时候,会首先遍历 deps,移除对 dep.subs 数组中 Wathcer 的订阅,然后把 newDepIds 和 depIds 交换,newDeps 和 deps 交换,并把 newDepIds 和 newDeps 清空。

那么为什么需要做 deps 订阅的移除呢,在添加 deps 的订阅过程,已经能通过 id 去重避免重复订阅了。

考虑到一种场景,我们的模板会根据 v-if 去渲染不同子模板 a 和 b,当我们满足某种条件的时候渲染 a 的时候,会访问到 a 中的数据,这时候我们对 a 使用的数据添加了 getter,做了依赖收集,那么当我们去修改 a 的数据的时候,理应通知到这些订阅者。那么如果我们一旦改变了条件渲染了 b 模板,又会对 b 使用的数据添加了 getter,如果我们没有依赖移除的过程,那么这时候我去修改 a 模板的数据,会通知 a 数据的订阅的回调,这显然是有浪费的。

因此 Vue 设计了在每次添加完新的订阅,会移除掉旧的订阅,这样就保证了在我们刚才的场景中,如果渲染 b 模板的时候去修改 a 模板的数据,a 数据订阅回调已经被移除了,所以不会有任何浪费,真的是非常赞叹 Vue 对一些细节上的处理。

总结

  • 依赖收集就是订阅数据变化的 watcher 的收集

  • 依赖收集的目的是为了当这些响应式数据发生变化,触发它们的 setter 的时候,能知道应该通知哪些订阅者去做响应的逻辑处理。

# 派发更新

通过上一节分析我们了解了响应式数据依赖收集过程,收集的目的就是为了当我们修改数据的时候,可以对相关的依赖派发更新。

当我们在组件中对响应的数据做了修改,就会触发 setter 的逻辑,最后调用 dep.notify() 方法, 它是 Dep 的一个实例方法。这里的逻辑非常简单,遍历所有的 subs,也就是 Watcher 的实例数组,然后调用每一个 watcher 的 update 方法。

这里对于 Watcher 的不同状态,会执行不同的逻辑,computed 和 sync 等状态的分析我会之后抽一小节详细介绍,在一般组件数据更新的场景,会走到最后一个 queueWatcher(this) 的逻辑。

这里引入了一个队列的概念,这也是 Vue 在做派发更新的时候的一个优化的点,它并不会每次数据改变都触发 watcher 的回调,而是把这些 watcher 先添加到一个队列里,然后在 nextTick 后执行 flushSchedulerQueue。

flushSchedulerQueue 中会对队列进行:队列排序、队列遍历和状态恢复。

对于渲染 watcher 而言,它在执行 this.get() 方法求值的时候,会执行 getter 方法。所以这就是当我们去修改组件相关的响应式数据的时候,会触发组件重新渲染的原因,接着就会重新执行 patch 的过程,但它和首次渲染有所不同。

Vue 数据修改派发更新的过程,实际上就是当数据发生变化的时候,触发 setter 逻辑,把在依赖过程中订阅的的所有观察者,也就是 watcher,都触发它们的 update 过程,这个过程又利用了队列做了进一步优化,在 nextTick 后执行所有 watcher 的 run,最后执行它们的回调函数。nextTick 是 Vue 一个比较核心的实现了,下面我们来重点分析它的实现。

# nextTick

Vue.js 提供了 2 种调用 nextTick 的方式,一种是全局 API Vue.nextTick,一种是实例上的方法 vm.$nextTick,无论我们使用哪一种,最后都是调用 next-tick.js 中实现的 nextTick 方法。

next-tick.js 申明了 microTimerFunc 和 macroTimerFunc 2 个变量,它们分别对应的是 micro task 的函数和 macro task 的函数。对于 macro task 的实现,优先检测是否支持原生 setImmediate,这是一个高版本 IE 和 Edge 才支持的特性,不支持的话再去检测是否支持原生的 MessageChannel,如果也不支持的话就会降级为 setTimeout 0;而对于 micro task 的实现,则检测浏览器是否原生支持 Promise,不支持的话直接指向 macro task 的实现。

next-tick.js 申明了 microTimerFunc 和 macroTimerFunc 2 个变量,它们分别对应的是 micro task 的函数和 macro task 的函数。对于 macro task 的实现,优先检测是否支持原生 setImmediate,这是一个高版本 IE 和 Edge 才支持的特性,不支持的话再去检测是否支持原生的 MessageChannel,如果也不支持的话就会降级为 setTimeout 0;而对于 micro task 的实现,则检测浏览器是否原生支持 Promise,不支持的话直接指向 macro task 的实现。

总结

  • nextTick 是把要执行的任务推入到一个队列中,在下一个 tick 同步执行

  • 数据改变后,触发渲染 watcher 的 update,但是 watchers 的 flush 是在 nextTick 后,所以重新渲染是异步的

  • 当 nextTick 不传 cb 参数的时候,提供一个 Promise 化的调用

# 检测变化的注意事项

对象添加属性

对于使用 Object.defineProperty 实现响应式的对象,当我们去给这个对象添加一个新的属性的时候,是不能够触发它的 setter 的。但是添加新属性的场景我们在平时开发中会经常遇到,那么 Vue 为了解决这个问题,定义了一个全局 API Vue.set 方法。

set 方法接收 3个参数,target 可能是数组或者是普通对象,key 代表的是数组的下标或者是对象的键值,val 代表添加的值。首先判断如果 target 是数组且 key 是一个合法的下标,则之前通过 splice 去添加进数组然后返回,这里的 splice 其实已经不仅仅是原生数组的 splice 了,稍后我会详细介绍数组的逻辑。接着又判断 key 已经存在于 target 中,则直接赋值返回,因为这样的变化是可以观测到了。接着再获取到 target.ob 并赋值给 ob,之前分析过它是在 Observer 的构造函数执行的时候初始化的,表示 Observer 的一个实例,如果它不存在,则说明 target 不是一个响应式的对象,则直接赋值并返回。最后通过 defineReactive(ob.value, key, val) 把新添加的属性变成响应式对象,然后再通过 ob.dep.notify() 手动的触发依赖通知。

在 getter 过程中判断了 childOb,并调用了 childOb.dep.depend() 收集了依赖,这就是为什么执行 Vue.set 的时候通过 ob.dep.notify() 能够通知到 watcher,从而让添加新的属性到对象也可以检测到变化。这里如果 value 是个数组,那么就通过 dependArray 把数组每个元素也去做依赖收集。

数组

Vue 也是不能检测到以下变动的数组:

1.当你利用索引直接设置一个项时,例如:vm.items[indexOfItem] = newValue

2.当你修改数组的长度时,例如:vm.items.length = newLength

对于第一种情况,可以使用:Vue.set(example1.items, indexOfItem, newValue);而对于第二种情况,可以使用 vm.items.splice(newLength)

我们刚才也分析到,对于 Vue.set 的实现,当 target 是数组的时候,也是通过 target.splice(key, 1, val) 来添加的,那么这里的 splice 到底有什么黑魔法,能让添加的对象变成响应式的呢。

其实之前我们也分析过,在通过 observe 方法去观察对象的时候会实例化 Observer,在它的构造函数中是专门对数组做了处理。

vue 中对数组中所有能改变数组自身的方法,如 push、pop 等这些方法进行重写。重写后的方法会先执行它们本身原有的逻辑,并对能增加数组长度的 3 个方法 push、unshift、splice 方法做了判断,获取到插入的值,然后把新添加的值变成一个响应式对象,并且再调用 ob.dep.notify() 手动触发依赖通知,这就很好地解释了之前的示例中调用 vm.items.splice(newLength) 方法可以检测到变化。

总结

  • 响应式数据中对于对象新增删除属性以及数组的下标访问修改和添加数据等的变化观测不到

  • 通过 Vue.set 以及数组的 API 可以解决这些问题,本质上是它们内部手动去做了依赖更新的派发

# 计算属性 VS 侦听属性

计算属性

计算属性的初始化是发生在 Vue 实例初始化阶段的 initState 函数中,执行了 if (opts.computed) initComputed(vm, opts.computed)

计算属性本质上就是一个 computed watcher,也了解了它的创建过程和被访问触发 getter 以及依赖更新的过程,其实这是最新的计算属性的实现,之所以这么设计是因为 Vue 想确保不仅仅是计算属性依赖的值发生变化,而是当计算属性最终计算的值发生变化才会触发渲染 watcher 重新渲染,本质上是一种优化。

computed 本质是一个惰性求值的观察者。

computed 内部实现了一个惰性的 watcher,也就是 computed watcher,computed watcher 不会立刻求值,同时持有一个 dep 实例。

其内部通过 this.dirty 属性标记计算属性是否需要重新求值。

当 computed 的依赖状态发生改变时,就会通知这个惰性的 watcher

computed watcher 通过 this.dep.subs.length 判断有没有订阅者

有的话,会重新计算,然后对比新旧值,如果变化了,会重新渲染。 (Vue 想确保不仅仅是计算属性依赖的值发生变化,而是当计算属性最终计算的值发生变化时才会触发渲染 watcher 重新渲染,本质上是一种优化。)

没有的话,仅仅把 this.dirty = true。 (当计算属性依赖于其他数据时,属性并不会立即重新计算,只有之后其他地方需要读取属性的时候,它才会真正计算,即具备 lazy(懒计算)特性。)

侦听属性

侦听属性的初始化也是发生在 Vue 的实例初始化阶段的 initState 函数中,在 computed 初始化之后,执行了:

if (opts.watch && opts.watch !== nativeWatch) {
  initWatch(vm, opts.watch)
}
1
2
3

watch 的初始化在 data 初始化之后(此时的 data 已经通过 Object.defineProperty 的设置成响应式)

watch 的 key 会在 Watcher 里进行值的读取,也就是立马执行 get 获取 value (从而实现 data 对应的 key 执行 getter 实现对于 watch 的依赖收集),此时如果有 immediate 属性那么立马执行 watch 对应的 回调函数

当 data 对应的 key 发生变化时,触发 user watch 实现 watch 回调函数的执行

总结

  • 计算属性本质上是 computed watcher

  • 而侦听属性本质上是 user watcher,它还支持 deep、sync、immediate 等配置

  • 就应用场景而言,计算属性适合用在模板渲染中,某个值是依赖了其它的响应式对象甚至是计算属性计算而来;而侦听属性适用于观测某个值的变化去完成一段复杂的业务逻辑。

同时我们又了解了 watcher 的 4 个 options,通常我们会在创建 user watcher 的时候配置 deep 和 sync,可以根据不同的场景做相应的配置:

  • deep watch(深层次监听)
  • user watch(用户监听)
  • computed watcher(计算属性)
  • sync watcher(同步监听)

# 组件更新

当数据发生变化的时候,会触发渲染 watcher 的回调函数,进而执行组件的更新过程。

组件的更新还是调用了 vm._update 方法,更新的过程中,会执行 vm.$el = vm.__patch__(prevVnode, vnode),它仍然会调用 patch 函数。

这里执行 patch 的逻辑和首次渲染是不一样的,因为 oldVnode 不为空,并且它和 vnode 都是 VNode 类型,接下来会通过 sameVNode(oldVnode, vnode) 判断它们是否是相同的 VNode 来决定走不同的更新逻辑:

function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

sameVnode 的逻辑非常简单,如果两个 vnode 的 key 不相等,则是不同的;否则继续判断对于同步组件,则判断 isComment、data、input 类型等是否相同,对于异步组件,则判断 asyncFactory 是否相同。

所以根据新旧 vnode 是否为 sameVnode,会走到不同的更新逻辑:

新旧节点不同

如果新旧 vnode 不同,那么更新的逻辑非常简单,它本质上是要替换已存在的节点,大致分为 3 步:

  • 创建新节点

    • 以当前旧节点为参考节点,创建新的节点,并插入到 DOM 中,同过 createElm 函数
  • 更新父的占位符节点

    • 到当前 vnode 的父的占位符节点,先执行各个 module 的 destroy 的钩子函数,如果当前占位符是一个可挂载的节点,则执行 module 的 create 钩子函数
  • 删除旧节点

    • 把 oldVnode 从当前 DOM 树中删除,如果父节点存在,则执行 removeVnodes 方法

新旧节点相同

新旧节点相同,它会调用 patchVNode 方法,patchVnode 的作用就是把新的 vnode patch 到旧的 vnode 上,这里我们只关注关键的核心逻辑,我把它拆成四步骤:

  • 执行 prepatch 钩子函数

    • 当更新的 vnode 是一个组件 vnode 的时候,会执行 prepatch 的方法
    • prepatch 方法就是拿到新的 vnode 的组件配置以及组件实例,去执行 updateChildComponent 方法
    • updateChildComponent 的逻辑也非常简单,由于更新了 vnode,那么 vnode 对应的实例 vm 的一系列属性也会发生变化,包括占位符 vm.$vnode 的更新、slot 的更新,listeners 的更新,props 的更新等等
  • 执行 update 钩子函数

    • 在执行完新的 vnode 的 prepatch 钩子函数,会执行所有 module 的 update 钩子函数以及用户自定义 update 钩子函数
  • 完成 patch 过程

    • 如果 vnode 是个文本节点且新旧文本不相同,则直接替换文本内容。如果不是文本节点,则判断它们的子节点,
  • 执行 postpatch 钩子函数

    • 再执行完 patch 过程后,会执行 postpatch 钩子函数,它是组件自定义的钩子函数,有则执行。

总结

  • 组件更新的过程核心就是新旧 vnode diff,对新旧节点相同以及不同的情况分别做不同的处理。

  • 新旧节点不同的更新流程是创建新节点->更新父占位符节点->删除旧节点;

  • 新旧节点相同的更新流程是去获取它们的 children,根据不同情况做不同的更新逻辑。

  • 最复杂的情况是新旧节点相同且它们都存在子节点,那么会执行 updateChildren 逻辑,这块儿可以借助画图的方式配合理解。

# 原理图

vue 的响应式原理核心就是去观测数据的变化,当我们这些数据发生变化之后,通知我们对应的观察者来实行相关的逻辑。整个响应式最核心的就是 Dep 的实现,它实际上是连接数据和数据对应观察者的桥梁。

  1. 在 vue 的初始化阶段,会对 vue 上定义的数据分别做一些不同的处理

  2. 对于 data 和 props 会通过 observe 或 defineReactive 做一系列的操作,把 props 和 data 定义的对象和每一个属性都变成响应式的,同时它们内部会持有一个 Dep 的实例,当我们访问到这些属性的时候,就会触发 Dep 的 depend() 方法来收集依赖。收集的依赖是当前正在进行计算的 watcher (Dep.target),这些依赖就会做为当前的订阅者,来订阅这些数据的变化,然后当我们去修改这些数据的时候,就会通过调用 Dep.notify() 方法来通知我们这些订阅者去做 updata 的逻辑

  3. 对于 computed 属性而言,它实际上内部会创建一个 computed watcher 这样特殊的 watcher 。每个 computed watcher 会持有一个 Dep 实例,当我们去访问 computed 属性的时候,会调用我们 computed watcher 的 evaluate() 计算方法,这个时候就会触发内部 Dep 的 depend 方法去收集依赖,和 data/props 一样会收集当时正在计算的 watcher,把收集到的 watcher 做为 Dep 的 subs(订阅者)收集起来。收集起来的作用就是当我们的计算属性依赖的值发生了变化,就会触发 computed watcher 进行重新计算,重新计算的结果如果变了,就会调用 Dep.notify 通知订阅 computed 变化的这些订阅者来触发它们的更新。

  4. 对于 watch 而言,实际上会创建一个 user watcher,user watcher 实际上可以理解为用户自定义的 watcher,它可以观测 data,props 的变化,也可以观测 computed 的变化。当我们的观测的属性发生变化的时候,就会通知 Dep 去遍历我们所有的 user watcher,去调用它们的 updata 方法,当发现新旧值发生变化就会调用 run 方法,来执行用户定义的回调方法。

  5. 对于 vue 的渲染和重新渲染就是基于响应式系统的。在 vue 的创建过程中,对于每一个组件而言,都会执行组件的 mount 方法,mount 过程中内部会创建一个唯一的 render watcher, 当执行 render 渲染的时候,也就是去渲染成 vnode 的过程中,会访问 props,data 等定义的数据或 computed 等等。然后 render watcher 就会作为订阅者订阅了那些渲染需要的数据和计算属性的变化。一旦数据发生变化,就会触发 data,props 或 computed 的 notify 方法,然后就会触发 render watcher 的 update 方法,就会执行它们的 run 方法,在执行 run 方法的过程中,实际上最后会调用 updateComponent 方法重新去做一次渲染。

一句话总结:

vue.js 采用数据劫持结合发布-订阅模式,通过 Object.defineproperty 来劫持各个属性的 setter,getter,在数据变动时发布消息给订阅者,触发响应的监听回调

# 编译

之前我们分析过模板到真实 DOM 渲染的过程,中间有一个环节是把模板编译成 render 函数,这个过程我们把它称作编译。

虽然我们可以直接为组件编写 render 函数,但是编写 template 模板更加直观,也更符合我们的开发习惯。

Vue.js 提供了 2 个版本,一个是 Runtime + Compiler 的,一个是 Runtime only 的,前者是包含编译代码的,可以把编译过程放在运行时做,后者是不包含编译代码的,需要借助 webpack 的 vue-loader 事先把模板编译成 render函数。

,对编译过程的了解会让我们对 Vue 的指令、内置组件等有更好的理解。不过由于编译的过程是一个相对复杂的过程,我们只要求理解整体的流程、输入和输出即可,对于细节我们不必抠太细。

# 编译入口

compileToFunctions 方法就是把模板 template 编译生成 render 以及 staticRenderFns。

compileToFunctions 方法接收 3 个参数、编译模板 template,编译配置 options 和 Vue 实例 vm。核心的编译过程就一行代码:

const compiled = compile(template, options)
1

编译的入口我们终于找到了,它主要就是执行了如下几个逻辑:

  • 解析模板字符串生成 AST

    • const ast = parse(template.trim(), options)
  • 优化语法树

    • optimize(ast, options)
  • 生成代码

    • const code = generate(ast, options)

因为 Vue.js 在不同的平台下都会有编译的过程,因此编译过程中的依赖的配置 baseOptions 会有所不同。而编译过程会多次执行,但这同一个平台下每一次的编译过程配置又是相同的,为了不让这些配置在每次编译过程都通过参数传入,Vue.js 利用了函数柯里化的技巧很好的实现了 baseOptions 的参数保留。同样,Vue.js 也是利用函数柯里化技巧把基础的编译过程函数抽出来,通过 createCompilerCreator(baseCompile) 的方式把真正编译的过程和其它逻辑如对编译配置处理、缓存处理等剥离开,这样的设计还是非常巧妙的。

# parse

编译过程首先就是对模板做解析,生成 AST,它是一种抽象语法树,是对源代码的抽象语法结构的树状表现形式。在很多编译技术中,如 babel 编译 ES6 的代码都会先生成 AST。

parse 的目标是把 template 模板字符串转换成 AST 树,它是一种用 JavaScript 对象的形式来描述整个模板。那么整个 parse 的过程是利用正则表达式顺序解析模板,当解析到开始标签、闭合标签、文本的时候都会分别执行对应的回调函数,来达到构造 AST 树的目的。

AST 元素节点总共有 3 种类型,type 为 1 表示是普通元素,为 2 表示是表达式,为 3 表示是纯文本。其实这里我觉得源码写的不够友好,这种是典型的魔术数字,如果转换成用常量表达会更利于源码阅读。

当 AST 树构造完毕,下一步就是 optimize 优化这颗树。

# optimize

当我们的模板 template 经过 parse 过程后,会输出生成 AST 树,那么接下来我们需要对这颗树做优化,optimize 的逻辑是远简单于 parse 的逻辑,所以理解起来会轻松很多。

为什么要有优化过程,因为我们知道 Vue 是数据驱动,是响应式的,但是我们的模板并不是所有数据都是响应式的,也有很多数据是首次渲染后就永远不会变化的,那么这部分数据生成的 DOM 也不会变化,我们可以在 patch 的过程跳过对他们的比对。

optimize 的过程,就是深度遍历这个 AST 树,去检测它的每一颗子树是不是静态节点,如果是静态节点则它们生成 DOM 永远不需要改变,这对运行时对模板的更新起到极大的优化作用。

我们通过 optimize 我们把整个 AST 树中的每一个 AST 元素节点标记了 static(静态节点) 和 staticRoot(静态根),它会影响我们接下来执行代码生成的过程。

# codegen

编译的最后一步就是把优化后的 AST 树转换成可执行的代码,利用 generate 函数生成 render 函数字符串:

const code = generate(ast, options)
1

# 扩展

# event

  • event 在编译阶段生成相关的 data ,对于 DOM 事件在 patch 过程中的创建阶段和更新阶段执行 updateDOMListeners 生成 DOM 事件;对于自定义事件,会在组件初始化阶段通过 initEvents 创建

  • 原生 DOM 事件和自定义事件的主要的区别在于添加和删除事件的方式不一样,并且自定义事件的派发是往当前实例上派发,但可以利用在父组件环境定义回调函数来实现父子组件的通讯

  • 只有组件节点才可以添加自定义事件,并且添加原生 DOM 事件需要使用 native 修饰符;而普通元素使用 .native 修饰符是没有作用的,也只能添加原生 DOM 事件。

# v-model

很多同学在理解 Vue 的时候都把 Vue 的数据响应原理理解为双向绑定,但实际上这是不准确的,我们之前提到的数据响应,都是通过数据的改变去驱动 DOM 视图的变化,而双向绑定除了数据驱动 DOM 外, DOM 的变化反过来影响数据,是一个双向关系,在 Vue 中,我们可以通过 v-model 来实现双向绑定。

v-model 是 Vue 双向绑定的真正实现,但本质上就是一种语法糖,但是运行时也做了一些优化。它即可以支持原生表单元素,也可以支持自定义组件。在组件的实现中,我们是可以配置子组件接收的 prop 名称,以及派发的事件名称。

# slot

插槽分为普通插槽和作用域插槽,它们可以解决不同的场景。

普通插槽和作用域插槽的实现,它们有一个很大的差别是数据作用域:

  • 普通插槽是在父组件编译和渲染阶段生成 vnodes,所以数据的作用域是父组件实例,子组件渲染的时候直接拿到这些渲染好的 vnodes。

  • 而对于作用域插槽,父组件在编译和渲染阶段并不会直接生成 vnodes,而是在父节点 vnode 的 data 中保留一个 scopedSlots 对象,存储着不同名称的插槽以及它们对应的渲染函数,只有在编译和渲染子组件阶段才会执行这个渲染函数生成 vnodes,由于是在子组件环境执行的,所以对应的数据作用域是子组件实例。

简单地说,两种插槽的目的都是让子组件 slot 占位符生成的内容由父组件来决定,但数据的作用域会根据它们 vnodes 渲染时机不同而不同。

# keep-alive

  • 组件是一个抽象组件,它的实现通过自定义 render 函数并且利用了插槽
  • 会缓存 vnode。缓存渲染的时候不会再执行 created 钩子函数,在 patch 过程也不会执行 mounted 钩子函数,销毁时也不会执行 destroyed 钩子函数,所以不会有一般的组件的生命周期函数
  • 的 props 除了 include 和 exclude 还有文档中没有提到的 max,它能控制我们缓存的个数。

# transition

Vue.js 除了实现了强大的数据驱动,组件化的能力,也给我们提供了一整套过渡的解决方案。它内置了

  • 条件渲染 (使用 v-if)
  • 条件展示 (使用 v-show)
  • 动态组件
  • 组件根节点

总结起来,Vue 的过渡实现分为以下几个步骤:

  1. 自动嗅探目标元素是否应用了 CSS 过渡或动画,如果是,在恰当的时机添加/删除 CSS 类名。

  2. 如果过渡组件提供了 JavaScript 钩子函数,这些钩子函数将在恰当的时机被调用。

  3. 如果没有找到 JavaScript 钩子并且也没有检测到 CSS 过渡/动画,DOM 操作 (插入/删除) 在下一帧中立即执行。

所以真正执行动画的是我们写的 CSS 或者是 JavaScript 钩子函数,而 Vue 的 <transition> 只是帮我们很好地管理了这些 CSS 的添加/删除,以及钩子函数的执行时机。

# transition-group

只能针对单一元素实现过渡效果。我们做前端开发经常会遇到列表的需求,我们对列表元素进行添加和删除,有时候也希望有过渡效果,Vue.js 提供了 <transition-group> 组件,很好地帮助我们实现了列表的过渡效果。

<transtion-group> 实现了列表的过渡,以及它会渲染成真实的元素。

当我们去修改列表的数据的时候,如果是添加或者删除数据,则会触发相应元素本身的过渡动画,这点和 <transition> 组件实现效果一样,除此之外 <transtion-group> 还实现了 move 的过渡效果,让我们的列表过渡动画更加丰富。