变化侦测主要分为两种类型,一种是“推”(push),另一种是“拉”(pull)。
Angular和React中的变化侦测都属于“拉”,这就是说当状态发生变化时,它不知道那个状态变了,只知道状态有可能变,然后会发送一个信号告诉框架,框架内部接收到信号后,会进行一个暴力比对来找出哪些DOM节点需要重新渲染。这在Angular中是脏数据检查的流程,在React中使用的是虚拟DOM。
而Vue.js的变化侦测属于推。当状态发生变化时,Vue.js立刻就知道哪发生了变化。“拉”的粒度是较大的,而“推”需要绑定的依赖相对较多,所以粒度较小,开销也相对较大。所以vue2之后引用虚拟DOM,将粒度调整为中等粒度。
Vue侦测系统的三个核心:Observer、Dep、Watcher
Observe:遍历data中的属性,使用object.defineProperty的get/set方法对其进行数据劫持。
Dep:每个属性拥有自己的消息订阅器Dep,用于存放所有订阅了该属性的依赖。
Watcher:中介对象。通过Dep实现对相应属性的监听,监听到结果后,会通知其它地方进行相应操作。
这三个是主要核心,看不懂没关系,下面再慢慢道来。
很显然,JS给我们提供了两个方法,一是使用Object.defineProperty的get/set方法,二是使用ES6中的proxy。总所周知VUE3.0采用了proxy实现数据侦测,从18到20年了vue3还是没正式发布,所以我们这先讨论使用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属性进行变化追踪。当data的key数据读取时,get函数则触发,当给data的key赋值时,set函数则触发。
现在我们知道如何检测一个属性发生变化了,当没什么用啊,我们得知道哪些地方用到这属性,当变化的时候告诉它啊!所以我们要收集依赖。
我们需要把用到的依赖都收集起来,然后等属性发生变化时,把之前收集好的依赖遍历触发一遍就好了。在这里,我们定义了一个数组Dep,用来保存依赖对象。假设依赖是一个函数,保存在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
}
})
}
这里我们可以看到,当这个依赖读取该属性时,会自动触发get方法,将其添加到依赖数组里面,当该属性发生变化时,会自动触发set方法遍历dep数组从而通知每个订阅者。
这样写耦合度较高,我们将Dep单独封装成一个类:
export default class Dep {
constructor() {
this.subs = []
}
addSub(sub) {
this.subs.push(sub)
}
removeSub(sub) {
this.removeSub(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)
}
}
}
function defineReactive(data, key, val) {
let dep = new Dep() //实例化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,但这个window.target到底是什么?watcher其实是一个中介角色,数据发生变化时,通知它,它再通知其他地方。
我们要知道用到数据的地方,而使用这个数据的地方很多,而且类型不同,即有可能是模板,也有可能是用户写的watch,所以我们需要抽象出一个能集中处理这些情况的类。我们在收集依赖的时候只收集这个封装好的类,通知也只通知它一个,接着,它再负责通知其他地方。
看一个经典的使用方式:
//keypath
vm.$watch('a.b.c',function(newVal,oldVal){
//触发事件
})
当a.b.c属性发生变化的时候,触发第二参数中的函数。结合上面我们我可以定义一个watcher方法,我们把这个watcher实例添加到data.a.b.c属性的Dep中就行了。
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.getter()
}
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)
}
}
因为在get方法中先把window.target设置为this,也就是当前watcher实例,然后执行this.getter读取一下data.a.b.c的值,这就会触发defineReactive中的get方法,将this添加到Dep中,完成依赖收集。之后再将window.target初始化为undefined。
依赖注入到Dep之后,每当触发data.a.b的值发生变化时,就会让依赖列表中所有的依赖循环触发update方法,也就是Watcher中的update方法。而update方法执行参数中的回调函数,将value和oldValue传到参数中。那这里有个问题:当我们访问侦测属性的时候都会自动加入Dep,当多次访问后,Dep里面不就有好几个相同的Watcher?这个问题留到后面再聊。其实处理起来也很简单,只是作下判断即可。
在上面我们已经实现了变化侦测的功能,现在只需要封装一个Observer类,递归遍历数据中所有的属性(包括子属性)就好了。这个类的作用是将一个数据中所有属性都转换为getter/setter的形式,然后去追踪它们的变化:
//Object类回附加到每一个被侦测的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继续递归遍历。在这里,我们只介绍了object的遍历,数组的遍历之后章节再聊!
前面我们知道object类型的变化侦测原理,但是其只能侦测到属性的修改,有些语法是侦测不到的,如下:
var vm = new VTTCue({
el: '#el',
template: '#demo-template',
methods: {
action1() {
this.obj.age = 12
},
action1() {
delete this.obj.name
}
},
data: {
obj: {
name:"binguo"
}
}
})
我们新增或者删除某个属性的时候,是不会被侦测到的。这也是object.definePorperty的缺陷,它只能侦测到属性是否被修改,无法追到新增或者删除属性。
为了弥补这缺陷,vue.js也给我们提供了两个API:vm.$set和vm.$delete
1.Data通过Observer遍历每个属性通过getter/setter方法进行数据的侦听。
2.当外界通过Watcher读取数据时,会触发getter从而将watcher添加到依赖中。
3.当数据变化时,会触发setter,从而向Dep中的依赖(Watcher)发送通知。
4.Watcher接收到通知后,会向外界发送通知,外界进行相应操作。
注:本文主要参考自《深入浅出Vue.js》