如何实现一个响应式系统(三)

106 阅读11分钟

该系列文章为《Vue.js设计与实现》这本书的读书笔记,若想了解更详细的内容可以阅读原书。

示例代码:Github

一、理解 Proxy 和 Reflect

Proxy

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。

这里需要注意的是:

  1. Proxy 只能代理对象,无法代理非对象值,例如字符串、数字等;
  2. 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];
  },
  /* 省略 */
});
  1. 我们通过 obj 访问 obj.bar,触发 get 拦截函数
  2. get 拦截函数中,通过 target[key] 返回属性值
  3. 其中 target 指的是原始对象 data,而 key 就是 'bar',所以 target[key] 相当于 data['bar']
  4. 所以 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
  • 判断对象或原型上是否存在给定的 keykey in obj
  • 使用 for..in 循环比例对象

但是,对于这些操作,我们应该怎么拦截呢?

  1. 阅读 ECMA 规范,查看操作符的运行时逻辑;

文档链接:Runtime Semantics: Evaluation

文档链接:Proxy Object Internal Methods and Internal Slots

  1. 取巧的方法:自已定义一个包含所有拦截函数的代理对象,加入日志,直接运行,看看会涉及哪些拦截函数。不过,这个方法不够准确,有些操作会涉及到多个拦截函数,需要找到最关键的那一个。
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;
  },
});

只有新值和旧值不全等的时候,才会触发响应。但是我们知道一个特殊情况, NaNNaN 进行全等比较得到的是 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 时,会调用 childset 拦截函数,在 set 拦截函数中我们调用了 Reflect.set(target, key, newVal, receiver) 完成默认行为,我们根据规范可以知道:如果设置的属性不存在于对象上,那么会取其原型,并调用原型的 set 方法。所以,parentset 拦截函数也执行了,这就导致了副作用函数执行两次。

文档地址:OrdinarySetWithOwnDescriptor

我们知道,两次更新是由于 set 拦截函数被触发两次导致的,所以我们只要能够在 set 拦截函数内区分这两次更新,然后把 parent 的那次给屏蔽掉就行了。我们来看下两次 set 拦截函数:

// child 的拦截函数
set(target, key, newVal, receiver) {
  // target 是原始对象 obj
  // receiver 是代理对象 child
}
// parent 的拦截函数
set(target, key, newVal, receiver) {
  // target 是原始对象 proto
  // receiver 仍然是代理对象 child
}

我们发现,在 childset 拦截函数中,receivertarget 的代理对象; 在 parentset 拦截函数中,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;
  },
});

这样,就屏蔽了由原型引起的更新。


系列文章:

如何实现一个响应式系统(一)

如何实现一个响应式系统(二)

如何实现一个响应式系统(三)