2013年7⽉28⽇,有⼀位名叫尤⾬溪,英⽂名叫Evan You的⼈在GitHub上第⼀次为Vue.js提交代码。这是Vue.js的第⼀个提交(commit),但这时还不叫Vue.js。从仓库的package.json⽂件可以看出,这时的名字叫作Element,后来被更名为Seed.js。
2013年12⽉7⽇,尤⾬溪在GitHub上发布了新版本0.6.0,将项⽬正式改名为Vue.js,并且把默认的指令前缀变成v- 。这⼀版本的发布,代表Vue.js正式问世。
2014年2⽉1⽇,尤⾬溪将Vue.js 0.8发布在了国外的Hacker News⽹站,这代表它⾸次公开发布。听尤⾬溪说,当时被顶到了Hacker News的⾸⻚,在⼀周的时间内拿到了615个GitHub的star,他特别兴奋。从这之后,经过近两年的孵化,
直到2015年10⽉26⽇这天,Vue.js终于迎来了1.0.0版本的发布。我不知道当时尤⾬溪的⼼情是什么样的,但从他发布版本时所带的格⾔可以看出,他⼼⾥⼀定很复杂。
那句话是:
“The fate of destruction is also the joy of rebirth.”
翻译成中⽂是:
毁灭的命运,也是重⽣的喜悦。
并且为1.0.0这个版本配备了⼀个代号,叫新世纪福⾳战⼠(Evangelion),这是⼀部动画⽚的名字。事实上,Vue.js每⼀次⽐较⼤的版本发布,都会配⼀个动画⽚的名称作为代号。
2016年10⽉1⽇,这⼀天是祖国的⽣⽇,但同时也是Vue.js 2.0发布的⽇⼦。Vue.js 2.0的代号叫攻壳机动队(Ghost in the Shell)。同时,这⼀次尤⾬溪发布这个版本时所带的格⾔是:
“Your effort to remain what you are is what limits you.”
翻译成中⽂是:
保持本⾊的努⼒,也在限制你的发展。
在开发Vue.js的整个过程中,它的定位发⽣了变化,⼀开始的定位是:
“Just a view layer library”就是说,最早的Vue.js只做视图层,没有路由,没有状态管理,也没有官⽅的构建⼯具,只有⼀个库,放在⽹⻚⾥就直接⽤。
后来,他发现Vue.js⽆法⽤在⼀些⼤型应⽤上,这样在开发不同⼤⼩的
应⽤时,需要不停地切换框架以及思维模式。尤⾬溪希望有⼀个⽅案,有⾜够的灵活性,能够适应不同⼤⼩的应⽤需求。所以,Vue.js就慢慢开始加⼊了⼀些官⽅的辅助⼯具,⽐如路由
(Router)、状态管理⽅案(Vuex)和构建⼯具(vue-cli)等。加⼊这些⼯具时,Vue.js始终维持着⼀个理念:“这个框架应该是渐进式的 。”
这时Vue.js的定位是:
The Progressive Framework
翻译成中⽂,就是渐进式框架 。
所谓渐进式框架,就是把框架分层。
最核⼼的部分是视图层渲染,然后往外是组件机制,在这个基础上再
加⼊路由机制,再加⼊状态管理,最外层是构建⼯具,如图1-1所⽰。
所谓分层,就是说你既可以只⽤最核⼼的视图层渲染功能来快速开发⼀些需求,也可以使⽤⼀整套全家桶来开发⼤型应⽤。Vue.js有⾜够的灵活性来适应不同的需求,所以你可以根据⾃⼰的需求选择不同的层级。
Vue.js 2.0与Vue.js 1.0之间内部变化⾮常⼤,整个渲染层都重写了,但API层⾯的变化却很⼩。可以看出,Vue.js是⾮常注重⽤户体验和学习曲线的,它尽量让开发者⽤起来很爽,同时在应⽤场景上,其他框架能做到的Vue.js都能做到,不存在其他框架可以实现⽽Vue.js不能实现这样的问题,所以在技术选型上,只需要考虑Vue.js的使⽤⽅式是不是符合⼝味,团队来了新同学能否快速融⼊等问题。由于⽆论是学习曲线还是API的设计上,Vue.js都⾮常优雅,所以它具有很强的竞争⼒。
Vue.js 2.0引⼊了⾮常多的特性,其中⼀个明显的效果是Vue.js变得更
轻、更快了。
Vue.js 2.0引⼊了虚拟DOM,其渲染过程变得更快了。虚拟DOM现在已经被⽹上说烂了,但是我想说的是,不要⼈云亦云。Vue.js引⼊虚拟DOM是有原因的。事实上,并不是引⼊虚拟DOM后,渲染速度变快了。准确地说,应该是80% 的场景下变得更快了,⽽剩下的20% 反⽽变慢了。
任何技术的引⼊都是在解决⼀些问题,⽽通常解决⼀个问题的同时会引发另外⼀个问题,这种情况更多的是做权衡,做取舍。所以,不要像⽹上⼤部分⼈那样,成天说因为引⼊了虚拟DOM⽽变快了。我们要透过现象看本质,本书的⽬的也在于此。除了引⼊虚拟DOM外,Vue.js 2.0还提供了很多令⼈激动的特性,⽐如⽀持JSX和TypeScript,⽀持流式服务端渲染,提供了跨平台的能⼒等。
Vue.js最独特的特性之⼀是看起来并不显眼的响应式系统。数据模型仅仅是普通的JavaScript对象。⽽当你修改它们时,视图会进⾏更新。这使得状态管理⾮常简单、直接。不过理解其⼯作原理同样重要,这样你可以回避⼀些常⻅的问题。——官⽅⽂档
从状态⽣成DOM,再输出到⽤户界⾯显⽰的⼀整套流程叫作渲染,应⽤在运⾏时会不断地进⾏重新渲染。⽽响应式系统赋予框架重新渲染的能⼒,其重要组成部分是变化侦测。变化侦测是响应式系统的核⼼,没有它,就没有重新渲染。框架在运⾏时,视图也就⽆法随着状态的变化⽽变化。
简单来说,变化侦测的作⽤是侦测数据的变化。当数据变化时,会通知视图进⾏相应的更新。
正如⽂档中所说,深⼊理解变化侦测的⼯作原理,既可以帮助我们在开发应⽤时回避⼀些很常⻅的问题,也可以在应⽤程序出问题时,快速调试并修复问题。
⼤部分⼈不会想到Object 和Array 的变化侦测采⽤不同的处理⽅式。事实上,它们的侦测⽅式确实不⼀样。
什么是变化侦测
Vue.js会⾃动通过状态⽣成DOM,并将其输出到⻚⾯上显⽰出来,这个过程叫渲染。Vue.js的渲染过程是声明式的,我们通过模板来描述状态与DOM之间的映射关系。
通常,在运⾏时应⽤内部的状态会不断发⽣变化,此时需要不停地重新渲染。这时如何确定状态中发⽣了什么变化?
变化侦测就是⽤来解决这个问题的,它分为两种类型:⼀种是“推”(push),另⼀种是“拉”(pull)。
Angular和React中的变化侦测都属于“拉”,这就是说当状态发⽣变化时,它不知道哪个状态变了,只知道状态有可能变了,然后会发送⼀个信号告诉框架,框架内部收到信号后,会进⾏⼀个暴⼒⽐对来找出哪些DOM节点需要重新渲染。这在Angular中是脏检查的流程,在
React中使⽤的是虚拟DOM。
⽽Vue.js的变化侦测属于“推”。当状态发⽣变化时,Vue.js⽴刻就知道了,⽽且在⼀定程度上知道哪些状态变了。因此,它知道的信息更多,也就可以进⾏更细粒度的更新。
所谓更细粒度的更新,就是说:假如有⼀个状态绑定着好多个依赖,每个依赖表⽰⼀个具体的DOM节点,那么当这个状态发⽣变化时,向这个状态的所有依赖发送通知,让它们进⾏DOM更新操作。相⽐较⽽⾔,“拉”的粒度是最粗的。
但是它也有⼀定的代价,因为粒度越细,每个状态所绑定的依赖就越多,依赖追踪在内存上的开销就会越⼤。因此,从Vue.js 2.0开始,它引⼊了虚拟DOM,将粒度调整为中等粒度,即⼀个状态所绑定的依赖不再是具体的DOM节点,⽽是⼀个组件。这样状态变化后,会通知到组件,组件内部再使⽤虚拟DOM进⾏⽐对。这可以⼤⼤降低依赖数量,从⽽降低依赖追踪所消耗的内存。Vue.js之所以能随意调整粒度,本质上还要归功于变化侦测。因为“推”类型的变化侦测可以随意调整粒度。
关于变化侦测,⾸先要问⼀个问题,在JavaScript(简称JS)中,如何侦测⼀个对象的变化?
其实这个问题还是⽐较简单的。学过JavaScript的⼈都知道,有两种⽅法可以侦测到变化:使⽤Object.defineProperty 和ES6的Proxy。
由于ES6在浏览器中的⽀持度并不理想,到⽬前为⽌Vue.js还是使⽤Object.defineProperty 来实现的,所以书中也会使⽤它来介绍变化侦测的原理。
由于使⽤Object.defineProperty 来侦测变化会有很多缺陷,所以Vue.js的作者尤⾬溪说⽇后会使⽤Proxy 重写这部分代码。好在本章讲的是原理和思想,所以即便以后⽤Proxy 重写了这部分代码,书中介绍的原理也不会变。知道了Object.defineProperty 可以侦测到对象的变化,那么我们可以写出这样的代码:
function defineReactive (data, key, val) {
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
return val
},
set: function (newVal) {
if(val === newVal){
return
}
val = newVal
}
})
}
这⾥的函数defineReactive ⽤来对Object.defineProperty 进⾏封装。从函数的名字可以看出,其作⽤是定义⼀个响应式数据。也就是在这个函数中进⾏变化追踪,封装后只需要传递data 、key 和val 就⾏了。封装好之后,每当从data 的key 中读取数据时,get 函数被触发;每当往data 的key 中设置数据时,set 函数被触发。
如果只是把Object.defineProperty 进⾏封装,那其实并没什么实际⽤处,真正有⽤的是收集依赖。现在我要问第⼆个问题:如何收集依赖?思考⼀下,我们之所以要观察数据,其⽬的是当数据的属性发⽣变化时,可以通知那些曾经使⽤了该数据的地⽅。
举个例⼦:
<template>
<h1>{{ name }}</h1>
</template>
该模板中使⽤了数据name ,所以当它发⽣变化时,要向使⽤了它的地⽅发送通知。
注意 在Vue.js 2.0中,模板使⽤数据等同于组件使⽤数据,所以当数据发⽣变化时,会将通知发送到组件,然后组件内部再通过虚拟DOM重新渲染。对于上⾯的问题,我的回答是,先收集依赖,即把⽤到数据name 的地⽅收集起来,然后等属性发⽣变化时,把之前收集好的依赖循环触发
⼀遍就好了。总结起来,其实就⼀句话,在getter中收集依赖,在setter中触发依赖。
现在我们已经有了很明确的⽬标,就是要在getter中收集依赖,那么要把依赖收集到哪⾥去呢?
思考⼀下,⾸先想到的是每个key 都有⼀个数组,⽤来存储当前key的依赖。假设依赖是⼀个函数,保存在window.target 上,现在就可以把defineReactive 函数稍微改造⼀下:
function defineReactive(data, key, val) {
let dep = [] // 新增
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
dep.push(window.target) // 新增
return val
},
set: function (newVal) {
if (val === newVal) {
return
}
// 新增
for (let i = 0; i < dep.length; i++) {
dep[i](newVal, val)
}
val = newVal
}
})
}
这⾥我们新增了数组dep ,⽤来存储被收集的依赖。然后在set 被触发时,循环dep 以触发收集到的依赖。但是这样写有点耦合,我们把依赖收集的代码封装成⼀个Dep 类,它专门帮助我们管理依赖。使⽤这个类,我们可以收集依赖、删除依赖或者向依赖发送通知等。其代码如下:
export default class Dep {
constructor() {
this.subs = []
}
addSub(sub) {
this.subs.push(sub)
}
removeSub(sub) {
remove(this.subs, sub)
}
depend() {
if (window.target) {
this.addSub(window.target)
}
}
notify() {
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
function remove(arr, item) {
if (arr.length) {
const index = arr.indexOf(item)
if (index > -1) {
return arr.splice(index, 1)
}
}
}
// 之后再改造⼀下defineReactive :
function defineReactive(data, key, val) {
let dep = new Dep() // 修改
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
dep.depend() // 修改
return val
},
set: function (newVal) {
if (val === newVal) {
return
}
val = newVal
dep.notify() // 新增
}
})
}
此时代码看起来清晰多了,这也顺便回答了上⾯的问题,依赖收集到哪⼉?收集到Dep 中。
在上⾯的代码中,我们收集的依赖是window.target ,那么它到底
是什么?我们究竟要收集谁呢?收集谁,换句话说,就是当属性发⽣变化后,通知谁。
我们要通知⽤到数据的地⽅,⽽使⽤这个数据的地⽅有很多,⽽且类型还不⼀样,既有可能是模板,也有可能是⽤户写的⼀个watch,这时需要抽象出⼀个能集中处理这些情况的类。然后,我们在依赖收集阶段只收集这个封装好的类的实例进来,通知也只通知它⼀个。接着,它再负责通知其他地⽅。所以,我们要抽象的这个东⻄需要先起⼀个好听的名字。嗯,就叫它Watcher 吧。现在就可以回答上⾯的问题了,收集谁?Watcher !
Watcher 是⼀个中介的⾓⾊,数据发⽣变化时通知它,然后它再通知
其他地⽅。
关于Watcher ,先看⼀个经典的使⽤⽅式:
// keypath
vm.$watch('a.b.c', function (newVal, oldVal) {
// 做点什么
})
这段代码表⽰当data.a.b.c 属性发⽣变化时,触发第⼆个参数中的
函数。
思考⼀下,怎么实现这个功能呢?好像只要把这个watcher 实例添加到data.a.b.c 属性的Dep 中就⾏了。然后,当data.a.b.c 的值发⽣变化时,通知Watcher 。接着,Watcher 再执⾏参数中的这个回调函数。好,思考完毕,写出如下代码:
export default class Watcher {
constructor(vm, expOrFn, cb) {
this.vm = vm
// 执⾏this.getter(),就可以读取data.a.b.c的内容
this.getter = parsePath(expOrFn)
this.cb = cb
this.value = this.get()
}
get() {
window.target = this
let value = this.getter.call(this.vm, this.vm)
window.target = undefined
return value
}
update() {
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldValue)
}
}
这段代码可以把⾃⼰主动添加到data.a.b.c 的Dep 中去,是不是很
神奇?因为我在get ⽅法中先把window.target 设置成了this ,也就是当前watcher 实例,然后再读⼀下data.a.b.c 的值,这肯定会触发getter。
触发了getter,就会触发收集依赖的逻辑。⽽关于收集依赖,上⾯已经介绍了,会从window.target 中读取⼀个依赖并添加到Dep 中。这就导致,只要先在window.target 赋⼀个this ,然后再读⼀下值,去触发getter,就可以把this 主动添加到keypath 的Dep 中。有
没有很神奇的感觉啊?依赖注⼊到Dep 中后,每当data.a.b.c 的值发⽣变化时,就会让依
赖列表中所有的依赖循环触发update ⽅法,也就是Watcher 中的update ⽅法。⽽update ⽅法会执⾏参数中的回调函数,将value和oldValue 传到参数中。所以,其实不管是⽤户执⾏的vm.$watch(‘a.b.c’, (value,oldValue) => {}) ,还是模板中⽤到的data ,都是通过Watcher来通知⾃⼰是否需要发⽣变化。这⾥有些⼩伙伴可能会好奇上⾯代码中的parsePath 是怎么读取⼀个
字符串的keypath 的,下⾯⽤⼀段代码来介绍其实现原理:
/**
* 解析简单路径
*/
const bailRE = /[^\w.$]/
export function parsePath(path) {
if (bailRE.test(path)) {
return
}
const segments = path.split('.')
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
可以看到,这其实并不复杂。先将keypath ⽤ . 分割成数组,然后循
环数组⼀层⼀层去读数据,最后拿到的obj 就是keypath 中想要读的
数据。
现在,其实已经可以实现变化侦测的功能了,但是前⾯介绍的代码只
能侦测数据中的某⼀个属性,我们希望把数据中的所有属性(包括⼦
属性)都侦测到,所以要封装⼀个Observer 类。这个类的作⽤是将
⼀个数据内的所有属性(包括⼦属性)都转换成getter/setter的形式,然
后去追踪它们的变化:
/**
* Observer类会附加到每⼀个被侦测的object上。
* ⼀旦被附加上,Observer会将object的所有属性转换为getter/setter的形
式
* 来收集属性的依赖,并且当属性发⽣变化时会通知这些依赖
*/
export class Observer {
constructor(value) {
this.value = value
if (!Array.isArray(value)) {
this.walk(value)
}
}
/**
* walk会将每⼀个属性都转换成getter/setter的形式来侦测变化
* 这个⽅法只有在数据类型为Object时被调⽤
*/
walk(obj) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
}
function defineReactive(data, key, val) {
// 新增,递归⼦属性
if (typeof val === 'object') {
new Observer(val)
}
let dep = new Dep()
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
dep.depend()
return val
},
set: function (newVal) {
if (val === newVal) {
return
}
val = newVal
dep.notify()
}
})
}
在上⾯的代码中,我们定义了Observer 类,它⽤来将⼀个正常的object 转换成被侦测的object ,然后判断数据的类型,只有Object 类型的数据才会调⽤walk 将每⼀个属性转换成getter/setter的形式来侦测变化。最后,在defineReactive 中新增new Observer(val) 来递归⼦属性,这样我们就可以把data 中的所有属性(包括⼦属性)都转换成getter/setter的形式来侦测变化。当data 中的属性发⽣变化时,与这个属性对应的依赖就会接收到通知。也就是说,只要我们将⼀个object 传到Observer 中,那么这个object 就会变成响应式的object
前⾯介绍了Object 类型数据的变化侦测原理,了解了数据的变化是通
过getter/setter来追踪的。也正是由于这种追踪⽅式,有些语法中即便是
数据发⽣了变化,Vue.js也追踪不到。
⽐如,向object 添加属性:
var vm = new Vue({
el: '#el',
template: '#demo-template',
methods: {
action() {
this.obj.name = 'berwin'
}
},
data: {
obj: {}
}
})
在action ⽅法中,我们在obj 上⾯新增了name 属性,Vue.js⽆法侦
测到这个变化,所以不会向依赖发送通知。再⽐如,从obj 中删除⼀个属性:
var vm = new Vue({
el: '#el',
template: '#demo-template',
methods: {
action() {
delete this.obj.name
}
},
data: {
obj: {
name: 'berwin'
}
}
})
在上⾯的代码中,我们在action ⽅法中删除了obj 中的name 属性,⽽Vue.js⽆法侦测到这个变化,所以不会向依赖发送通知。
Vue.js通过Object.defineProperty 来将对象的key 转换成getter/setter的形式来追踪变化,但getter/setter只能追踪⼀个数据是否被修改,⽆法追踪新增属性和删除属性,所以才会导致上⾯例⼦中提到的问题。
但这也是没有办法的事,因为在ES6之前,JavaScript没有提供元编程的能⼒,⽆法侦测到⼀个新属性被添加到了对象中,也⽆法侦测到⼀个属性从对象中删除了。为了解决这个问题,Vue.js提供了两个API——vm.$set 与vm.$delete
变化侦测就是侦测数据的变化。当数据发⽣变化时,要能侦测到并发出通知。Object 可以通过Object.defineProperty 将属性转换成getter/setter的形式来追踪变化。读取数据时会触发getter,修改数据时会触发setter。
我们需要在getter中收集有哪些依赖使⽤了数据。当setter被触发时,去通知getter中收集的依赖数据发⽣了变化。收集依赖需要为依赖找⼀个存储依赖的地⽅,为此我们创建了Dep ,它⽤来收集依赖、删除依赖和向依赖发送消息等。所谓的依赖,其实就是Watcher 。只有Watcher 触发的getter才会收集依赖,哪个Watcher 触发了getter,就把哪个Watcher 收集到Dep中。当数据发⽣变化时,会循环依赖列表,把所有的Watcher 都通知⼀遍。
Watcher 的原理是先把⾃⼰设置到全局唯⼀的指定位置(例如window.target ),然后读取数据。因为读取了数据,所以会触发这个数据的getter。接着,在getter中就会从全局唯⼀的那个位置读取当前正在读取数据的Watcher ,并把这个Watcher 收集到Dep 中去。通过这样的⽅式,Watcher 可以主动去订阅任意⼀个数据的变化。此外,我们创建了Observer 类,它的作⽤是把⼀个object 中的所有数据(包括⼦数据)都转换成响应式的,也就是它会侦测object 中
所有数据(包括⼦数据)的变化。由于在ES6之前JavaScript并没有提供元编程的能⼒,所以在对象上新增属性和删除属性都⽆法被追踪到。
图2-1给出了Data 、Observer 、Dep 和Watcher 之间的关系。
42页图。。。。。
Data 通过Observer 转换成了getter/setter的形式来追踪变化。
当外界通过Watcher 读取数据时,会触发getter从⽽将Watcher 添加到依赖中。
当数据发⽣了变化时,会触发setter,从⽽向Dep 中的依赖(Watcher)发送通知。
Watcher 接收到通知后,会向外界发送通知,变化通知到外界后可能会触发视图更新,也有可能触发⽤户的某个回调函数等。