研究 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 发生了什么变化 在 ...
随机推荐
-
liunx ln -s 软连接
项目中遇到不同项目中上传图片共享问题 解决方法就用到了 liunx的ln -s 的软连接, 用法: liunx ln -s 文件路径 实现共享思路:不同的目录都软连接到同一个目录
-
拷贝Java项目报错
经常需要将一个项目,导出,然后发给同事,或者是自己用另一个Eclipse工具打开. 这时,导入项目后,就会出现各种各样的问题.大牛笔记:www.weixuehao.com 代码相同,环境不同,主要是修 ...
-
Nhibernate中CreateSQLQuery用法实例
说明: 使用原生SQL查询时,若要通过addEntity方法引入对象,则查询结果列中必须包含该对象的所有属性,否则会抛出System.IndexOutOfRangeException异常. 结论: 若 ...
-
java项目上各种小问题
md,出现好几次这种问题,还得上百度? 以此为证,再出现这种问题我就不信想不起来怎么解决!!!----清除不存在jar包 ok,第二个问题: 每个类上有无数个红叉,然而代码并没有问题 解决方案:run ...
-
python 中@property的使用
从14年下半年开始接触到python,自学了一段时间,后又跟别人学习了下,把基础知识基本上学过了.忽然感觉python不可能这么简单吧,就这么点东西?后来看了下书,发现还有很多的高级部分.连续看了两天 ...
-
解决Xcode7 iOS9苹果将原http协议改成了https协议问题
在info.plist 加入key <key>NSAppTransportSecurity</key> <dict> <key>NSAllowsArbi ...
-
IBM Minus One(water)
IBM Minus One Time Limit: 2000/1000 MS (Java/Others) Memory Limit: 65536/32768 K (Java/Others)Tot ...
-
升级_宽视野Oracle图形升级(升级后dbca建库)—10.2.0.1.0提拔10.2.0.5.0
***********************************************声明********************************************** 原创作 ...
-
Struts2 语法--result type
result type: dispatcher,redirect:只能跳转到jsp,html之类的页面,dispatcher属于服务器跳转, redirect属于客户端跳转 chain: 等同于for ...
-
CCF系列之门禁系统(201412-1)
试题编号:201412-1试题名称:门禁系统时间限制: 2.0s内存限制: 256.0MB 问题描述 涛涛最近要负责图书馆的管理工作,需要记录下每天读者的到访情况.每位读者有一个编号,每条记录用读者的 ...