该系列文章为《Vue.js设计与实现》这本书的读书笔记,若想了解更详细的内容可以阅读原书。
示例代码:Github
一、理解 Proxy 和 Reflect
Proxy
Proxy
可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。
这里需要注意的是:
Proxy
只能代理对象,无法代理非对象值,例如字符串、数字等;Proxy
只能拦截对一个对象的基本操作;
obj.fn() // 对象下的方法调用是一个复合操作,即先通过 get 获取到 obj.fn,然后再做函数调用
Proxy 支持的拦截操作一共 13 种,参考《ECMAScript 6 入门》
Reflect
Reflect
对象的方法与 Proxy
对象的方法是一一对应的,只要是 Proxy
对象的方法,就能在 Reflect
对象上找到对应的方法。不管 Proxy
怎么修改默认行为,你都可以在 Reflect
上获取默认行为。
Reflect 的静态方法一共 13 个,参考《ECMAScript 6 入门》
统一使用 Reflect.* 方法
我们先来看一个例子:
const data = {
foo: 1,
get bar() {
return this.foo + 1;
},
};
const obj = new Proxy(data, {/* 省略 */});
effect(() => {
console.log(obj.bar);
});
obj.foo++;
effect
副作用函数执行时,会读取 obj.bar
,而 obj.bar
是一个访问器属性,因此执行 getter
函数。在 getter
函数中会通过 this.foo
读取 foo
的属性值,因此我们认为副作用函数与属性 foo
之间也会建立联系。但是,我们发现,修改 obj.foo
的值以后,副作用函数并没有重新执行。这是为什么呢?
当我们使用 this.foo
读取 foo
属性值时,这里的 this
指向的是谁?我们回顾一下调用流程:
const obj = new Proxy(data, {
get(target, key) {
track(target, key);
return target[key];
},
/* 省略 */
});
- 我们通过
obj
访问obj.bar
,触发get
拦截函数 get
拦截函数中,通过target[key]
返回属性值- 其中
target
指的是原始对象data
,而key
就是'bar'
,所以target[key]
相当于data['bar']
- 所以
getter
函数内部的this
指向的其实是原始对象data
,说明我们最终访问的是data.foo
显然,在副作用函数中通过原始对象访问它的某个属性,是不会建立联系的。
那怎么解决这个问题?Reflect.get(target, key, receiver)
函数就派上用场了:
const obj = new Proxy(data, {
get(target, key, receiver) {
track(target, key);
// 使用 Reflect.get 返回属性值
return Reflect.get(target, key, receiver);
},
/* 省略 */
});
代理对象的 get
拦截函数接收第三个参数 receiver
,它总是指向原始的读操作所在的那个对象,一般情况下就是 Proxy 实例。
而 Reflect.get
的第三个参数 receiver
,它表示:如果 key
为访问器属性,则 getter
函数的 this
绑定为 receiver
参数指定的值。
将 target[key]
改为 Reflect.get(target, key, receiver)
以后,this
由原始对象 data
变为代理对象 obj
,这样副作用函数便与响应式数据建立了联系。
基于这样的原因,后面对 target 的操作,都统一使用 Reflect.* 方法。
二、如何代理 Object
前面我们使用 get
拦截函数的读取操作,但是在响应系统中,“读取”是一个很宽泛的概念。对于一个普通对象的读取操作有:
- 访问属性:
obj.foo
- 判断对象或原型上是否存在给定的
key
:key in obj
- 使用
for..in
循环比例对象
但是,对于这些操作,我们应该怎么拦截呢?
- 阅读 ECMA 规范,查看操作符的运行时逻辑;
- 取巧的方法:自已定义一个包含所有拦截函数的代理对象,加入日志,直接运行,看看会涉及哪些拦截函数。不过,这个方法不够准确,有些操作会涉及到多个拦截函数,需要找到最关键的那一个。
var data = {
foo: 1,
};
var obj = new Proxy(data, {
// 拦截对象属性的读取,比如proxy.foo和proxy['foo']
get(target, key, receiver) {
console.log("get", key);
return Reflect.get(target, key, receiver);
},
// 拦截对象属性的设置,比如proxy.foo = v或proxy['foo'] = v,返回一个布尔值
set(target, key, value, receiver) {
console.log("set", key, value);
return Reflect.set(target, key, value, receiver);
},
// 拦截propKey in proxy的操作,返回一个布尔值
has(target, key) {
console.log("has", key);
return Reflect.has(target, key);
},
// 拦截Object.getOwnPropertyNames(proxy)、Object.getOwnPropertySymbols(proxy)、Object.keys(proxy)、for...in循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而Object.keys()的返回结果仅包括目标对象自身的可遍历属性。
ownKeys(target) {
console.log("ownKeys");
return Reflect.ownKeys(target);
},
/* 省略 */
});
"foo" in obj;
注意:后面会直接说明操作对应的拦截函数,并不会一个一个分析,有兴趣的可以阅读原书或者查看规范。
in 操作符
可以通过 has
拦截函数来拦截 in
操作:
// 原始数据
const data = {
foo: 1,
get bar() {
return this.foo + 1;
},
};
const obj = new Proxy(data, {
/* 省略 */
// key in target
has(target, key) {
track(target, key);
return Reflect.has(target, key);
},
});
effect(() => {
console.log('in', 'foo' in obj);
});
obj.foo++;
for...in
可以通过 ownKeys
拦截函数来拦截 for...in
循环。
但是我们发现 ownKeys
拦截函数和 get/set
拦截函数不同,在 get/set
拦截函数中,我们可以得到具体操作的 key
,但是 ownKeys
中我们只能拿到目标对象 target
,因为 ownKeys
这个操作是用来获取一个对象的所有属于自己的键值,不需要与任何键进行绑定。因此我们只能构造一个唯一的 key
作为标识,即 ITERATE_KEY
:
const ITERATE_KEY = Symbol();
const obj = new Proxy(data, {
/* 省略 */
// for ... in
ownKeys(target) {
track(target, ITERATE_KEY);
return Reflect.ownKeys(target);
},
});
现在我们追踪了 ITERATE_KEY
,那么对数据的哪些操作需要触发与 ITERATE_KEY
相关联的副作用函数呢?
- 对象添加属性
- 对象删除属性
这两种情况都会影响到 for...in
循环遍历的次数。
添加属性
const data = {
foo: 1,
};
const obj = new Proxy(data, {/* 省略 */});
effect(() => {
for (const key in obj) {
console.log(key);
}
});
obj.baz = 2;
当为 obj
添加 baz
属性时,会触发 set
拦截函数,在 set
拦截函数中会调用 trigger
函数,但是这只会触发与 baz
相关联的副作用函数。我们知道 for...in
循环是在副作用函数与 ITERATE_KEY
之间建立联系,与 baz
没有关系,所以并不能正确触发响应。
因此,在添加属性时,我们需要将与 ITERATE_KEY
关联的副作用也取出来执行:
function trigger(target, key) {
// 根据 target 从桶中获取 depsMap
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取得所有副作用函数 effets
const effects = depsMap.get(key);
const effectsToRun = new Set();
effects &&
effects.forEach((effectFn) => {
// 如果 trigger 触发执行的副作用函数和当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
// 将与 ITERATE_KEY 相关联的副作用函数也添加到 effectsToRun
const iterateEffects = depsMap.get(ITERATE_KEY);
iterateEffects &&
iterateEffects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
// 执行副作用函数
/* 省略 */
}
但是,我们会马上发现,对于添加新属性来说,这么修改是没有问题,但是仅仅修改已有属性的值的时候,就会出现问题,修改属性的值时,也会触发 ITERATE_KEY
关联的副作用函数。
所以我们需要在 set
拦截函数中判断操作类型,确定到底是添加属性,还是修改已有属性:
const obj = new Proxy(data, {
/* 省略 */
set(target, key, newVal, receiver) {
const oldVal = target[key];
const type = Object.prototype.hasOwnProperty.call(target, key)
? "SET"
: "ADD";
// 设置属性值
const res = Reflect.set(target, key, newVal, receiver);
// 把副作用函数从桶中取出来并执行
trigger(target, key, type);
return res;
},
});
我们使用 Object.prototype.hasOwnProperty
检查当前操作的属性是否已存在目标对象上,若已存在,则说明当前操作类型为 'SET'
,即修改属性值;否则便是 'ADD'
,为添加属性。然后我们把 type
当做第三个参数传给 trigger
函数。
function trigger(target, key, type) {
// 根据 target 从桶中获取 depsMap
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取得所有副作用函数 effets
const effects = depsMap.get(key);
const effectsToRun = new Set();
effects &&
effects.forEach((effectFn) => {
// 如果 trigger 触发执行的副作用函数和当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
if (type === "ADD") {
// 将与 ITERATE_KEY 相关联的副作用函数也添加到 effectsToRun
const iterateEffects = depsMap.get(ITERATE_KEY);
iterateEffects &&
iterateEffects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
}
// 执行副作用函数
/* 省略 */
}
在 trigger
函数内通过 type
来区分当前操作,并且只有操作类型为 ADD
时,才会触发与 ITERATE_KEY
相关联的副作用函数执行。
删除属性
delete
操作符可以使用 deleteProperty
拦截:
const obj = new Proxy(data, {
/* 省略 */
// delete target.key
deleteProperty(target, key) {
const hasKey = Object.prototype.hasOwnProperty.call(target, key);
const res = Reflect.deleteProperty(target, key);
if (res && hasKey) {
trigger(target, key, "DELETE");
}
},
});
首先检查被删除的属性是否属于对象本身,然后调用 Reflect.deleteProperty
函数完成属性删除工作,只有满足这两个条件时,才调用 trigger
函数触发副作用函数重新执行。
这里调用 trigger
函数时我们传的参数是 DELETE
。由于删除属性会使对象的键减少,影响到 for...in
循环的次数,因此当操作类型是 DELETE
时,我们也应该触发与 ITERATE_KEY
相关联的副作用函数重新执行:
function trigger(target, key, type) {
/* 省略 */
if (type === "ADD") {
// 将与 ITERATE_KEY 相关联的副作用函数也添加到 effectsToRun
const iterateEffects = depsMap.get(ITERATE_KEY);
iterateEffects &&
iterateEffects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
}
// 执行副作用函数
/* 省略 */
}
三、合理地触发响应
为了方便后面内容的理解,我们把代码稍微做一下封装:
function reactive(data) {
return new Proxy(data, {
// 省略拦截函数
});
}
值不变时不触发
当值没有发生变化时,应该不需要触发响应才对:
const data = {
foo: 1,
nan: NaN,
};
const obj = reactive(data);
effect(() => {
console.log("effect run", obj.foo, obj.nan);
});
obj.foo = 1;
obj.nan = NaN;
为了满足要求,我们需要修改 set
拦截函数,在调用 trigger
函数前,需要检查值是否发生变化:
new Proxy(data, {
/* 省略 */
set(target, key, newVal, receiver) {
const oldVal = target[key];
const type = Object.prototype.hasOwnProperty.call(target, key)
? "SET"
: "ADD";
// 设置属性值
const res = Reflect.set(target, key, newVal, receiver);
// 比较新值与旧值,只有当它们不全等,并且不都是 NaN 的时候才触发响应
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type);
}
return res;
},
});
只有新值和旧值不全等的时候,才会触发响应。但是我们知道一个特殊情况, NaN
与 NaN
进行全等比较得到的是 false
。所以我们要保证新旧值不全等的情况下,他们都不是 NaN
。
原型继承
我们看下这个例子:
const obj = {};
const child = reactive(obj);
const proto = { bar: 1 };
const parent = reactive(proto);
// 使用 parent 作为 child 的原型
Object.setPrototypeOf(child, parent);
effect(() => {
console.log(child.bar);
});
child.bar = 2;
从代码中看出 child
本身没有 bar
属性,因此访问 child.bar
时,值是从原型上获取的。但无论如何,既然 child
是响应式数据,那么它与副作用函数之间就会建立联系。但是我们执行 child.bar = 2
后发现,副作用函数执行了 2
次,造成了不必要的更新。
child
自身没有 bar
属性,所以会从其原型 parent
中去取,parent
也是响应式数据,因此在副作用函数中访问 parent.bar
,导致副作用函数也被收集。即 child.bar 和 parent.bar 都与副作用函数建立了联系。
当我们执行 child.bar = 2
时,会调用 child
的 set
拦截函数,在 set
拦截函数中我们调用了 Reflect.set(target, key, newVal, receiver)
完成默认行为,我们根据规范可以知道:如果设置的属性不存在于对象上,那么会取其原型,并调用原型的 set
方法。所以,parent
的 set
拦截函数也执行了,这就导致了副作用函数执行两次。
我们知道,两次更新是由于 set
拦截函数被触发两次导致的,所以我们只要能够在 set
拦截函数内区分这两次更新,然后把 parent
的那次给屏蔽掉就行了。我们来看下两次 set
拦截函数:
// child 的拦截函数
set(target, key, newVal, receiver) {
// target 是原始对象 obj
// receiver 是代理对象 child
}
// parent 的拦截函数
set(target, key, newVal, receiver) {
// target 是原始对象 proto
// receiver 仍然是代理对象 child
}
我们发现,在 child
的 set
拦截函数中,receiver
是 target
的代理对象; 在 parent
的 set
拦截函数中,receiver
并不是 target
的代理对象。所以,只有当 receiver
是 target
的代理对象时才触发更新,这样就能屏蔽由原型引起的更新了。
接下来,我们要确定 receiver
是不是 target
的代理对象。
首先,通过添加属性 raw
来读取原始数据:
new Proxy(data, {
/* 省略 */
get(target, key, receiver) {
// 代理对象可以通过 raw 属性访问原始数据
if (key === "raw") {
return target;
}
// 将副作用函数 activeEffect 添加到桶中
track(target, key);
// 使用 Reflect.get 返回属性值
return Reflect.get(target, key, receiver);
},
});
在 set
函数中判断 receiver
是不是 target
的代理对象:
new Proxy(data, {
/* 省略 */
set(target, key, newVal, receiver) {
const oldVal = target[key];
const type = Object.prototype.hasOwnProperty.call(target, key)
? "SET"
: "ADD";
// 设置属性值
const res = Reflect.set(target, key, newVal, receiver);
// receiver 就是 target 的代理对像
if (target === receiver.raw) {
// 比较新值与旧值,只有当它们不全等,并且不都是 NaN 的时候才触发响应
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type);
}
}
return res;
},
});
这样,就屏蔽了由原型引起的更新。
系列文章: