- 为什么Array和Object的侦测方式不同
object通过getter/setter来实现侦测,但数组中有许多方法,如push来改变数组,但它并不会触发getter/setter。正因为我们可以通过Array原型上的方法来改变数组的内容,所有object那种通过getter/setter的实现方式就行不通了。
一、如何追踪变化
(1)可以用自定义的方法去覆盖原生的原型方法。
(2)可以使用一个拦截器覆盖Array.prototype。之后,每当使用Array原型上的方法操作数组时,其实执行的都是拦截器中提供的方法,比如push方法。然后,在拦截器中使用原生Array的原型方法去操作数组。
二、拦截器
(1)拦截器其实就是一个和Array.prototype一样的object,里面包含的属性一摸一样,只不过这个object中某些可以改变数组自身内容的方法是我们处理过的。
(2)Array原型中可以改变数组自身内容的方法有7个,分别是push、pop、shift、unshif、splice、sort和reverse。
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){
return original.apply(this,args)
},
enumerable:false,
writable:true,
configurable:true
})
})
1、创建了变量arrayMethods,它继承自Array.prototype,具备其所有功能。未来,要使用arrayMethods去覆盖Array.prototype。
2、在arrayMethods上使用Object.defineProperty方法将那些可以改变数组自身内容的方法(push、pop、shift、unshif、splice、sort和reverse)进行封装。
3、当使用push方法的时候,其实调用的是arrayMethods.push,而arrayMethods.push是函数mutator,也就是说,实际上执行的是mutator函数。
3、在mutator中执行original(它是原生Array.prototype上的方法,例如Array.push)来做它应该做的事,比如push的功能。
4、因此可以在mutator函数中做一些其他的事,比如发送变化通知。
三、使用拦截器覆盖Array原型
(1)有了拦截器之后,想要让它生效,就需要使用它去覆盖Array.prototype。但是又不能直接覆盖,因为这样回污染全局的Array。我们希望拦截器操作只针对那些被侦测了变化的数据生效,也就是说希望拦截器只覆盖那些响应式数组的原型。
(2)而将一个数据转换成响应式的,需要通过Observer,所以只需在Observer中使用拦截器覆盖那些即将被转换成响应式Array类型数据的原型就好了。
export class Observer{
constructor(value){
this.value = value;
if(Array.isArray(value)){
value._proto_ = arrayMethods;//新增
}else{
this.walk.(value)
}
}
}
1、value._proto_ = arrayMethods的作用是将拦截器(加工后具备拦截功能的arrayMethods)赋值给value._proto_,通过_proto_可以很巧妙地实现覆盖value原型的功能。
2、_proto_其实是Objec.getPrototypeOf和Object.setPrototypeOf的早期实现,所以使用ES6的Object.setPrototypeOf来代替_proto_完全可以实现相同的效果。
四、将拦截器方法挂载到数组的属性上
(1)虽然绝大多数浏览器都支持这种非标准的属性(EX6之前并不是标准)来访问原型,但并不是所有浏览器都支持!,因此需要处理不能使用_proto_的情况。
(2)Vue的做法非常粗暴,如果不能使用_proto_,就直接将arrayMethods身上的这些方法设置到被侦测的数组上。
export class Observer{
constructor(value){
this.value = value;
if(Array.isArray(value)){
const augment = hasProto
?protoAugment
:copyAugment
augment(value,arrayMethods,arrayKeys);
}else{
this.walk.(value)
}
}
function protoAugment(target,src,keys){
target._proto_ = src
}
function copyAugment(target,src,keys){
for(let i=0,l=keys.length;i<l;i++){
const key = keys[i];
def(target,key,src[key])
}
}
}
1、新增了hasProto来判断当前浏览器是否支持_proto_。
2、新增了copyAugment函数,用来将已经加工了拦截操作的原型方法直接添加到value的属性中。
3、在浏览器不支持_proto_的情况下,会在数组上挂载一些方法。当用户使用这些方法时,其实执行的并不是浏览器原生提供的Array.prototype上的方法,而是拦截器中提供的方法。因为当访问一个对象的方法时,只有其自身不存在这个方法,才会去它的原型上找这个方法。
五、如何收集依赖
(1)数组在getter中收集依赖,在拦截器中触发依赖。
(2)Array的依赖和Object一样,也在defineReactive中收集。
function defineReactive(data,key,val){
if(typeof val==='object'){
new Observer(val)
}
let dep = new Dep();
Object.defineProperty(data,key,{
enumerable:true,
get:function(){
dep.depend()
//这里收集Array的依赖
return val
}
set:function(newVal){
if(val==newVal){
return;
}
val = newVal;
dep.notify();
})
}
新增一段注释,接下来要在这个位置去收集Array的依赖。
六、依赖列表存在哪儿
(1)Vue.js把Array的依赖存放在Observer中
export class Observer{
constructor(value){
this.value = value;
//新增dep
this.dep = new Dep();
if(Array.isArray(value)){
const augment = hasProto
?protoAugment
:copyAugment
augment(value,arrayMethods,arrayKeys);
}else{
this.walk.(value)
}
}
function protoAugment(target,src,keys){
target._proto_ = src
}
function copyAugment(target,src,keys){
for(let i=0,l=keys.length;i<l;i++){
const key = keys[i];
def(target,key,src[key])
}
}
}
(2)数组在getter中收集依赖,在拦截器中触发依赖,所以这个依赖保存的位置很关键,它必须在getter和拦截器中都可以访问到。
(3)之所以将依赖保存在Observer实例上,是因为在getter中可以访问到Observer实例,同时在Array拦截器中也可以访问到Observer实例。
七、收集依赖
(1)把Dep实例保存在Obeserver的属性上后,可以在getter中像下面这样访问并收集依赖
function defineReactive(data,key,val){
<!-- 修改 -->
let childOb = observe(val);
let dep = new Dep();
Object.defineProperty(data,key,{
enumerable:true,
get:function(){
dep.depend();
<!-- 新增 -->
if(childOb){
childOb.dep.depend()
}
return val
}
set:function(newVal){
if(val==newVal){
return;
}
val = newVal;
dep.notify();
}
})
}
<!-- 尝试为value创建一个Observer实例,
如果创建成功,直接返回新创建的Observer实例。
如果value已经存在一个Observer实例,则直接返回它 -->
export function observe(value,asRootData){
if(!isObject(value)){
return
}
let ob;
if(hasOwn(value,'_ob_')&&value._ob_ instanceof Observer){
ob = value._ob_
}else{
ob = new Observer(value);
}
return ob;
}
1、新增了函数observe,它尝试创建一个Observer实例。如果value已经是响应式数据了,不需要再次创建Observer实例,直接返回已经创建的Observer实例即可,避免了重复侦测value变化的问题。
2、在defineReactive函数中调用了observe,它把val当作参数传了进去并拿到一个返回值,那就是Observer实例。
3、defineReactive函数中的val很有可能会是一个数组。通过observe我们得到了数组的Observer实例(childOb),最后通过childOb的dep执行depend方法来收集依赖。
4、通过这种方式,我们就可以实现在getter中将依赖收集到Observer实例的Dep中。更通俗的解释是:通过这样的方式可以为数组收集依赖。
八、在拦截器中获取Observer实例
(1)因为Array拦截器是对原型的一种封装,所以可以在拦截器中访问到this(当前正在被操作的数组)。
(2)而dep保存在Observer中,所以需要在this上读到Observer的实例
<!-- 工具函数 -->
function def(obj,key,val,enumerable){
Object.defineProperty(obj,key,{
value:val,
enumerable:!!enumerable,
writable:true,
configurable:true
})
}
export class Observer{
constructor(value){
this.value = value;
this.dep = new Dep();
<!-- 新增 -->
def(value,'_ob_',this)
}
if(Array.isArray(value)){
const augment = hasProto
?protoAugment
:copyAugment
augment(value,arrayMethods,arrayKeys);
}else{
this.walk.(value)
}
。。。
}
1、在Observer上新增了一段代码,它可以在value上新增一个不可枚举的属性_ob_,这个属性的值就是当前Observer的实例。
2、_ob_的作用不仅仅是为了在拦截器中访问Observer实例,还可以用来标记当前value是否被Observer转换成了响应式数据。
3、所有被侦测了变化的数据身上都会有一个_ob_属性来表示它们是响应式的。
4、当value身上被标记了_ob_之后,就可以通过value._ob_来访问Observer实例。如果是Array拦截器,因为拦截器是原型方法,所以可以直接通过this._ob_来访问Observer实例。
[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
.forEach(function(method){
<!-- 缓存原始方法 -->
const original = arrayProto[method];
Object.defineProperty(arrayMethods,method,{
value:function mutator(...args){
<!-- 新增 -->
const ob = this._ob_;
return original.apply(this,args)
},
enumerable:false,
writable:true,
configurable:true
})
})
在上面代码中,我们在mutator函数里通过this._ob_来获取Observer实例。
九、向数组的依赖发送通知
(1)当侦测到数组发生变化时,会向依赖发送通知。只需要在Observer实例中拿到dep属性。然后直接发送通知就可以了。
[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
.forEach(function(method){
<!-- 缓存原始方法 -->
const original = arrayProto[method];
def(arrayMethods,method,function mutator(...args){
<!-- 新增 -->
const ob = this._ob_;
//向依赖发送通知
ob.dep.notify()
return original.apply(this,args)
}
}
十、侦测数组中元素的变化
(1)侦测数组的变化,指的是数组自身的变化,比如是否新增一个元素,是否删除一个元素。其实数组中保存了一些元素,它们的变化也是需要侦测的。比如,当数组中object身上某个属性的值发生了变化时,也需要发送通知。
(2)如果用户使用push往数组中新增了元素,这个新增元素的变化也需要侦测。
(3)即所有响应式数据的子数据都要侦测,不论是object中的数据还是Array中的数据。
(4)在Observer中新增一些处理,让它可以将Array也转换成响应式的。
export class Observer{
constructor(value){
this.value = value;
def(value,'_ob_',this)
if(Array.isArray(value)){
this.observeArray(value)
}else{
this.walk.(value)
}
}
<!-- 侦测Array中的每一项 -->
observeArray(items){
for(let i =0,l=items.length;i<l;i++){
observe(items[i])
}
}
}
1、新增了observeArray方法,其作用是循环Array中的每一项,执行observe函数来侦测变化。
2、observe函数,其实就是将数组中的每个元素都执行一遍new Observer,这很明显是一个递归的过程。
3、现在只要将一个数据丢进去,Observer就会把这个数据的所有子数据转换成响应式的。
十一、侦测新增元素的变化
(1)数组中有一些方法是可以新增数组内容的,比如push,而新增的内容也需要转换成响应式来侦测变化,否则会出现修改数据时无法触发消息等问题。因此,我们必须侦测数组中新增元素的变化。
(2)实现:只要能获取新增的元素并使用Observer来侦测它们就行。
(3)获取新增元素
1、想要获取新增元素,我们需要在拦截器中对数组方法的类型进行判断。如果操作数组的方法是push、unshift和splice(可以新增数组元素的方法),则把参数中新增的元素拿过来,用Observer来侦测。
2、通过switch对method进行判断,如果method是push、unshift和splice这种新增元素的方法,那么从args中将新增元素取出来,暂存在inserted中。
3、接下来要使用Observer把inserted中的元素转换成响应式的。
(4)使用Observer侦测新增元素
1、Observer会将自身的实例附加到value的_ob_属性上。所有被侦测了变化的数据都有一个_ob_属性,数组元素也不例外。
2、因此,我们可以在拦截器中通过this访问到_ob_,然后调用_ob_上的observeArray方法就可以了
;[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
.forEach(function(method){
<!-- 缓存原始方法 -->
const original = arrayProto[method];
def(arrayMethods,method,function mutator(...args){
const ob = this._ob_;
const result = original.apply(this,args);
let inserted;
switch(method){
case:'push':
case:'unshift':
inserted = args
break;
case:'splice':
inserted = args.slice(2);
break;
}
if(inserted) ob.observeArray(inserted);
ob.dep.notify()
return result;
})
})
3、从this._ob_上拿到Observer实例后,如果有新增元素,则使用ob.observeArray来侦测这些新增元素的变化。
十二、关于Array的问题
(1)Array的变化侦测是通过拦截原型的方式实现的。正是因为这种实现方式,其实有些数组操作Vue.js是拦截不到的。例如
- this.list[0] = 2;
修改数组第一个元素的值时,无法侦测到数组的变化,所以不会触发re-render或watch等。
- this.list.length = 0;
这个清空数组操作也无法侦测到数组的变化,所以也不会触发re-render或watch等。
十三、总结
(1)Array追踪变化的方式和object不一样。因为它是通过方法来改变内容的,所以我们通过创建拦截器去覆盖数组原型的方法来追踪变化。
(2)为了不污染全局Array.prototype,我们在Observer中只针对那些需要侦测变化的数组使用_proto_来覆盖原型方法,但_proto_在ES6之前并不是标准属性,不是所有浏览器都支持它。因此,针对不支持_proto_属性的浏览器,我们直接循环拦截器,把拦截器中的方法直接设置到数组身上来拦截Array.prototype上的原生方法。
(3)Array收集依赖的方式和object一样,都是在getter中收集。但是由于使用依赖的位置不同,数组要在拦截器中向依赖发消息,所以依赖不能像Object那样保存在defineReactive中,而是把依赖保存在了Observer实例上。
(4)在Observer中,我们对每个侦测了变化的数据都标上了印记_ob_,并把this(Observer实例)保存在_ob_上。这主要有两个作用,一方面是为了标记是否被侦测了变化(保证同一个数据只被侦测一次),另一方面可以很方便得通过数据取到_ob_,从而拿到Observer实例上保存的依赖。当拦截到数组发生变化时,向依赖发送通知。
(5)除了侦测数组自身的变化外,数组中元素发生的变化也要侦测。我们在Observer中判断如果当前被侦测的数据是数组,则调用oberveArray方法将数组中每一个元素都转换成响应式的并侦测变化。
(6)除了侦测已有数据外,当用户使用push等方法向数组中新增数据时,新增的数据也要进行变化侦测,我们使用当前操作数组的方法来判断,如果是push、unshift和splice方法,则从参数中将新增数据提取出来,然后使用observeArray对新增数据进行变化侦测。
(7)由于在ES6之前,JavaScript并没有提供元编程的能力,所以对于数组类型的数据,一些语法无法追踪到变化,只能拦截原型上的方法,而无法拦截数组特有的语法。例如使用length清空数组的操作就无法拦截。