深入了解Vue响应式之——数组侦测
一、思路
在Vue2当中,关于对象的侦测有Object.defaultProperty()来对对象的属性进行拦截,从而进行依赖的收集与更新。但是在数组当中,我们ES5并没有为我们提供可以拦截数组变化的能力(ES6中JavaScript为我们提供了Proxy)。但是,聪明的程序员可不会被这点困难难倒,既然你不给我拦截数组的能力,那么就自己创造一个方法来拦截数组。简而言之,就是通过自定义的方法来来覆盖数组上面的原型方法,这样我们就可以在数组调用原型方法的时候对它进行拦截了。
二、自定义拦截器
经过整理。我们发下数组当中可以对自身内容进行更改的方法一共有七个,分别是push、pop、shif、unshift、sort、splice、reverse。我们需要做的就是通过自定义的方法来替换掉这七个原型方法。
/**
* 对Array的实现
*/
//获取数组原型
const arrayProto = Array.prototype;
//创建一个对象,对象的原型指向数组的原型
const arrayMethods = Object.create(arrayProto);
["push", "pop", "shift", "unshift", "splice", "sort", "reverse"].forEach(
(method) => {
//缓存原始方法
const original = arrayProto[method];
//为特定的方法创建拦截
Object.defineProperty(arrayMethods, method, {
value: function mutator(...args) {
//创建拦截函数,当我们使用上面特定的方法时,会调用这个函数
return original.apply(this, args);
},
enumerable:false,
writable:true,
configurable:true,
});
});
在上面的代码当中,我们通过arrayProto获取到了数组的原型,然后通过Object.create()来创建了一个继承于arrayProto的新对象arrayMethods,arrayMethods的原型指向arrayProto。然后利用Object.defineProperty()的方法将为我们整理出来的数组方法挂载到arrayMethods上面,并且每个数组方法都是我们自定义的,在我们自定义的数组方法当中,我们可以对数组的元素进行操作,比如依赖收集和更新。然后调用数组的原型方法,之前已经被存储在original当中,所以我们只需要将original指向this,并且将参数传进去,注意这里的this指的是调用方法的数组。
三、将拦截器覆盖数组的原型
有了拦截器之后,我们需要将拦截器挂载到我们需要拦截的数组上面去。但是我们又不能直接覆盖Array的原型,因为这样会污染全局的Array,因此我们需要找一个合适的地方来覆盖原型。当我们把一个数组变成响应式的时候,需要通过Observe来转换。因此,我们可以在Observe来覆盖指定数组的原型。
/**
* 使用拦截器覆盖原型
*/
//判断对象上是否包含原型
const hasOwn = "__proto__" in {};
//获取arrayMethods上所有属性,包含不可枚举的
const arrayKeys = Object.getOwnPropertyNames(arrayMethods);
class Observer {
constructor(value) {
this.value = value;
if (Array.isArray(value)) {
//将添加响应式的数组的原型替换掉
const augment = hasOwn ? protoAugment : copyAugment;
augment(value, arrayMethods, arrayKeys);
} else {
//添加响应式的对象
this.walk(value);
}
}
walk(obj) {
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
defaineReactive(obj, keys[i], obj[keys[i]]);
}
}
}
//判断__proto__是否可用
function protoAugment(target, src, keys) {
target.__proto__ = src;
}
//如果__proto__不可用
function copyAugment(target, src, keys) {
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
def(target, key, src[key]);
}
}
在上面的代码当中,hasOwn是用来判断当前环境当中是否支持__proto__来获取原型,因为并不是所有的的浏览器都支持这样的做法,因此我们需要处理不能使用__proto__来获取原型的情况。
Vue当中的做法是如果不能通过__proto__来获取原型,那么就直接将拦截器挂载到数组上,这样在调用指定方法的时候,会优先调用我们设置好的拦截器,因为只有数组自身找不到的方法才会去原型上面寻找。
四、如何收集依赖
上面,我们创建并且在Observe里将拦截器覆盖了数组的原型方法,但是我们发现,现在还是什么的事情都做不了。为什么呢?因为我们之所以创建拦截器,本质上是为了得到一种能力,能实现对数组内容进行监测的能力,现在我们已经具备了这样的能力,但是我们该通知谁来处理这种变化呢?答案肯定是通知Dep当中的依赖(Watcher),但是我们该如何收集依赖呢?
在对象当中,我们通过defineProperty的get属性来获取依赖,当有依赖访问属性值的时候,就对触发dep.depend(),然后将依赖收集起来。在数组当中,我们也可以按照上面的思路来实现:
{
list:[1,2,3,4,5,6]
}
如果是上面这样的数组,那么想要获取数组,那么必须访问list这个key,也就是说,不管value是什么,只要想访问Object某个属性的数据,肯定得通过key来读取这个value。因此在读取的时候,会先触发这个list的key。
function defaineReactive(data, key, val) {
let childOb = observe(val); // 获取Observe的实例
let dep = new Dep();
if (typeof val === "object") {
new Observer(val);
}
//添加监听响应
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();
},
});
}
上面的代码显示了在那个位置去收集依赖。所以,Array在getter当中收集依赖,在拦截器当中触发依赖发生更新。
五、依赖列表存在哪?
知道了如何收集依赖之后,我们还有决定依赖存放在哪?这个存放的地点必须能让getter和拦截器都可以访问到。在Vue当中,把依赖存放于Observe当中,因为在Observe当中,这两个地方都可以访问到Observe的实例。
class Observer {
constructor(value) {
this.value = value;
//收集依赖,新增dep
this.dep = new Dep();
if (Array.isArray(value)) {
//将添加响应式的数组的原型替换掉
const augment = hasOwn ? protoAugment : copyAugment;
augment(value, arrayMethods, arrayKeys);
this.observeArray(value);
} else {
//添加响应式的对象
this.walk(value);
}
}
walk(obj) {
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
defaineReactive(obj, keys[i], obj[keys[i]]);
}
}
//将数组当中的所有子元素都变成响应式数据
observeArray(items) {
for (let i = 0; i < items.length; i++) {
observe(items[i]);
}
}
}
六、收集依赖
把Dep实例保存在Observe的属性上之后,我们可以在getter中像下面的代码当中那样收集依赖
function defaineReactive(data, key, val) {
let childOb = observe(val); // 获取Observe的实例
let dep = new Dep();
if (typeof val === "object") {
new Observer(val);
}
//添加监听响应
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
dep.depend();
//新增依赖
if (childOb) {
childOb.dep.depend();
}
return val;
},
set: function (newVal) {
if (val === newVal) {
return;
}
val = newVal;
dep.notify();
},
});
}
/**
* 尝试为vualue创建一个Observe实例
* 如果实例创建成功,直接返回新创建的Observe实例
* 如果value已经存在一个Observe实例,则直接返回它
*/
//判断value是否为对象
function isObject(obj) {
if (typeof obj === "object") {
return true;
} else {
return false;
}
}
//判断value上面是否有__ob__属性,
function hasOwnProperty(obj, key) {
if (Object.hasOwnProperty(obj, key)) {
return true;
} else {
return false;
}
}
function observe(obj) {
if (!isObject(obj)) {
return;
}
let ob;
if (hasOwnProperty(obj, "__ob__") && obj.__ob__ instanceof Observer) {
ob = obj.__ob__;
} else {
ob = new Observer(obj);
}
return ob;
}
上面的代码当中,首先需要获取Observe的实例,如果当前对象已经包含了Observe的实例,那么直接返回实例,如果没有实例的话,那么我们就新创建一个实例返回出去,资源是为了防止重复侦测value的变化。然后我们在get属性当中,通过childOb来获取dep,然后进行依赖收集。
七、在拦截器中获取Observe的实例
上面我们已经在getter当中获取Observe的实例,然后进行依赖收集,在拦截器当中,我们也需要获取Observe的实例,来进行依赖更新。
因为Array拦截器是对原型的一种封装,所以可以在拦截器当中访问this(当前正在被操作的数组)。
/**
* 使用拦截器覆盖原型
*/
//判断对象上是否包含原型
const hasOwn = "__proto__" in {};
//获取arrayMethods上所有属性,包含不可枚举的
const arrayKeys = Object.getOwnPropertyNames(arrayMethods);
class Observer {
constructor(value) {
this.value = value;
//收集依赖
this.dep = new Dep();
//给数组对象的原型上添加不可枚举的__ob__属性,属性包含Observe实例
def(value, "__ob__", this);
if (Array.isArray(value)) {
//将添加响应式的数组的原型替换掉
const augment = hasOwn ? protoAugment : copyAugment;
augment(value, arrayMethods, arrayKeys);
this.observeArray(value);
} else {
//添加响应式的对象
this.walk(value);
}
}
walk(obj) {
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
defaineReactive(obj, keys[i], obj[keys[i]]);
}
}
//将数组当中的所有子元素都变成响应式数据
observeArray(items) {
for (let i = 0; i < items.length; i++) {
observe(items[i]);
}
}
}
//将属性添加到对象上,并且不可枚举
function def(obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
//让他不可枚举的添加到数组上去
enumerable: !!enumerable,
Writable: true,
configurable: true,
});
}
通过这样,我们就可以在value当中访问到Observe的实例,然后通过那道Observe的实例来更新依赖。def函数的作用是将一个属性通过Object.defineProperty添加到指定对象上,并且让添加的属性不可枚举。
当然,__ob__的作用不仅仅是为了在拦截器中访问Observe实例这么简单,还可以用来标记当前value是否已经被Observe转换成了响应式数据。也就是说,所有的响应式数据上面都有一个__ob__属性来表示他们是响应式的。
当value被标记为响应式的时候,就可以通过__ob__来方位Observe的实例,这样我们就可以在拦截器当中获取Observe实例。
八、向数组的依赖发送通知
当被侦测的数组发生了变化的时候,会向依赖发送通知。此时要能访问到依赖,前面我们已经讲过,我们可以通过__ob__属性来获取Observe的实例,而依赖又保存在Observe实例当中的dep当中。因此我,1可以这样操作
/**
* 对Array的实现
*/
//获取数组原型
const arrayProto = Array.prototype;
//创建一个对象,对象的原型指向数组的原型
const arrayMethods = Object.create(arrayProto);
["push", "pop", "shift", "unshift", "splice", "sort", "reverse"].forEach(
(method) => {
//缓存原始方法
const original = arrayProto[method];
//为特定的方法创建拦截
Object.defineProperty(arrayMethods, method, {
value: function mutator(...args) {
//获取Observe的实例保存到ob上
const ob = this.__ob__;
//触发更新依赖
ob.dep.notify();
//创建拦截函数,当我们使用上面特定的方法时,会调用这个函数
return original.apply(this, args);
},
enumerable:false,
writable:true,
configurable:true,
});
});
在上面的代码当中,我们调用ob.dep.notify();去更新通知依赖(Watcher)数据发生了变化。
九、侦测数组当中元素的变化
上面说到的侦测数组的变化,是侦测数组自身发生的变化,是否新增元素,删除元素等等。但是对于数组的子元素,我们并没有进行侦测,因此,我们接下来要对数组的子元素进行侦测,实现的方法也很简单,只需要把数组的每一个子元素都添加到响应式当中就可以了,很显然,这里使用递归来实现。
/**
* 使用拦截器覆盖原型
*/
//判断对象上是否包含原型
const hasOwn = "__proto__" in {};
//获取arrayMethods上所有属性,包含不可枚举的
const arrayKeys = Object.getOwnPropertyNames(arrayMethods);
class Observer {
constructor(value) {
this.value = value;
//收集依赖
this.dep = new Dep();
//给数组对象的原型上添加不可枚举的__ob__属性,属性包含Observe实例
def(value, "__ob__", this);
if (Array.isArray(value)) {
//将添加响应式的数组的原型替换掉
const augment = hasOwn ? protoAugment : copyAugment;
augment(value, arrayMethods, arrayKeys);
//将数组的子元素也添加到侦测
this.observeArray(value);
} else {
//添加响应式的对象
this.walk(value);
}
}
walk(obj) {
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
defaineReactive(obj, keys[i], obj[keys[i]]);
}
}
//将数组当中的所有子元素都变成响应式数据
observeArray(items) {
for (let i = 0; i < items.length; i++) {
observe(items[i]);
}
}
}
//将元素变成响应式数据
function observe(obj) {
if (!isObject(obj)) {
return;
}
let ob;
if (hasOwnProperty(obj, "__ob__") && obj.__ob__ instanceof Observer) {
ob = obj.__ob__;
} else {
ob = new Observer(obj);
}
return ob;
}
在上面的代码当中,我们早Observe当中添加了一个新的方法observeArray通过遍历数组的所有子元素,将他们都添加到observe当中变成响应式数据。限制我们只需要将一个数据丢进去,Observe就会将这个数据的所有子元素全部转换为响应式数据。
十、侦测数组新增元素的变化
数组当中的一些新增数组元素的,比如push等,而新增的内容我们也需要对它进行响应式来侦测变化。基本的实现思路就是当调用push、unshif、splice的时候,对新的的元素进行响应式处理。
/**
* 对Array的实现
*/
//获取数组原型
const arrayProto = Array.prototype;
//创建一个对象,对象的原型指向数组的原型
const arrayMethods = Object.create(arrayProto);
["push", "pop", "shift", "unshift", "splice", "sort", "reverse"].forEach(
(method) => {
//缓存原始方法
const original = arrayProto[method];
def(arrayMethods, method, function mutator(...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);
}
const retult = original.apply(this, args);
//发送消息,告诉watcher这里发生了变化
ob.dep.notify();
return retult;
});
}
);
上面的代码当中,我们通过单独对push、unshift、splice的判断,来获取新增元素inserted,再对新增元素进行判断,如果有新增元素,就通过Observe实例来调用observeArray方法,将新增元素也进行响应式处理。
十一、问题
上面整体下来,我们已经对Array的变化进行了侦测,而变化侦测的实现是通过拦截原型的方式进行的。但是,正式因为这种方式,我们有些数组的变化是拦截不到的,比如
this.arr.length = 0;
这种通过调用length直接把数组清空的操作是没办法拦截的,也不会触发Watcher和re-render。
或者当使用下标修改数组的元素的时候,也是无法侦测到数组变化的
this.arr[2] = 2;
这是方式也无法进行拦截。
十二、总结
Array的变化侦测和Object的不一样,因为他没有通过javascript本身来监测数组属性变化的能力,所以我们需要自定义拦截器来覆盖数组的原型来侦测变化的。
为了不污染全局的Array原型,我们通过在Observe当中获取需要侦测的数组,然后通过判断__proto__来决定是覆盖原型还是直接挂载到被侦测的数组上面。
Array的依赖收集也和Object的一样,都是在访问Object.defineProperty的get属性的时候来收集依赖。但是,存储依赖的地方不一样,Object存储在defaultReactive当中,但是Array保存在Observe当中。为什么会有这样的区别?因为Array使用依赖的地方不一样,Array使用依赖的地方在拦截器当中,要在拦截器当中向依赖发送消息。所以必须保存在一个getter和拦截器都可以访问到的地方。
在Observe当中,我们把每一个需要侦测数据变化的对象都添加一个__ob__属性,属性当中保存着Observ的实例。这主要有两个作用,一方面是问了放在数据被重复侦测(我们只需要判断被侦测的数据上是否存在__ob__就可以了),另一方面,我们还可以在getter和拦截器当中通过__ob__来访问Observe实例,然后进行依赖的收集和通知。
当然,除了侦测自身的变化之外,数组的子元素也需要侦测变化,因此,在Oberve当中,我们对数组的子元素进行了遍历,并且将每个子元素通过observe函数来对它进行响应式处理,并且侦测变化。
除了已经在数组当中存在的数据之外,我们还需要侦测新增加的子元素,因此我们需要对push、unshif、splice这几个可以增加数组元素的方法进行筛选,然后获取到新增加的元素。通过调用Observe实例上的observeArray方法来进行响应式处理,其本质也是调用observe。
在ES6之前,javascript并没有元编程的能力,所以对于数组类型的数据,一些语法无法追踪到变化,只能拦截原型上的方法,而无法拦截数组特有的方法,例如使用length清空数组和使用下标增加数组元素等就无法进行拦截。