Array
的变化侦测
1. 如何追踪变化
用自定义的方法去覆盖原生的原型方法:
使用一个拦截器覆盖Array.prototype
,每当使用Array
原型上的方法操作数组时,其实执行的方法都是拦截器中提供的,然后在拦截器中使用原生Array
的原型方法去操作数组。
2. 拦截器
拦截器其实就是一个和Array.prototype
一样的Object
,里面包含的属性一毛一样,只不过这个Object
中某些可以改变数组自身内容的方法是我们处理过的。 Array
原型上可以改变数组自身内容的方法有7个:push
,pop
,shift
,unshift
,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:true,
writable:true,
configurable:true
})
})
对以上代码进行解读:
创建了变量arrayMethods
,它继承自Array.prototype
,具备其所有功能。
在arrayMethods
上使用Object.defineProperty
方法将那些=可以改变数组自身内容的方法进行封装。
所以在使用数组的原型方法push
时,其实调用的是arrayMethods
上的push
方法,而arrayMethods.push
是函数mustator
,也就是说,实际上执行的是mustator
函数。
在mustator
中执行original
(它是原生Array.prototype
上的方法)来做它应该做的事。
可以在mustator
函数中发送变化通知。
3. 使用拦截器覆盖Array
原型
拦截器想要生效,就必须去覆盖Array.prototype
,但是又不能直接覆盖,会污染全局的Array
。只希望拦截操作只针对那些被侦测了变化的数据生效,也就是说希望拦截器只覆盖那些响应式数组的原型。
而将一个数据转换成响应式的,需要通过Observer
,所以我们只需要在Observer
中使用拦截器覆盖那些即将被转换成响应式Array
类型数据的原型就好了。
export class Observer{
constructor(value){
this.value = value;
if(Array.isArray(value)){
value.__proto__ = arrayMethods; //新增
}else{
this.walk(value);
}
}
}
新增代码的作用是将拦截器(加工后具备拦截功能的arrayMethods
)赋值给value.__proto__
,通过__proto__
可以很巧妙地实现覆盖value
原型的功能。
4. 将拦截器方法挂载到数组的属性上
Vue
的做法非常的粗暴,如果不能使用__proto__
,就直接将arrayMthods
身上的这些方法设置到被侦测的数组上:
import {arrayMethods} from './js/array'
// __proto__是否可用
const hasProto = `__proto__` in {}
const arrayKeys = Object.getOwnPropertyNames(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;i<keys.length;i++){
const key = keys[i];
def(target,key,src[key]);
}
}
新增hasProto
来判断当前浏览器是否支持__proto__
,新增copyAugment
函数,用来将已经加工了拦截操作的原型方法直接添加到value
的属性中。hasProto
判断浏览器是否支持__proto__
:如果支持,只用protoAugment
函数来覆盖原型;如果不支持,调用copyAugment
函数将拦截器中的方法挂载到value
上。
在浏览器不支持__propt__
的情况下,会在数组上挂载一些方法,当用户使用这些方法时,其实执行的并不是浏览器原生提供的Array.prototype
上的方法,而是拦截器中提供的方法。
5. 如何收集依赖
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,
configurable:true,
get:function(){
dep.depend();
// 这里收集Array的依据
return val;
},
set:function(newVal){
if(val == newVal){
return;
}
dep.notify()
val = newVal
}
})
}
上面的代码增加了一行注释,就在这个位置上收集Array
的依赖
Array
在getter
中收集依赖,在拦截器中触发依赖
6. 依赖列表存在哪里?
Vue.js
把Array
的依赖存放在Observer
中:
export class Observer{
constructor(value){
this.value = value;
this.dep = new Dep() //新增dep
if(Array.isArray(value)){
const augment = hasProto?protoAugment:copyAugment
augment(value,arrayMethods,arrayKeys)
}else{
this.walk(value);
}
}
}
之所以将依赖保存在Observer
实例上,是因为在getter
中可以访问到Observer
实例,同时在Array
拦截器中也可以访问到Observer
实例。
7. 收集依赖
把Dep
实例保存在Observer
的属性上之后,我们可以在getter
中像下面这样访问并收集依赖:
function defineReactive(data,key,val ){
let childOb = observer(val) //修改
/* 调用observer,把val当作参数传进去得到一个返回值Observer */
let dep = new Dep();
Object.defineProperty(data,key,{
enumerable:true,
configurable:true,
get:function(){
dep.depend()
// \新增
if(childOb){
childOb.dep.depend()
}
},
set:function(newVal){
if(val = newVal){
return
}
dep.notify()
val = newVal
}
})
}
/*
尝试为value创建一个Observer实例
如果创建成功,直接返回新创建的Observer实例
如果value已经存在一个Observer实例,则直接返回它
*/
export function observer(value,asRootData){
if(!isObject(value)){
return;
}
let ob;
if(hasOwn(value,'__ob__')&&value.__ob__ instanceof Observer){
ob = value.__ob__
}else{
ob = new observer()
}
return ob;
}
新增observer
函数,它尝试创建一个Observer
实例。如果value
已经是响应式数据,不需要再次创建Observer
实例,直接返回已经创建的Observer
实例,避免了重复侦测value
变化的问题。
在defineReactive
函数中调用了observer
,它把val
当作参数传了进去并且拿到一个返回值,那就是Observer
实例。
通过observer
得到了数组的Observer
实例(childOb
),最后通过childOb
的dep
执行depend
方法来收集依赖。
通过这种方式,我们就可以实现getter
中将依赖收集到Observer
的实例dep
中。
8. 在拦截器中获取Observer
实例
因为Array
拦截器是对原型的一种封装,所以可以在拦截器中访问到this
(当前正在被操作的数组)。而dep
保存在Observer
中,所以需要在this
上读到Observer
的实例:
// 工具函数
function def(obj,key,val,enumerable){
Object.defineProperty(obj,key,{
value:val,
enumerable:!!enumerable,
configurable:true,
writable: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)
}
}
}
当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__; //新增
},
enumerable:true,
writable:true,
configurable:true
})
})
9. 向数组的依赖发送通知
当侦测到数组发生变化时,会向依赖发送通知。首先要能访问到依赖,只需要在Observer
实例中拿到dep
属性,然后直接发送通知:
['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__;
ob.dep.notify() //向依赖发送消息
return result;
})
})
调用了ob.dep.notify()
去通知依赖(Watcher
)数据发生了变化。
10.侦测数组中元素的变化
所有响应式数据的子数据都要侦测,不论是Object
中的数据还是Array
中的数据。
export class Observer{
constructor(value){
this.value = value;
def(value,"__ob__",this)
// 新增
if(Array.isArray(value)){
this.observeArray(value)
}else{
this.walk()
}
}
/*
侦测Array中的每一项
*/
observerArray(items){
for(let i= 0;i<items.length;i++){
observer(items[i])
}
}
// ...
}
新增observerArray
方法,作用是循环Array
中的每一项,执行observer
函数来侦测变化。
11. 侦测新增元素的变化
新增的内容需要转换成响应式来侦测变化,否则会出现修改数据时无法触发消息等问题。我们必须侦测数组中新增元素的变化。只需要在拦截器中对数组方法的类型进行判断。
1. 获取新增元素
如果操作数组的方法是push
,unshift
,splice
(可以新增数组元素的方法)则把参数中新增的元素拿过来,用Observer
来侦测:
['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;
}
ob.dep.notify();
return result;
})
})
通过switch
对methos
进行判断,如果method
是push
,unshift
,splice
这种可以新增数组元素的方法,那么args
中将新增元素取出来,暂存在inserted
中。
2. 使用Observer
侦测新增元素
前面介绍过Observer
会将自身的实例附加到value
的__ob__
属性上,所有被侦测了变化的数据都有一个__ob__
属性,数组元素也不例外。 我们可以在拦截器中通过this
访问到__ob__
,然后调用__ob__
上的observerArray
方法:
['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 'shift':
inserted = args
break;
case 'splice':
inserted = args.slice(2);
break;
}
if(observer){
ob.onserverArray(inserted);//新增
}
ob.dep.notify();
return result;
})
})
从this.__ob__
上拿到Observer
实例后,如果有新增元素,则使用ob,observerArray
来侦测这些新增元素的变化。
12. 关于Array
的问题
对Array
的变化侦测是通过拦截原型的方式实现的,正是因为这种实现方式,其实有些数组操作Vue.js
是拦截不到的,例如:
this.list[0] = 2;
修改数组的第一个元素的值时,无法侦测到数组的变化,所以并不会触发re-render
或者watch
等。
例如:
this.list.length = 0
清空数组的操作也无法侦测到数组的变化,所以也不会触发re-render
或者watch
等。
因为Vue.js
无法对以上的例子做拦截,就没有办法响应。作者猜测未来会使用到ES6
提供的Proxy
来实现,现在这个猜测被证实了,Vue.js 3.0
确实使用了Proxy
进行了处理,不得不佩服作者的高瞻远瞩。
13. 总结
Array
追踪变化的方式和Object
的不一样。因为它是通过方法来改变内容的,所以我们通过创建拦截器去覆盖数组原型的方法来追踪变化。
为了不污染全局Array.prototypy
,我们在Observer
中只针对那些需要侦测变化的数组使用__proto__
来覆盖原型方法,但__proto__
在ES6
之前并不是标准属性,不是所有的浏览器都支持它。因此,针对不支持__proto__
属性的浏览器,我们直接循环拦截器,把拦截器中的方法直接设置到数组身上来拦截Array.prototype
上的原生方法。
Array
收集依赖的方式和Object
一样,都是在getter
中收集。但是由于使用依赖的位置不同,数组要在拦截器中向依赖发消息,所以依赖不能像Obhect
一样保存在defineReactive
中,而是把依赖保存在Observer
实例上。
在Observer
中,我们对每个侦测了变化的数组都标记上__ob__
,把this
(Observer实例
)保存到__ob__
上。作用有两点:1.为了标记数据是否被侦测了变化(保证一个数据只被侦测一次),2.可以很方便的通过数据取到__ob__
,从而拿到Observer
实例上保存的依赖。当拦截到数组发生变化时,向依赖发送通知。
除了侦测数组自身的变化外,数组中元素发生的变化也要侦测。我们在Observer
判断如果当前被侦测的数据是数组,则调用observerArray
方法将数组每一个元素都转换成响应式的并侦测变化。
除了侦测已有数据外,当用户使用push
等方法向数组中新增数据时,新增的数据也要进行变化侦测。我们使用当前操作数组的方法来进行判断,如果是push
、ubshift
、splice
方法,则从参数中将新增数组提取出来,然后使用observerArray
对新增数据进行变化侦测。
由于在ES6
之前,JavaScript
并没有提供元编程的能力,所以对于数组类型的数据,一些语法无法追踪到变化,只能拦截原型上的方法,而无法拦截数组特有的语法,例如使用length
清空数组的操作就无法拦截。