深入浅出 - Vue变化侦测原理
如何侦测变化?
- 关于变化侦测首先要问一个问题,在js中是如何侦测一个对象的变化的.其实这个问题还算比较简单的,js中有两种方法可以侦测到变化,
Object.defineProperty
和ES6中的proxy
. - 到目前为止vue使用的还是
Object.defineProperty
,那么我们可以写出这样的代码
function defineReaction (data, key, val) {
Object.defineProperty (data, key, {
enumerable: true,
configurable: true,
get: function () {
return val
},
set: function (newVal) {
if(val === newVal) {
return
}
val = newVal
}
})
}
Object.defineProperty
的使用请看 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
- 写一个函数封装一下
Object.defineProperty
,毕竟Object.defineProperty
的用法复杂,封装一个我们只需要传递data,key,val的就好了 - 现在封装好了之后每当
data
的key
读取数据get
这个函数可以被触发,设置数据的时候set
函数可以被触发.但是执行起来没什么用~~~
怎么观察变化!
- 思考一下,我们之所以要观察一个数据,目的是为了当数据的属性发生变化时,可以通知那些使用了这个
key
的地方
举例说明:
<template>
<div>{{key}}</div>
<p>{{key}}</p>
</template>
- 模板中有两处使用了
key
,所以当数据发生变化时,要把这两处都通知到. - 所以上面的问题,先收集依赖,吧这些使用到
key
的地方先收集起来,然后等属性发生变化时,把收集好的依赖循环触发一遍 - 在
getter
中收集依赖,在setter
中,触发依赖
在那收集依赖
- 首先想到的是每个
key
都有一个数组,用来存储当前key
的依赖,假设依赖是一个函数存在window.target
上,先把defineReactive
改造一下
function defineReaction (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
把收集到的依赖触发 - 上述代码耦合较高,封装一下
export default class Dep {
static target: ?Watcher,
id: number,
subs: Array<Watcher>,
constructor () {
this.id = uid++
this.subs = []
},
addSub (sub: Watcher) {
this.subs.push(sub)
},
removeSub (sub: Watcher) {
remove(this.subs, sub)
},
depend () {
if (Dep.target) {
this.addSub(Dep.target)
}
},
notify () {
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
},
}
function defineReaction (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
}
dep.notify()
val = newVal
}
})
}
收集谁?
- 收集谁,换句话说就是当属性发生变化后,通知谁.上面我们假装
window.target
是需要我们收集的依赖,但我们已经改成了Dep.target
. - 我们要通知那个使用到数据的地方,而使用这个数据的地方有很多,而且类型不一样,有可能是模板,有可能是用户写得一个watch,所以这个时候我们需要抽象出一个能集中处理这些不同情况的类.然后我们在依赖收集阶段只收集这个封装好的类的实例进来,通知也只通知它一个,然后它在负责通知其他地方!!!
watcher
- watcher是一个中介的角色,数据变化通知给watcher,然后watcher在通知给其他地方
watcher使用方法:
vm.$watch('a.b.c', function (newVal, oldVal) {
// ...
})
这段代码标示当data.a.b.c
这个属性发生变化时,触发第二个参数
- 我们只要把这个
watcher
实例添加到Dep
中去就行了,然后数据变化会通知到watcher
,然后执行参数中的这个回调函数
class Watch {
constructor (expOrFn, cb) {
this.getter = parsePath(expOrFn)
this.cb = cb
this.value = this.get()
}
get () {
Dep.target = this
value = this.getter.call(vm, vm)
Dep.target = undefined
}
update () {
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldValue)
}
}
- 这段代码可以吧自己主动
push
到变化的Dep
中去.因为我在get
这个方法中,先把Dep.target
设置成了this
,也就是当前watcher
实例,然后在读一下变化的值,因为有读取,肯定会触发getter
.触发了getter
上面封装的defineReactive
函数中有一段逻辑就会从Dep.target
里读取一个依赖push
到Dep
中 - 依赖注入到
Dep
之后,当这个变化的值有所变化时,就把所有的依赖循环触发update
方法,update
方法会触发参数中的回调函数,监value
和oldValue
传到参数中 - 其实不管是用户执行的
vm.$watch('a.b.c', (value, oldValue) => {})
还是模板中用到的data,都是通过watcher
来通知自己是否需要发生变化
递归侦测所有的key
- 现在其实已经可以实现变化侦测的功能了,但是我们之前写的代码只能侦测数据中的一个key,所以我们要加工一下
defineReactive
这个函数
function walk (obj: Object) {
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) {
walk(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
}
dep.notify()
val = newVal
},
})
}
- 这样我们就可以通过执行
walk(data)
,把data
中的所有key
都加工成可以被侦测的,因为是一个递归的过程,所以key
中的value
如果是一个对象,那这个对象的所有key
也会被侦测
Array怎么进行侦测
- 现在考虑的是
data
中所有的value
都是对象和基本类型,但如果是一个数组怎么办?数组是没有办法通过Object.defineProperty
来侦测到行为的 - 在vue中对这个数组问题的解决方案非常简单粗暴,大体分三步
- 第一步:先把原生的
Array
的原型方法继承下来 - 第二步:对继承后的对象使用
Object.defineProperty
做一些拦截操作 - 第三步:把加工后可以被拦截的原型,赋值到需要被拦截的
Array
类型的数据的原型上
vue的实现
- 第一步:先把原生的
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
.forEach(function (method) {
const original = arrayProto[method]
Object.defineProperty(arrayMethods, method, {
value: function mutator (...args) {
console.log(method) // 打印数组方法
return original.apply(this, args)
},
enumerable: false,
writable: true,
configurable: true
})
})
- 现在可以看到,每当被侦测的
array
执行方法操作数组时,我都可以知道他执行的方法是什么,并且打印到console
中 - 现在我要对这个数组方法类型进行判断,如果操作数组的方法是
push
,unshift
,splice
,需要把新增的元素用上面封装的walk
来进行变化侦测,并且不论操作数组的是什么方法,我都要触发消息,通知依赖列表中依赖数据发生了什么变化
// 工具函数
function def (obj: Object, key: string, val: any, enumerable?: boolean) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
})
}
export class Observer {
value: any
dep: Dep
vmCount: number
constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
if (Array.isArray(value)) {
this.observeArray(value)
} else {
this.walk(value)
}
}
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
new Observer(items[i])
}
}
}
- 我们定义了一个
Observer
类,他的职责是将data
转换成可以被注册到变化的data
,并且新增了对类型的判断,如果是value
的类型是Array
,循环Array
将每个元素丢到Observer
中 - 并且在
value
上做了一个标记__ob__
,这样我们就可以通过value
的__ob__
拿到Observer
实例,然后使用__ob__
上的dep.notify()
就可以发送通知
[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
.forEach(function (method) {
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted)
// notify change
ob.dep.notify()
return result
})
})
- 可以看到谢了一个
switch
对method
进行判断,如果是push
,unshift
,splice
这种可以新增数组元素的方法就使用ob.observeArray(inserted)
把新增的元素也丢到Observer
中去转换成可以被侦测到变化的数据。 - 在最后不论操作数组的方法是什么,都会调用
ob.dep.notify()
去通知watcher
数据发生了改变.
arrayMethods 是怎么生效的
- 现在我们有一个
arrayMethods
是被加工后的Array.prototype
,那么怎么让这个对象应用到Array
呢? - 我们不能直接修改
Array.prototype
因为这样会污染全局的Array
,我们希望arrayMethods
只对data
中的Array
生效,所以我们只需要把arrayMethods
赋值给 value 的__proto__
上就好了
我们改造一下 Observer:
export class Observer {
constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
if (Array.isArray(value)) {
value.__proto__ = arrayMethods
this.observeArray(value)
} else {
this.walk(value)
}
}
}
// 不使用__proto__
const hasProto = '__proto__' in {}
export class Observer {
constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
if (Array.isArray(value)) {
// 修改
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value)
} else {
this.walk(value)
}
}
}
function protoAugment (target, src: Object, keys: any) {
target.__proto__ = src
}
function copyAugment (target: Object, src: Object, keys: Array<string>) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
def(target, key, src[key])
}
}
关于Array的问题
- 关于vue对
Array
的拦截,正因为这种实现方法,其实有些数组操作vue是拦截不到的
this.list[0] = 2
- 修改数组第一个元素的值,是无法侦测到数组的变化的,所以并不会触发
re-render
或者watch
等 this.list.length = 0
清空数组的操作,也是无法侦测到的- 因为vue的实现方法就决定了无法对上面的例子做拦截,也就没有办法做到响应,ES6是有能力做到的,在ES6之前是无法做到模拟数组的原生行为的,现在ES6的
Proxy
可以模拟数组的原生行为,也可以通过ES6的继承来继承数组原生行为,从而进行拦截.