ZHANGYU.dev

October 14, 2023

 如何利用Proxy实现一个响应式对象

JavaScript5.0 min to read

本文所需知识:ProxyReflect


ProxyES6新增的对象,可以对指定对象做一层代理。

当我们从一个对象中获取某一个键时,会触发Proxyget的拦截,对某一个键赋值的时候,则会触发Proxyset的拦截,依据此特性,可以实现一个简单的观察者模式。

简单的观察者模式

在触发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的观察函数了)

这就是一个简单的、带依赖收集的观察者模式的实现。

现在这个极简版还有非常多的问题,这里列举几个:

  1. 如果创建了2个observable对象并且有相同的键名时,会将观察函数存在observerMap的同一个key,触发一个对象的更新会导致另一个对象观察函数执行
  2. 如果是对象的遍历如Object.keys时不会触发观察函数
  3. 如果修改深层对象如obj.a.b++,不会触发观察函数
  4. 删除对象某一个键不会触发观察函数
  5. Map类型和Set类型不会触发观察函数

解决方法:

  1. 创建另一个WeakMap做映射,即对象 => <键 => 观察函数Set>的映射,在拦截函数里需要用target对象找到当前的键 => 观察函数Set的映射
  2. 对象遍历会触发ownKeys的拦截,在此拦截里自定义一个key来存储对应的观察函数
  3. get拦截里将深层对象转为observable对象
  4. 删除会触发deleteProperty的拦截
  5. 需要对Map或Set做一个函数劫持,

这种模式在Vue 3中有所使用,Vue 3是将单独的功能分发为单独的包细分管理的,在@vue/reactivity中,特别提到了nx-js/observer-util,我也是学习这个库后,写了本文进行了一个简单的思路总结。

本文参考:

  1. 带你彻底搞懂Vue3的Proxy响应式原理!TypeScript从零实现基于Proxy的响应式库
  2. observer-util