在vuejs2.x中,对象和数组的变化侦测是不同的,本篇主要是对象变化侦测,数组变化侦测以及一些与变化侦测有关的API的学习笔记
Object的变化侦测
1.如何知道object中的属性发生了变化(变化侦测)
function definReactive(data,key,val){
Object.defineProperty(data,key,{
enumerable:true,
configurable:true,
get:function(){
// do something
},
set:function(newVal){
// do something
}
}
}
通过设置这样的一个函数,我们在读取对象属性的时候会触发这个属性的get方法,当设置属性的时候会触发这个属性的set方法。所以我们在初始化data的时候,会将data设置为响应式,也就是循环遍历data中我们设置的属性,每一个属性都调用上述方法变成响应式,这样,我们操作属性的时候,无论是读还是取,都能知道并且做相应的操作。
2.如何知道有哪些地方用到了这个属性(收集依赖)
之所以能够实现响应式,就是数据变化了之后通知用到这个数据的地方。回忆一下我们如何使用data中定义的数据,可以花括号绑定在模板上面,还可以做条件判断,还可以用watch监听,还可以在methods中做一些逻辑操作等等,所以说。一个属性会有多个依赖,组件中的很多地方都会使用到这个属性,而使用到的地方就都是这个属性的依赖。前面已经知道读取这个属性的时候会触发get方法,所以收集依赖的工作可以在get中进行,每读取一遍,就向这个属性的依赖数组中添加一个当前依赖。这样我们就知道了有哪些地方用到了这个属性。
3.添加依赖用一个push方法就可以搞定,可是我们要push进去的是什么?当前依赖要怎么表示?
依赖的形式五花八门,有可能是在模板当中,有可能是我们写的一个watch。这种情况最好集中处理,抽象出一个类,我们在收集依赖的时候,把这个类的实例收集进来,这样依赖就都是同一个形式了,后续的通知更新等操作也就更方便了。这个类就是Watcher,我们收集的也就是watcher的实例。
export default class watcher{
// exporFn 就是我们要watch的对象比如:obj.a.b,cb 就是监听过后的回调操作
// this.$watch(obj.a.b,function(val,newval){ // do something })
constructor(vm,expOrFn,cb){
this.vm = vm
// parsePath这个方法就是一个剥洋葱的过程,把N层obj的最里面的对象的值提取出来,返回一个函数,当这个函数被执行,就意味着读取了这个属性
this.getter = parsePath(expOrFn)
// 得到了新的值,然后执行回调
this.cb = cb
this.value = this.get()
}
get(){
// 把当前实例赋值给window.target
window.target = this
// 这一步就是读取了,会触发属性的get操作
let value = this.getter.call(this.vm,this.vm)
// 添加完了就设置为undefined避免重复添加
window.target = undefined
return value
}
update(){
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm,this.value,oldvalue)
}
}
到此,也就有点明白了,向数组中push的是window.target。而window.target恰好就是当前watcher的实例。有人可能会疑问,是写了个类在这,里面也有一系列的逻辑操作。可是它在哪执行了?哪里触发这个类执行了。那赶快去回忆一下new一个对象的过程。先创建一个空对象,然后把这个空对象的原型指向这个类的原型,然后要执行call方法把当前this传入到父级的构造函数中,从而使用这个构造函数中的属性与方法。所以说new Watcher()的时候,就会触发这一系列操作啦!而watcher实例就可以把自己挂在window.target上,而依赖数组就可以push(window.target)。这样,就完成了一个依赖的添加。而很明显,update操作是在set方法中进行,有新的值就触发watcher的update方法。所以每一个依赖就是一个new Watcher()。
至此就完成了对象响应式的逻辑:先把对象用defineProperty进行变化侦测,然后在get中收集依赖,每读取一次当前属性,就进行一次new Watcher(),把当前watcher实例push到这个属性的依赖数组里。每当设置属性时,就触发set方法,然后循环遍历当前属性的依赖列表,触发每一项的update方法从而实现更新操作。
正是因为使用Object.defineProperty来进行变化侦测,所以对象的响应式也存在一些问题:不能监听到对象属性的增加和删除。所以也才会有$set和$delete这两个API
Array的变化侦测
为什么array的变化侦测和object的变化侦测方式不同?
因为array有很多可以改变自身的方法:push,pop,shift,unshift,splice,sort,reverse。而Object.defineProperty做不到对这些方法的监听或者拦截。所以Array的变化侦测要重新设计。
Object的设置操作有set可以知道,那array的push操作怎么知道呢?
答案是拦截器。使用一个拦截器覆盖Array.prototype。之后每当我们使用array原型上的方法操作数组的时候,执行的都是拦截器的方法,然后在拦截器里面使用原生的Array方法去操作数组。
const arrayProto = Array.prototype // 保存原生数组的原型
export const arrayMethods = Object.create(arrayProto) // 创建一个以原生数组为原型的空对象(这个对象就是要覆盖原来原型的对象)
['push','pop','shift','unshift','splice','sort','reverse'].forEach(function(method){
// 先缓存原始数组原型的方法
const original = arrayProto[methods]
Object.defineProperty(arrayMethods,method,{
value:function mutator(...args){
// do something
return original.apply(this.args)
},
enumerable:false,
writable:true,
configurable:true
})
}
这样,当我们使用push方法操作数组的时候,会被arrayMethods拦截,从而会执行内部的mutator函数,在mutator函数中,我们可以做一些通知变化的操作,就像是在对象中的set所做的那样。之后再调用原生的push方法,对数组进行正常的push操作。
怎么使用自定义的拦截器函数覆盖原来的原型?
我们不能暴力的直接覆盖在Array上,因为会造成全局污染。我们只需要去覆盖那些需要侦测变化的数组。所以在Observer类中(设置响应式数据的类)如果是对象就走对象的设置流程,如果是数组,那就把它的__proto__设置为自定义的arrayMethods对象,这样就实现了数组的变化侦测。(如果浏览器不支持__proto__这个方法,那就循环遍历拦截器,把拦截器中的属性直接设置在要侦测的数组上,因为在js中实例自身有方法就不会通过原型链调用父级的方法,直接实现了覆盖)
现在已经可以监测到数组的操作变化了,那变化了通知谁呢?通知用到了它的地方,也就是数组的依赖。怎么收集数组的依赖?
data(){
return{
list:[1,2,3,4,5]
}
}
和Object一样,在getter中收集依赖。回顾一下我们在模板中使用数组的方法:如果我们想要使用list,会通过this.list进行访问,所以还是对对象的属性进行读取操作。所以也由此得出结论:和对象一样,在getter中收集依赖。
所以数组设置响应式数据的流程为:通过对象属性访问数组的时候,会向数组依赖中添加自己(也是一个watcher实例),当数组发生改变时(也就是操作上述的7个方法),会被拦截器拦截,在mutator函数中,向依赖发送数据变化的通知,从而实现数组的响应式。此外,数组的响应式还对数组中的每一项进行了设置,但如果数组项不是一个object的话,就不会进行响应式设置,这也就是为什么改变数组某一个下标的值不会引起视图更新的原因。同时,数组的响应式还对7个方法中的数组具体项变化进行了设置,也就意味着通过push新增的数组项也会被设置成响应式。
也正是由于数组是通过拦截器进行响应式设置,所以数组中的某些操作也不是响应式的(不会触发视图更新)。比如修改具体项this.list[0] = 2就不会触发视图更新,代码中本可以实现,但在2.0中没有选择实现。this.list.length = 0这样的操作在2.0的实现中也无法被侦测。
与变化侦测相关的API
$watcher
$watcher可以利用watcher来实现,只不过我们在使用$watcher的过程中可以传入两个选项参数:immediate和deep,所以就是在Watcher的基础上增加了一些其它的代码逻辑
Vue.prototype.$watcher = fucntion(expOrFn,cb,options){
const vm = this
options = options || {}
const watcher = new Watcher(vm,expOrFn,cb,options)
if(options.immediate){
// 当传入immediate选项的时候,立刻以当前的值执行一遍
cb.call(vm,watcher.value)
}
// 返回一个用来取消观察观察数据的函数
return functionWatchFn(){
watcher.teardown()
}
}
$watcher在内部使用了Watcher的逻辑,此外添加了选项判断以及返回了一个用于取消观察的函数, 当执行了这个函数的时候,本质就是把watcher实例从当前正在观察的状态依赖列表中移除,所以不光是别的状态要收集watcher依赖,当前watcher也要知道自己订阅了哪些状态。(因为expOrnFn支持函数,我们都知道,在Watcher内部调用getter函数的时候回触发依赖收集的逻辑,而expOrFn是一个函数的话,就直接将这个函数赋值给当前watcher的getter,实例化的时候执行一遍,那么在函数体中用到的多个的状态都会订阅当前的watcher)
那watcher怎么知道自己都订阅了谁呢?
所以在watcher中要创建一个数组,用来保存自己订阅的状态;还要创建一个set类型的数据结构,用id来标识唯一性(已经加入到订阅数组中的状态信息避免重复加入)。当watcher开始读取状态信息时,会将自己挂在window.target上,状态的依赖类就会判断,如果window.target为真,就执行window.target.addDep(this)。很明显,addDep是watcher的一个方法,它是将当前状态的依赖实例添加到我们刚才在watcher中创建的数组中,为当前依赖实例添加一个ID标识加入到我们刚才创建的set结构中,这样,watcher就保存了一个订阅信息。再次触发watcher时,只需要判断该状态依赖实例的id是否存在,从而实现了避免重复添加。而watcher中通过执行addDep的方法,向dep(状态依赖)中添加this,也就是当前watcher实例。这样就实现了相互保存信息,这是一个多对多的关系。我们在执行watcher.teardown()的时候,只需要遍历watcher中保存订阅状态的数组,依次remove即可。
deep的实现原理
watcher想要监听某个数据,就会触发某个数据的依赖收集逻辑,将自己收集进去,然后当它发生变化时,就会通知watcher。想要实现deep的功能,就是除了要触发当前这个被监听数据的依赖收集逻辑之外,还要触发当前依赖所有子集的依赖收集逻辑,使得他们发生变化的时候也去通知watcher。
我们知道,触发依赖收集就是要去读一遍数据,通过触发它的访问器get方法进行依赖收集。所以我们就可以手动读取当前监听数据的子数据(一定要在window.target = undefined之前读取,不然deep会失效。)没错,只需要手动读取,就可以将当前watcher加入到子级的依赖列表中,这样当子级数据发生改变,也会通知到watcher。
$set
同样分为数组处理和对象处理两种情况:
数组处理:如果我们通过this.$set的方法设置数组中的某一个下标的值。在set方法的内部使用splice方法实现增改的,这样就会触发数组的拦截器逻辑,从而自动将这个val转换成响应式的。
export function set(target,key,val){
if(Array.isArray(target) && isValidArrayIndex(key)){
target.length = Math.max(target.length,key)
target.splice(key,1,val)
return val
}
}
对象处理:处理对象的新增属性使它变为响应式的,只需要将数据传入到defineReactive函数中(回顾Object响应式),将它变为响应式的即可。(当前操作对象不能是vue的根实例)
$delete
删除属性的控制逻辑也很简单,如果当前操作对象不是vue的根数据对象,并且当前要删除的属性存在于这个对象,并且当前对象已经转换为响应式,则删除了数据之后,手动向他们的依赖发送通知。从而实现响应式。
export function del(target,key){
if(Array.isArray(target) && isValidArrayindex(key)){
// 触发数组的拦截器
target.splice(key,1)
return
}
// 被转换为响应式的数据会被增加__ob__属性,通过这个来判断数据是否已经设置为响应式数据
const ob = target.__ob__
// target 为当前根实例时,发出警告
if(target._isVal || (ob && ob.vmCount)){
process.env.NODE_ENV ! == 'production' && warn('xxx')
}
// 如果当前删除属性不是target中的属性,就没必要响应了
if(hasOwn(target,key)){
return
}
// 进行删除
delete target[key]
// target不是响应数据,就没必要设置响应了
if(!ob){
return
}
// 发送通知,进行数据更新,通知组件进行重新渲染
ob.dep.notify()
}
本篇笔记参考《深入浅出Vue.js》 刘博文 著