Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
zhangyu1818 opened this issue Jan 18, 2021 · 0 comments
Closed

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

zhangyu1818 opened this issue Jan 18, 2021 · 0 comments

Comments

@zhangyu1818
Copy link
Owner

本文所需知识: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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant