这是我参与更文挑战的第3天,活动详情查看: 更文挑战。
前言
之前我们学了 Object 的侦测变化,那为什么 Array 要单独来讲呢?我们用下面的例子来说明一下:
this.list.push(1)
Object 可以通过 getter/setter
来实现状态的侦测,而数组的 push 方法,无法触发 getter/setter。本文,我们将学习 Array 是如何实现变化侦测的。
如何追踪变化
Object 是通过 setter 来通知依赖 update 的。如果我们能在数组 push 的时候发出通知,就能实现相同的效果。
我们可以将数组的原型指向一个新的对象,这个对象会重写 Array.prototype 上的数组方法。
拦截器
上面所说的既有数组方法,又能实现通知功能的对象,我们给它取个名字,叫做 拦截器(这也是 代理模式 的一种体现)。
如何实现一个拦截器呢?
我们可以发现 Array.prototype
中 可以改变数组自身内容的方法共有7个: push、pop、shift、unshift、spice 和 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, {
enumerable: false,
writable: true,
configurable: true,
value: function mutator(...args){
// ...
// 调用原型上的方法
return original.apply(this.args)
}
})
})
有了拦截器之后,我们该如何让它生效?暴力的方式是直接修改 Array.prototype,但是这种方式会污染全局的 Array,所以我们要换个方式———把数组实例的原型指向拦截器。
- 利用
__proto__
- 利用 ES6 的
Object.setPrototypeOf()
考虑兼容性的问题,我们使用第一种,如果连 __proto__
也不支持的话,就把拦截器的方法直接复制到数组实例上。
function protoAugment(target, src, keys) {
target.__proto__ = src;
}
function copyAugment(target, src, keys) {
for (let i = 0, len = keys.length; i < len; i++) {
const key = keys[i];
def(target, key, src[key]);
}
}
function def(target, key, val, enumerable?: boolean) {
Object.defineProperty(target, key, {
enumerable: !!enumerable,
writable: true,
configurable: true,
value: val,
});
}
我们现在重新改造一下 Observer:
const hasProto = '__proto__' in {};
export default class Observer {
constructor(value) {
this.value = value;
if (Array.isArray(value)) {
const augment = hasProto ? protoAugment : copyAugment;
augment(value, arrayMethods, arrayKeys);
} else {
this.walk(value); // 把对象的所有属性都转成 getter/setter
}
}
walk(obj) {
Object.keys(obj).forEach((key) => {
defineReactive(obj, key, obj[key]);
});
}
}
如何收集依赖
上面我们实现了拦截器,但是这个拦截器还不具备通知依赖的功能。要实现通知依赖,首先得先实现收集依赖的功能。那么,数组是如何收集依赖的呢?
我们先回顾一下 Object 的依赖是如何收集的。Object 的依赖收集是在 getter 中使用 Dep 实例收集的,每个 key 都有一个 dep 来收集依赖。
其实,数组也是在 getter 中收集依赖的。
{
list: [1,2,3,4]
}
在读取 list 的时候,会触发 list 属性的 getter。
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;
dep.notify();
val = newVal;
},
});
}
Array 在 getter 中收集依赖,在拦截器中触发依赖。
依赖收集在哪里
Vue.js 把 Array 的依赖存放在 Observer 实例上:
export default class Observer {
constructor(value) {
this.value = value;
this.dep = new Dep(); // 数组的依赖收集在这里
if (Array.isArray(value)) {
const augment = hasProto ? protoAugment : copyAugment;
augment(value, arrayMethods, arrayKeys);
} else {
this.walk(value); // 把对象的所有属性都转成 getter/setter
}
}
}
收集依赖
在 Observer 实例上添加一个 dep 属性后,我们就可以收集依赖了。
function defineReactive(data, key, val) {
let childOb = observe(val);
let dep = new Dep();
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;
dep.notify();
val = newVal;
},
});
}
function observe(value, asRootData) {
if (typeof value !== "object") {
return;
}
let ob;
if (value.hasOwnProperty("__ob__") && value.__ob__ instanceof Observer) {
ob = value.__ob__;
} else {
ob = new Observer(value);
}
return ob;
}
通知依赖更新
上面我们已经完成了数组的依赖收集,接下来就差通知依赖了。要想通知依赖,我们需要能拿到 Observer 实例上的 dep,那么想一想如何在拦截器中访问到 dep?
心细的同学可能已经注意到了,在上面的 observe
函数中出现了 __ob__
,没错核心就是它。
export default 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); // 把对象的所有属性都转成 getter/setter
}
}
}
我们在 value
上定义一个新属性 __ob__
指向 Observer 实例,然后我们就可以在拦截器中在 value
上访问到 Observer 实例,也就能访问到它的 dep 属性。
const arrayProto = array.prototype;
export const arrayMethods = Object.create(arrayProto);
[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
].forEach(function (method){
constt original = arrayProto[method];
Object.defineProperty(arrayMethods, method, {
enumerable: false,
writable: true,
configurable: true,
value: function mutator(...args){
let ob = value.__ob__; // 新增
ob.dep.notify() // 新增
return original.apply(this.args)
}
})
})
侦测数组中元素的变化
上面,我们实现了数组的依赖收集和依赖的通知。那么如果数组中存在对象怎么办呢?
如果数组中某个对象的属性发生变化,按照道理也需要发送通知。另外,如果往数组中添加了一个对象,也需要把这个对象转成响应式的对象。所以,我们需要遍历数组,尝试把数组中元素转成响应式的。
export default class Observer {
constructor(value) {
this.value = value;
this.dep = new Dep();
if (Array.isArray(value)) {
const augment = hasProto ? protoAugment : copyAugment;
augment(value, arrayMethods, arrayKeys);
this.observeArray(value); // 新增
} else {
this.walk(value);
}
}
observerArray(list) {
for (let i = 0, len = list.length; i < l; i++) {
observe(list[i]); // 每一项都尝试转成响应式的
}
}
}
侦测数组新增元素
我们可以在拦截器中,把新增元素传给 __ob__
的 observeArray
方法,就能把新增元素也尝试转成响应式的。
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, {
enumerable: false,
writable: true,
configurable: true,
value: function mutator(...args){
const result = original.apply(this.args)
let ob = value.__ob__;
// 新增, 将新增的元素也尝试转成响应式的
let inserted;
switch(metthod){
case 'push':
case 'unshift':
inserted = args;
break;
case 'splice':
inserted = args.slice(2);
break;
}
if (inserted) ob.serveArray(inserted);
ob.dep.notify()
return result;
}
})
})
Array 的问题
直接通过下标修改元素
,以及 list.length = 0
来清空数组,这样的变化是无法侦测到的。
总结
Array 的变化侦测是如何实现的
- 与 Object 不同,我们将数组实例的原型指向我们定义的拦截器,在 getter 中收集依赖,在拦截器中通知依赖。
- 为了在 getter 中收集依赖,我们给 observer 添加了一个实例属性 dep
- 为了能通知依赖,我们定义
value.__ob__
属性,指向 observer 实例, 在拦截器中使用value.__ob__.dep.notify()
来通知依赖。 - 考虑到数组中元素可能是对象,为了侦测到数组中对象元素的变化,尝试把数组中的元素也转成响应式,在 observer 上新增
observeArray
方法,既能在初始化时将数组中元素转成响应式,也能在数组的拦截器中,调用value.__ob__.observeArray(inserted)
,将新增元素也尝试转成响应式的。