当应用在运行时,内部状态是会不断变化的。而对于 web 应用而言这会直接导致页面不停的重新渲染。那么如何通过状态变化确定具体要重新渲染哪个部分呢?在 MVVM 框架出现之前,大多数时候都需要手动去创建并维护数据与显示层的联系,随着应用的复杂度提高,内部状态和 UI 的联系变得错综复杂,难以维护。前端 MVVM 的框架正是通过编写一个通用的 ViewModel 层,负责让 Model 层的变化自动同步到 View 层,还负责让 View 层的修改同步回 Model。今天我们一起来剖析一下,当应用的内部状态改变时,Vue.js 是怎么做到侦测到变化的。
Vue.js 的变化侦测与 React 不同,对 React而言,当状态发生变化时,它并不知道具体哪个状态变化了,只知道状态有可能变了,然后发送信号给框架,框架内部收到信号后,会进行暴力比对找出来那些DOM节点需要重新渲染。对 Vue.js 而言,当状态发生改变时,它立刻就知道了,而且一定程度上知道具体哪些状态改变了,且如果一个状态上绑定了多个依赖,当状态改变时,会向所有绑定的依赖发送通知。但是,这种粒度更细也是要付出一定代价的,每个状态绑定的依赖越多,依赖跟踪在内存上的消耗就更大,这种情况在 Vue.js 2.0 引入虚拟 DOM 之后改善了很多。
问题一:如何侦听一个对象的变化
在 JS 中,我们侦听对象变化的手段无非两种:Object.defineProperty 和 ES6 的 Proxy。由于 ES6 的支持情况不理想,Vue.js 2.0 中采用的是第一种方法,但在新版本中应该会放弃 Object.defineProperty 选择 Proxy。因为 Object.defineProperty 是存在明显缺陷的,后文会提到。首先我们可以采用下面的函数来封装 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
}
})
}
此时,思考一下,要观察数据的真正目的是什么?
目的就是当数据变化时,可以通知那些曾经使用过该数据的地方。所以我们需要先收集依赖,这样当数据变化时在去通知这些依赖。显而易见,可以在 getter 中收集依赖,在 setter 中触发依赖。
问题二:依赖收集在哪里
首先我们可以封装一个通用的依赖类,在 Vue.js 中是 Dep 类:
class Dep {
constructor () {
this.subs = []
}
addSub (sub) {
this.subs.push(sub)
}
removeSub (sub) {
remove(this.subs, sub)
}
depend () {
if (somethingToWatch) {
this.addSub(somethingToWatch)
}
}
notify () {
const subs = this.subs.slice()
for (let sub of subs) {
sub.update()
}
}
}
function remove (arr, items) {
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 类中出现了 somethingToWatch,显然它正是我们在数据变化之后需要通知的对象。在 Vue.js 中,我们通知用到数据的地方有很多,比如模板中,或是自定义的一个 watch 。所以此时需要一个抽象的类来覆盖这些情况,Vue.js 中这个类为 Watcher:
class Watcher {
constructor (vm, expOrFn, cb) {
this.vm = vm
this.getter = parsePath(expOrFn)
this.cb = cb
this.value = this.get()
}
get () {
somethingToWatch = this
let value = this.getter.call(this.vm, this.vm)
somethingToWatch = undefined
return value
}
update() {
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldValue)
}
}
在这段代码中,当 Watcher 初始化时,会调用 get 方法,而在 get 方法中,我们将 somethingToWatch 指向了当前的 Watcher 实例,当我们在获取 value 值的时候又会触发数据的 getter,从而自动将 Watcher 实例添加到 Dep 中。当数据变化时,Dep 会触发依赖列表中所有依赖的 update 方法,也就是 Watcher 中的 update 方法,Watcher 中的 update 方法。
可以看一个 vm.$watch('a.b.c', (oldVal, newVal) => {}) 的例子,当 a.b.c 变化时,要调用后面的回调函数。首先,要解析 a.b.c,在 Vue.js 中用 parsePath 来完成:
const bailRE = /^\w+.$/
function parsePath (path) {
if (bailRE.test(path)) {
return
}
const segments = path.split('.')
return function (obj) {
for (let segment of segments) {
if (!obj) return
obj = obj[segment]
}
}
return obj
}
至此,我们就拿到了 a.b.c 这个属性,且在 Watcher 的 get 方法中访问了它,触发了它的 getter,从而将当前 Watcher 实例添加到 a.b.c 的依赖列表里。且当 a.b.c 发生变化时,回调函数将会在 Watcher 中的 update 方法里被调用。
问题四:怎么侦测所有 key
可以看到使用 Object.defineProperty 可以侦测到对象的某个属性值变化,但是我们需要侦听所有属性值(包括子属性)的变化。现在开始封装 Observer 类来实现这一目的:
/**
* Observer 类会被附加到每一个被侦测的 object上。
* 一旦加上,会将 object 所有的属性都转化为 getter/setter 的形式
* 来收集属性依赖,并且在属性变化时通知这些依赖
*/
class Observer {
constructor(value) {
this.value = value
if (!Array.isArray(value)) {
this.walk(value)
}
}
/**
* walk 将每一个属性都转为 getter/setter
*/
walk (obj) {
const keys = Object.keys(obj)
for (let key of keys) {
defineReactive(obj, key, obj[key])
}
}
}
function defineReactive(data, key, val) {
// 新增,用于递归子属性
if (typeof val == 'object') {
new Observer(val)
}
...
}
通过定义了 Observer 类,我们将一个正常的 object 转换为了被侦测的 object。然后判断数据类型,只有 Object 类型的数据才会调用 walk 方法将每一个属性都变为 getter/setter 模式。而改造后的 defineReactive 加上了一段新代码用于判断当子属性为 Object 时,对子属性调用 new Observer(val),从而形成递归。这样我们就把所有的属性都变为 getter/setter 的形式了。
问题五:Object.defineProperty 带来的隐藏问题
思考一个场景,当我们在一个 Vue 实例中,定义 data: { a: {} },又定义了一个方法 action () { this.a.name = 'jay' },如果调用了 action 方法,能不能侦听到对象 a 的改变呢? 答案是否定的,由于在初始化过程中, a 并没有 name 这个属性,也就是说在 walk 方法中,我们没有将 name 属性变为 getter/setter 模式,所以无法侦测到这个变化,也不会向依赖发送通知。
再比如,我们在 action 中删除某个已经存在的属性值,Object.defineProperty 只能判断一个数据是否被修改,故同样也是无法侦测到变化的。要解决这两个问题,我们可以调用 vm.delete 这两个API。
Object 的变化侦测过程梳理
Data 通过 Observe 转换为 getter/setter 形式来追踪变化。当外界通过 Watcher 读取数据是,会触发 getter 从而将 Watcher 添加到依赖中。当数据发生变化时,会触发 setter,从而向 Dep 中的依赖发送通知。Watcher 收到通知后,会向外界发送通知,外界收到通知后,可能会触发视图更新,也可能触发用户的回调函数。
本系列文章均是深入浅出 Vue.js的学习笔记,有兴趣的小伙伴可以去看书哈。