Proxy
是ES6
新增的对象,可以对指定对象做一层代理。
当我们从一个对象中获取某一个键时,会触发Proxy
中get
的拦截,对某一个键赋值的时候,则会触发Proxy
中set
的拦截,依据此特性,可以实现一个简单的观察者模式。
简单的观察者模式
在触发set
拦截的时候,触发注册的观察函数。
// 存观察函数的数组
const observerList = [];
// 创建可观察对象
const observable = (obj) => {
const proxy = new Proxy(obj, {
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
// 触发set拦截的时候遍历并调用观察函数
observerList.forEach((fn) => fn());
return result;
},
});
return proxy;
};
// 添加观察函数
const observer = (fn) => {
fn();
observerList.push(fn);
};
const obj = observable({ current: 0 });
observer(() => console.log(obj.current)); // 输出 0
obj.current++; // 输出 1
将观察函数放入队列中,每次触发set
拦截的时候,触发队列中的观察函数,这样就实现了一个最简单观察者模式。
依赖收集问题
这样的方式,以一个比较大的问题就是没有包含依赖收集,参考如下情况:
const obj = observable({ a: 0, b: 0 });
observer(() => console.log(obj.a)); // 输出 0
observer(() => console.log(obj.b)); // 输出 0
obj.a++;
// console.log(obj.a) => 输出 1
// console.log(obj.b) => 输出 0
在第二个观察函数中,并没有使用到obj.a
,但是在触发set
拦截的时候,会将所有的观察函数统一触发,所以第二个观察函数也被触发了。
想要解决这个问题也比较简单,那就是将观察函数中使用的键名key
和当前所在的观察函数做一个映射,即key => 观察函数[]
,用Map
存储则可表示为Map<key,function[]>
。
现在的问题就是如何在对象被观察函数访问时,触发get
拦截并获取到此对象所在的观察函数,简而言之就是observer(() => console.log(obj.a));
中,如何在obj.a
被访问的时候获取到() => console.log(obj.a)
。
依赖收集的实现
在调用观察函数之前,我们可以将该函数存入一个数组中,执行观察函数,观察函数中访问的键触发get
拦截,从数组中取得最后一项就是当前所在的函数。
语言描述比较无力,直接看代码吧,这部分比较长,我会逐行注释解释。
// 用来缓存正在运行的观察函数
const stack = [];
// 储存对象key所对应的观察函数数组
const observerMap = new Map(); // key => 观察函数[]
const observer = (fn) => {
// 运行观察函数前,先将观察函数压入stack
stack.push(fn);
// 调用观察函数进行依赖收集
// 这是观察函数中访问对象的键,触发get拦截
fn();
// 依赖收集完后将观察函数从stack弹出
stack.pop();
};
const observable = (obj) => {
const proxy = new Proxy(obj, {
get(target, key, receiver) {
// 这时候对应上方的fn()调用的时候
// 可以从stack数组中获取到最后一个函数,就是当前对象运行的函数
const runningFunction = stack[stack.length - 1];
if (runningFunction) {
// 通过访问的key获取对应的观察函数列表
let observerList = observerMap.get(key);
// 如果当前key没有对应的观察函数列表
if (!observerList) {
// 用Set存储去重,以免多次访问相同key时重复调用
observerList = new Set();
// 存入Map,有了key=>Set的映射
observerMap.set(key, observerList);
}
// 如果当前的观察函数没有存入Set就存入
if (!observerList.has(runningFunction)) {
observerList.add(runningFunction);
}
}
return Reflect.get(target, key, receiver);
},
});
return proxy;
};
const obj = observable({ a: 0, b: 0 });
observer(() => console.log(obj.a));
observer(() => console.log(obj.b));
// 获取key所对应的观察函数
console.log(observerMap.get("a")); // Set[ () => console.log(obj.a) ]
console.log(observerMap.get("b")); // Set[ () => console.log(obj.b) ]
根据输出可以看出,observerMap
中已经将键对应的观察函数列表储存了下来,接下来只需要在set
拦截中,获取key
对应的观察函数,遍历调用就行了
带依赖收集的简单观察者模式
// 用来缓存正在运行的观察函数
const stack = [];
// 储存对象key所对应的观察函数数组
const observerMap = new Map(); // key => 观察函数[]
const observer = (fn) => {
// 运行观察函数前,先将观察函数压入stack
stack.push(fn);
// 调用观察函数进行依赖收集
// 这是观察函数中访问对象的键,触发get拦截
fn();
// 依赖收集完后将观察函数从stack弹出
stack.pop();
};
const observable = (obj) => {
const proxy = new Proxy(obj, {
get(target, key, receiver) {
// 这时候对应上方的fn()调用的时候
// 可以从stack数组中获取到最后一个函数,就是当前对象运行的函数
const runningFunction = stack[stack.length - 1];
if (runningFunction) {
// 通过访问的key获取对应的观察函数列表
let observerList = observerMap.get(key);
// 如果当前key没有对应的观察函数列表
if (!observerList) {
// 用Set存储去重,以免多次访问相同key时重复调用
observerList = new Set();
// 存入Map,有了key=>Set的映射
observerMap.set(key, observerList);
}
// 如果当前的观察函数没有存入Set就存入
if (!observerList.has(runningFunction)) {
observerList.add(runningFunction);
}
}
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
// 存下旧值做对比
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
// 获取当前key所对应的观察函数列表
const observerList = observerMap.get(key);
// 如果列表存在并且新值和旧值不同,遍历调用观察函数
if (observerList && oldValue !== value) {
observerList.forEach((fn) => {
fn();
});
}
return result;
},
});
return proxy;
};
const obj = observable({ a: 0, b: 0 });
observer(() => console.log("a", obj.a)); // a 0
observer(() => console.log("b", obj.b)); // b 0
obj.a++; // a 1 (没有触发b的观察函数了)
这就是一个简单的、带依赖收集的观察者模式的实现。
现在这个极简版还有非常多的问题,这里列举几个:
- 如果创建了2个
observable
对象并且有相同的键名时,会将观察函数存在observerMap
的同一个key
,触发一个对象的更新会导致另一个对象观察函数执行 - 如果是对象的遍历如
Object.keys
时不会触发观察函数 - 如果修改深层对象如
obj.a.b++
,不会触发观察函数 - 删除对象某一个键不会触发观察函数
- Map类型和Set类型不会触发观察函数
解决方法:
- 创建另一个
WeakMap
做映射,即对象 => <键 => 观察函数Set>
的映射,在拦截函数里需要用target
对象找到当前的键 => 观察函数Set
的映射 - 对象遍历会触发
ownKeys
的拦截,在此拦截里自定义一个key
来存储对应的观察函数 - 在
get
拦截里将深层对象转为observable
对象 - 删除会触发
deleteProperty
的拦截 - 需要对Map或Set做一个函数劫持,见
这种模式在Vue 3中有所使用,Vue 3是将单独的功能分发为单独的包细分管理的,在@vue/reactivity中,特别提到了nx-js/observer-util,我也是学习这个库后,写了本文进行了一个简单的思路总结。
本文参考: