研究 runtime
一边 Vue
一边源码
初看 Vue 是 Vue
源码是源码
再看 Vue 不是 Vue
源码不是源码
再再看
Vue 是调用栈
源码也是调用栈
—— By DOM哥
Vue 运行时这一块是非常有意思的,不像 Vue 编译器那么枯燥,这里面有大量的实用技巧和设计思想可以学习。使用过 Vue 的小伙伴应该对 Vue 【响应的数据绑定】(也叫双向绑定)的印象非常深刻,在修改了数据之后,视图就会实时得到相应更新,这无疑极大地减轻了开发者的负担,使得开发人员可以专注于处理业务逻辑和操作数据,也就是闻名遐迩的【数据驱动开发】。至于操作 DOM 更新视图这件苦脏累的活,Vue 已经帮你妥善处理完毕并且对你完全透明(意思是它就像空气一样你完全注意不到它,却又深度依赖它,离不开它)。
Vue 运行时模块主要是围绕 Vue 实例的生命周期展开的,它涵盖了 Vue 实例生命周期内所需要的全部设施,包括实例创建,响应的数据绑定,挂载到 DOM 节点以及数据变化时自动更新视图等关键部分。本篇也将沿着 Vue 实例的生命周期路线,结合运行时关键实现伪代码,一步步清晰地描绘出 Vue 运行时的空中鸟瞰图。
Vue 实例的生命周期
本段的部分内容参考自 Vue 官网的生命周期描述。
就像每个人的生命周期有 幼年、童年、少年、青年、中年、老年,每个 Vue 实例的生命周期也有 beforeCreate、created、beforeMount、mounted、beforeUpdate、updated、activated、deactivated、beforeDestroy、destroyed 等多个阶段。
Vue 实例生命周期代码示例:
<div id="index">{{msg}}</div>
new Vue({
el: '#index',
data: {
msg: 'lifecycle',
},
beforeCreate(){ console.log('beforeCreate')},
created(){ console.log('created')},
beforeMount(){ console.log('beforeMount')},
mounted(){ console.log('mounted')},
})
// Console output:
// beforeCreate
// created
// beforeMount
// mounted
每个 Vue 实例在被创建时都要经过一系列的初始化过程,例如设置数据监听,编译 HTML 模板,将实例挂载到 DOM 等。在这个初始化的过程中会在特定的地方运行一些叫做【生命周期钩子】的函数,这些钩子其实就是开发者可以自定义的回调函数,如上面传入的 created
函数就会在 Vue 实例 created 时被调用。
下面一张图可以非常清晰地说明 Vue 各个生命周期钩子的调用时机(图片来自 Vue 官网生命周期图示):
Vue 的生命周期图示
你不需要立马弄明白图上所有的东西,不过随着你的不断学习和使用,它的参考价值会越来越高。
实例创建
众所周知 Vue 是通过 new Vue()
的方式进行使用的,也就是说 Vue 内部将自己封装成了一个类。然而 Vue 并没有使用 ES6 最新的 class
方式进行实现,而是用了原来 prototype 那一套,这是让宝宝有些伤心的。闲话待会再叙,先看一下源码:
// vue/src/core/instance/index.js
function Vue (options) {
this._init(options)
}
Vue 将初始化工作全部放在了 Vue.prototype._init()
方法里。去伪存真,_init
方法主代码如下:
// vue/src/core/instance/init.js
Vue.prototype._init = function (options) {
const vm = this
vm.$options = mergeOptions(options || {})
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initState(vm)
callHook(vm, 'created')
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
initEvents
和 initRender
函数主要用来初始化 Vue 实例的一些容器字段,现在可暂时忽略它们。接下来重点来了,在 initState
函数中封装了实现【响应的数据绑定】的关键代码,虽然这不是 Vue 最流弊的部分,但却是咱对 Vue 最好奇的地方,也是咱开始本源码系列的最初动力。在 initState
之前和之后分别调用了 Vue 的生命周期钩子函数 beforeCreate
和 created
,接下来看看 Vue 是如何实现响应的数据绑定的。
响应的数据绑定
响应的数据绑定并不是 Vue 独创的,而是 MVVVM 模式理论的一部分,它是 View 层和 ViewModel 层的连接方式。如下图所示:
MVVM 分层示意图
Vue 通过【观察者模式】实现了一套响应式系统。观察者模式(也叫发布/订阅模式)会将观察者和被观察的对象严格分离开,当被观察对象的状态发生变化时,所有依赖于它的观察者都将得到通知并自动刷新。举个栗子,用户界面可以作为一个观察者,业务数据是被观察者,用户界面观察业务数据的变化,当数据发生变化时,用户界面就会自动更新。
该模式必须包含两个角色:观察者和被观察对象。Vue 定义了一个 Watcher
类来创建观察者,定义了一个 Dep
类来创建被观察对象。 Dep 是 Dependent 的缩写,意思是作为观察者的依赖存在,也就是被观察对象。
首先看一下【观察者】 Watcher
的定义:
// vue/src/core/observer/watcher.js
import Dep from './dep'
export default class Watcher {
constructor(vm) {
this.vm = vm
this.newDeps = []
Dep.target = this
}
// 添加一个观察者,或者说注册一个依赖
addDep(dep) {
this.newDeps.push(dep)
// 在【观察者】收集【被观察者】的同时,【被观察者】也会收集【观察者】
// 这好比王八看绿豆对眼儿了,遂互存了电话号码,就有了后来的相识相知
dep.addSub(this)
}
// 在被观察对象状态发生变化时调用此方法
update() {
let {vm} = this
// 更新视图
vm._update(vm._render())
}
}
每一个【观察者】都会收集自己要观察的数据对象(Dep),当【被观察对象】发生变化时,【被观察对象】会通知【观察者】,【观察者】收到通知后执行 update
方法更新视图。
接下来看一下【被观察者】 Dep
:
export default class Dep {
constructor () {
this.subs = []
}
addSub (sub) {
this.subs.push(sub)
}
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
// 通知所有对自己有依赖的观察者
notify () {
const subs = this.subs
for (let i = 0; i < subs.length; i++) {
subs[i].update()
}
}
}
Dep.target = null
每个【被观察对象】同样会收集依赖自己的【观察者】,当自己发生变化时,就会通知(notify
)这些观察者 update
。
那么问题来了,这两个角色是如何收集对方的呢?又如何得知【被观察者】发生变化了呢? 这就用到了并不常用的 Object.defineProperty() 方法,通过在 JavaScript 对象每个属性描述符的 setter
和 getter
里做文章,就能实时捕捉 JavaScript 对象的变化。
需要注意的是,Object.defineProperty()
是 JS 语言本身的一个 API 而不是 Vue 实现的,Object.defineProperty 是 ES5 中一个无法 shim 的特性,这也是为什么 Vue 不支持 IE8 以及更低版本浏览器的原因。如果想支持 IE8 以及更低版本浏览器怎么办呢?那就只有放弃 Vue,选择 Knockout。更好的解决方案就是直接让 IE8 以及更 low 的家伙见鬼去吧。不过基本上不用担心这个问题了,因为据最新浏览器使用调查报告,IE8 以及更低版本浏览器的市场份额已经微不足道,直接忽略不计就行了。
既然 JS 已经支持在对象属性变化时添加自定义处理,Vue 需要做的事就是遍历传入的 data
选项,为 data
的每个属性设置 setter
和 getter
。这就解决了如何得知【被观察者】发生了变化这个问题。
接下来说说这两者是如何收集对方的。【观察者】和【被观察者】就好比单身男和单身女,得有人安排相亲才能建立起联系呵,Vue 就是这个牵线搭桥的媒婆。下面是相亲源码:
// vue/src/core/observer/index.js
import Dep from './dep'
export function observe (obj) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
let key = keys[i], value = obj[key];
// 深度优先遍历
observe(value)
const dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 【观察者】收集【被观察者】
// 同时【被观察者】也会收集【观察者】
if (Dep.target) {
Dep.target.addDep(dep)
}
return value
},
set(newVal) {
value = newVal
// 【被观察者】通知【观察者】
dep.notify()
}
})
}
}
可以看到,Vue 在遍历 data
对象时完成了【观察者】和【被观察对象】彼此之间的收集工作。并且在 data
的某字段发生变化时,相应的依赖就会通知【观察者】自己发生了变化,【观察者】就可以做出反应。
Vue 接下来就会在 initState()
中调用 observe(vm.$options.data)
,执行之后实例化 Vue 时传入的 data
对象就会成为响应式的,当你修改 data
对象的数据时(通常是根据用户操作执行对应的业务逻辑),【被观察者】就会通知已收集的所有【观察者】,观察者就会调用自己的 update
方法,从而更新视图。这基本上就是 Vue 所实现的响应的数据绑定的工作原理。
挂载到 DOM 节点
在构建完响应式系统之后,Vue 接下来会检查用户是否传入了 el
选项,因为 Vue 在将包含指令的 HTML 模板编译成最终的朴素的 HTML 之后会执行 DOM 替换操作,最终展示在页面上,如果没有 el
选项,Vue 就不知道要把产出的 HTML 放到哪里去展示。
挂载到 DOM 节点并非替换一下 DOM 那么简单,它包括将模板编译成 render
函数,执行 render
函数生成虚拟DOM,计算出新旧虚拟DOM之间的最小变更,打补丁式地更新页面视图等几大步。
将模板编译成 render 函数
这个编译过程在前几篇的 Vue 编译器模块里已经讲得很清楚了,主要分为根据模板生成 AST,对 AST 进行优化,根据 AST 生成 render 函数这三步,这里不再赘述,感兴趣的可前往查看。
执行 render 函数生成虚拟DOM
【虚拟DOM】并非 Vue 提出的概念,而是老早就被发掘出来的新型DOM操作方式,MVVM 框架在引入虚拟DOM之后如虎添翼。之所以叫做虚拟DOM,是相对于真实DOM而言的。直接操作DOM很慢,因为真实的DOM对象很重,操作真实DOM对象(HTMLElement)花销很大,而且操作完之后往往会引起浏览器对页面的重绘和重排。如果频繁的进行DOM操作,页面性能会急剧下降。于是聪明的 Jser 决定使用简单的 JS 对象格式来表示真实 DOM,也就是虚拟DOM。先执行对虚拟DOM的操作(这会执行的很快,因为是纯 JS 操作),最后对比操作前后的新旧虚拟DOM树,找出最小变更,一次性地应用到真实DOM上。虽然还是要对真实DOM操作,但次数却大大减少,从而在更新视图的同时可有效保证页面性能。
Vue 的虚拟DOM系统是在开源虚拟DOM库 Snabbdom 的基础上做了适当的改进。
下面是 Vue 的 VNode 定义(正是一个个这样的 VNode 组成了一棵虚拟DOM树):
// vue/src/core/vdom/vnode.js
export default class VNode {
constructor (tag, data, children, text, elm) {
this.tag = tag
this.data = data
this.children = children
this.text = text
this.elm = elm // 此字段存放真实DOM
}
}
计算出新旧虚拟DOM之间的最小变更
在上一步执行 render
函数生成虚拟DOM后,接下来就需要对比新旧虚拟DOM之间的差异,从而获得DOM的最小变更。比较两棵DOM树的差异是虚拟DOM库最核心的部分,这也是所谓的 Virtual DOM 的 diff 算法。就像版本控制系统 Git 的 diff 可以计算出两次提交之间的变更,虚拟DOM的 diff 也可以计算出新旧虚拟DOM之间的差异。计算出来的差异称为一个 patch,也就是补丁。
打补丁式更新页面视图
如果是首次渲染,也就是页面刚加载进来第一次渲染,Vue 会用模板编译后的DOM替换掉传入的 el
元素。请注意这一点,对模板内DOM的操作(绑定事件,引用DOM等)应该始终放在 Vue 的 mounted
之后,否则所有处理都将丢失,因为模板会被替换掉。
如果是后续数据发生变化,Vue 就会用打补丁的方式更新视图,尽可能重用现有DOM,将真实的DOM操作减到最少。
结论
在上面【观察者】 Watcher
的定义中 update
方法里执行视图更新。因此 Vue 运行时的整个工作流程基本上是这样的:
用户调用 new Vue(options)
实例化 Vue,Vue 在 _init
方法中初始化相关字段和事件,最重要的,建立起响应式系统,Vue 实例的后续运行重度依赖于此响应式系统。Vue 会新建一个【观察者】,该观察者在创建时会执行 update
方法首次渲染视图,包含 Vue 指令的模板会被替换成编译后的朴素 HTML。Vue 会遍历传入的 data
选项,通过 Object.defineProperty
设置 setter
和 getter
将其变成【被观察对象】。当 data
的数据发生变化时,被观察对象就会通知观察者,观察者就会再次调用 update
方法打补丁式地更新视图。
本篇完,将在下一篇中开始深究运行时实现细节。
本系列会以每周一篇的速度持续更新,喜欢的小伙伴记得点关注哦