大白话Vue源码系列(05):运行时鸟瞰图
研究 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 方法打补丁式地更新视图。
本篇完,将在下一篇中开始深究运行时实现细节。
本系列会以每周一篇的速度持续更新,喜欢的小伙伴记得点关注哦
大白话Vue源码系列(05):运行时鸟瞰图的更多相关文章
- 大白话Vue源码系列(02):编译器初探
阅读目录 编译器代码藏在哪 Vue.prototype.$mount 构建 AST 的一般过程 Vue 构建的 AST 题接上文,上回书说到,Vue 的编译器模块相对独立且简单,那咱们就从这块入手,先 ...
- 大白话Vue源码系列(03):生成render函数
阅读目录 优化 AST 生成 render 函数 小结 本来以为 Vue 的编译器模块比较好欺负,结果发现并没有那么简单.每一种语法指令都要考虑到,处理起来相当复杂.上篇已经生成了 AST,本篇依然对 ...
- 大白话Vue源码系列(04):生成render函数
阅读目录 优化 AST 生成 render 函数 小结 本来以为 Vue 的编译器模块比较好欺负,结果发现并没有那么简单.每一种语法指令都要考虑到,处理起来相当复杂.上篇已经生成了 AST,本篇依然对 ...
- 大白话Vue源码系列(03):生成AST
阅读目录 AST 节点定义 标签的正则匹配 解析用到的工具方法 解析开始标签 解析结束标签 解析文本 解析整块 HTML 模板 未提及的细节 本篇探讨 Vue 根据 html 模板片段构建出 AST ...
- 大白话Vue源码系列(01):万事开头难
阅读目录 Vue 的源码目录结构 预备知识 先捡软的捏 Angular 是 Google 亲儿子,React 是 Facebook 小正太,那咱为啥偏偏选择了 Vue 下手,一句话,Vue 是咱见过的 ...
- 大白话Vue源码系列目录
.first-level{ font-size: 1.2rem; cursor: default; color: #666; } .second-level{ font-size: 1.1rem; p ...
- 手牵手,从零学习Vue源码 系列一(前言-目录篇)
系列文章: 手牵手,从零学习Vue源码 系列一(前言-目录篇) 手牵手,从零学习Vue源码 系列二(变化侦测篇) 手牵手,从零学习Vue源码 系列三(虚拟DOM篇) 陆续更新中... 预计八月中旬更新 ...
- 手牵手,从零学习Vue源码 系列二(变化侦测篇)
系列文章: 手牵手,从零学习Vue源码 系列一(前言-目录篇) 手牵手,从零学习Vue源码 系列二(变化侦测篇) 陆续更新中... 预计八月中旬更新完毕. 1 概述 Vue最大的特点之一就是数据驱动视 ...
- Vue 源码学习(1)
概述 我在闲暇时间学习了一下 Vue 的源码,有一些心得,现在把它们分享给大家. 这个分享只是 Vue源码系列 的第一篇,主要讲述了如下内容: 寻找入口文件 在打包的过程中 Vue 发生了什么变化 在 ...
随机推荐
- 十五、Hadoop学习笔记————Zookeeper客户端的使用
timeout表示会话超时时间,zookeeper靠与客户的心跳来判断会话是否有效(单位毫秒), -r为只读,表示zookeeper如果与半数以上服务器失去连接则会停止服务,如果有-r参数,则会继续保 ...
- 【MAVEN】maven系列--pom.xml标签详解
pom文件作为MAVEN中重要的配置文件,对于它的配置是相当重要.文件中包含了开发者需遵循的规则.缺陷管理系统.组织.licenses.项目信息.项目依赖性等.下面将重点介绍一下该文件的基本组成与功能 ...
- python 正则空格\xa0实录 与xpath取 div 里面的含多个标签的所有文字
业余玩爬虫时,由原先的原生写法 改为 scrapy框架了,使用自带的selector时,xpath配合正则来抓取回复数和阅读数的时候,遇到的小问题,mark下. 首先获取到 我需要的数据块,(我用sc ...
- Handlebars 和 angularjs 之间的区别
handlebarsjs算不上框架,只是一种js模板引擎,是模板库,模板库的主要作用是:你想要生成某一大片有一定规律的界面,比如商品详情,不同商品之间差的只是名称,价格,图片,介绍这些,但是结构一样的 ...
- php 将pdf转成图片且将图片拼接
说明: 1.pdf转图片通过安装php扩展imagick实现. 2.由于windows扩展安装的一系列问题,建议在linux环境开发,windows大伙可以尝试安装. 3.为Centos 安装Imag ...
- Unity 5--全局光照技术
本文整理自Unity全球官方网站,原文:UNITY 5 - LIGHTING AND RENDERING 简介全局光照,简称GI,是一个用来模拟光的互动和反弹等复杂行为的算法,要精确的仿真全局光照非常 ...
- netty详解之io模型
提起IO模型首先想到的就是同步,异步,阻塞,非阻塞这几个概念.每个概念的含义,解释,概念间的区别这些都是好理解,这里深入*nix系统讲一下IO模型. 在*nix中将IO模型分为5类. Blocking ...
- Android中关于JNI 的学习(三)在JNI层訪问Java端对象
前面两篇文章简介了JNI层跟Java层的一些相应关系,包含方法名,数据类型和方法名称等,相信在理论层面.可以非常好地帮助我们去了解JNI在Native本地开发中的作用,对JNI的一些概念也有了一个初步 ...
- JVM垃圾收集相关经常使用參数
參 数 描 述 UseSerialGC 虚拟机执行在Client 模式下的默认值,打开此开关后,使用Serial + Serial Old 的收集器组合进行内存回收 UseParNewGC 打开此开关 ...
- 原生JS与jQuery操作DOM对比
一.创建元素节点 1.1 原生JS创建元素节点 document.createElement("p"); 1.2 jQuery创建元素节点 $('<p></p&g ...