Skip to content

理解 Proxy 和 Reflect

基本语义 给出一个对象 obj,可以对它进行一些操作,例如读取属性值、设置属性值:

js
 obj.foo // 读取属性 foo 的值
 obj.foo++ // 读取和设置属性 foo 的值

可以使用 Proxy 拦截

js
const p = new Proxy(obj, {
  // 拦截读取属性操作
  get() { /*...*/ },
  // 拦截设置属性操作
  set() { /*...*/ }
})

JavaScript 的世界里,万物皆对象。例如一个函数也是一个对象,所以调用函数也是对一个对象的基本操作:

js
const fn = (name) => {
  console.log('我是:', name)
}

// 调用函数是对对象的基本操作
fn()
js
import { effect, track, trigger } from './0_effect.js'

const obj = {
  foo: 1,
  get bar() {
    return this.foo
  },
}

// 对原始数据的代理对象
export const p = new Proxy(obj, {
  // 拦截读取操作,接收第三个参数 receiver
  get(target, key, receiver) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key)

    // 返回属性值 使用 Reflect.get 返回读取到的属性值
    return Reflect.get(target, key, receiver)
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
    return true
  },
})

effect(() => {
  console.log(p.bar) // 1
})
setTimeout(() => {
  p.foo++
}, 100)

// --------------------------------------------------------------
const fn = name => {
  console.log('我是:', name)
}

const p2 = new Proxy(fn, {
  // 使用 apply 拦截函数调用
  apply(target, thisArg, argArray) {
    target.call(thisArg, ...argArray)
  },
})

// p2('hcy') // 输出:'我是:hcy'

const objFn = { foo: 1 }

// // 直接读取
// console.log(objFn.foo) // 1
// // 使用 Reflect.get 读取
// console.log(Reflect.get(objFn, 'foo')) // 1
html
<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <title>Demo</title>
</head>

<body>
</body>
<script src="./1_demo.js" type="module"></script>

</html>

上面两个例子说明了什么是基本操作。Proxy 只能够拦截对一个对象的基本操作

什么是非基本操作呢?其实调用对象下的方法就是典型的非基本操作,我们叫它复合操作:

js
obj.fn()

实际上,调用一个对象下的方法,是由两个基本语义组成的。

  • 第一个基本语义是 get,即先通过 get 操作得到 obj.fn 属性。
  • 第二个基本语义是函数调用,即通过 get 得到obj.fn 的值后再调用它,也就是我们上面说到的 apply。 理解 Proxy 只能够代理对象的基本语义很重要,后续我们讲解如何实现对数组或 Map、Set 等数据类型的代理时,都利用了 Proxy 的这个特点

理解了 Proxy,我们再来讨论 Reflect。Reflect 是一个全局对象,其下有许多方法,例如:

js
Reflect.get()
Reflect.set()
Reflect.apply()
// ...

Reflect 下的方法与 Proxy 的拦截器方法名字相同,其实这不是偶然。任何在 Proxy 的拦截器中能够找到的方法,都能够在 Reflect 中找到同名函数.

Reflect.get 函数来说,它的功能就是提供了访问一个对象属性的默认行为,例如下面两个操作是等价的:

js

const obj = { foo: 1 }

// 直接读取
console.log(obj.foo) // 1
// 使用 Reflect.get 读取
console.log(Reflect.get(obj, 'foo')) // 1

Reflect.get 函数还能接收第三个参数,即指定接收者 receiver,你可以把它理解为函数调用过程中的 this

js
 const obj = { boo: 1,get foo(){return this.boo}}
 console.log(Reflect.get(obj, 'foo', { boo: 2 }))  // 输出的是 2 而不是 1

回顾一下在上一节中实现响应式数据的代码:

js
const obj = { foo: 1 }

const p = new Proxy(obj, {
  get(target, key) {
    track(target, key)
    // 注意,这里我们没有使用 Reflect.get 完成读取
    return target[key]
  },
  set(target, key, newVal) {
    // 这里同样没有使用 Reflect.set 完成设置
    target[key] = newVal
    trigger(target, key)
  }
})

这是上一章中用来实现响应式数据的最基本的代码。在 get 和 set 拦截函数中,我们都是直接使用原始对象 target 来完成对属性的读取和设置操作的,其中原始对象target 就是上述代码中的 obj 对象。

js
 const obj = {
   foo: 1,
   get bar() {
     return this.foo
   }
 }

effect(() => {
  console.log(p.bar) // 1
})
js
import { effect, track, trigger } from './0_effect.js'

const obj = {
  foo: 1,
  get bar() {
    return this.foo
  },
}

// 对原始数据的代理对象
export const p = new Proxy(obj, {
  // 拦截读取操作,接收第三个参数 receiver
  get(target, key, receiver) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key)

    // 返回属性值 使用 Reflect.get 返回读取到的属性值
    return Reflect.get(target, key, receiver)
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
    return true
  },
})

effect(() => {
  console.log(p.bar) // 1
})
setTimeout(() => {
  p.foo++
}, 100)

// --------------------------------------------------------------
const fn = name => {
  console.log('我是:', name)
}

const p2 = new Proxy(fn, {
  // 使用 apply 拦截函数调用
  apply(target, thisArg, argArray) {
    target.call(thisArg, ...argArray)
  },
})

// p2('hcy') // 输出:'我是:hcy'

const objFn = { foo: 1 }

// // 直接读取
// console.log(objFn.foo) // 1
// // 使用 Reflect.get 读取
// console.log(Reflect.get(objFn, 'foo')) // 1
html
<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <title>Demo</title>
</head>

<body>
</body>
<script src="./1_demo.js" type="module"></script>

</html>

2 JavaScript 对象及 Proxy 的工作原理

JavaScript 中一切皆对象 :一种叫作常规对象(ordinary object)​,另一种叫作异质对象(exotic object)​。

引擎内部会调用 [​[Get]​] 这个内部方法来读取属性值。这里补充说明一下,在ECMAScript 规范中使用 [​[xxx]​] 来代表内部方法或内部槽。当然,一个对象不仅部署了 [​[Get]​] 这个内部方法,表 5-1 列出了规范要求的所有必要的内部方法 alt text

还有两个额外的必要内部方法 :​[​[Call]​] 和[​[Construct]​]​,如表 5-2 所示。 alt text

如何区分一个对象是普通对象还是函数呢 : 通过内部方法和内部槽来区分对象,例如函数对象会部署内部方法 [​[Call]​]​,而普通对象则不会

内部方法具有多态性: 不同类型的对象可能部署了相同的内部方法,却具有不同的逻辑

例如,普通对象和 Proxy 对象都部署了 [​[Get]​] 这个内部方法,但它们的逻辑是不同的,普通对象部署的 [​[Get]​] 内部方法的逻辑是由 ECMA 规范的 10.1.8 节定义的,而 Proxy 对象部署的 [​[Get]​] 内部方法的逻辑是由 ECMA 规范的 10.5.8 节来定义的

满足以下三点要求的对象就是常规对象:

  • 对于表 5-1 列出的内部方法,必须使用 ECMA 规范 10.1.x 节给出的定义实现;
  • 对于内部方法 [​[Call]​]​,必须使用 ECMA 规范 10.2.1 节给出的定义实现;
  • 对于内部方法 [​[Construct]​]​,必须使用 ECMA 规范 10.2.2 节给出的定义实现。

而所有不符合这三点要求的对象都是异质对象。例如,由于 Proxy 对象的内部方法[​[Get]​] 没有使用 ECMA 规范的 10.1.8 节给出的定义实现,所以 Proxy 是一个异质对象。

现在我们对 JavaScript 中的对象有了更加深入的理解。接下来,我们就具体看看Proxy 对象。既然 Proxy 也是对象,那么它本身也部署了上述必要的内部方法,当我们通过代理对象访问属性值时:

js
const p = new Proxy(obj, {/* ... */})
p.foo

调用部署在对象 p 上的内部方法 [​[Get]​]​。到这一步,其实代理对象和普通对象没有太大区别。

区别在于对于内部方法 [​[Get]​] 的实现,这里就体现了内部方法的多态性,即不同的对象部署相同的内部方法,但它们的行为可能不同。

具体的不同体现在,如果在创建代理对象时没有指定对应的拦截函数,例如没有指定get() 拦截函数,那么当我们通过代理对象访问属性值时,代理对象的内部方法 [​[Get]​]会调用原始对象的内部方法 [​[Get]​] 来获取属性值,这其实就是代理透明性质。

创建代理对象时指定的拦截函数,实际上是用来自定义代理对象本身的内部方法和行为的,而不是用来指定被代理对象的内部方法和行为的

5-3 列出了 Proxy 对象部署的所有内部方法以及用来自定义内部方法和行为的拦截函数名字 alt text

其中 [​[Call]​] 和 [​[Construct]​] 这两个内部方法只有当被代理的对象是函数和构造函数时才会部署

当我们要拦截删除属性操作时,可以使用 deleteProperty 拦截函数实现:

js
const obj = { foo: 1 }
const p = new Proxy(obj, {
  deleteProperty(target, key) {
    return Reflect.deleteProperty(target, key)
  }
})

console.log(p.foo) // 1
delete p.foo
console.log(p.foo) // 未定义

5.3 如何代理 Object

本节开始,我们将着手实现响应式数据 "读取"是一个很宽泛的概念,例如使用 in 操作符检查对象上是否具有给定的 key 也属于“读取”操作,如下面的代码所示

js
 effect(() => {
   'foo' in obj
 })

这本质上也是在进行“读取”操作。响应系统应该拦截一切读取操作,以便当数据变化时能够正确地触发响应。下面列出了对一个普通对象的所有可能的读取操作。

  • 访问属性:obj.foo。
  • 判断对象或原型上是否存在给定的 key:key in obj。
  • 使用 for...in 循环遍历对象:for (const key in obj){}。

我们逐步讨论如何拦截这些读取操作。首先是对于属性的读取,例如obj.foo,我们知道这可以通过 get 拦截函数实现:

js
const obj = { foo: 1 }

const p = new Proxy(obj, {
  get(target, key, receiver) {
    // 建立联系
    track(target, key)
    // 返回属性值
    return Reflect.get(target, key, receiver)
  },
})

对于 in 操作符,应该如何拦截呢?

  1. 我们可以先查看表 5-3,尝试寻找与 in 操作符对应的拦截函数,但表 5-3 中没有与 in 操作符相关的内容。
  2. 查看关于 in 操作符的相关规范。
  3. 在 ECMA-262 规范的 13.10.1 节中,明确定义了 in 操作符的运行时逻辑

alt text

查看 HasProperty 抽象方法的逻辑

alt text

可以看到 HasProperty 抽象方法的返回值是通过调用对象的内部方法[​[HasProperty]​] 得到的。而 [​[HasProperty]​] 内部方法可以在表 5-3 中找到,它对应的拦截函数名叫 has,因此我们可以通过 has 拦截函数实现对 in 操作符的代理

js
const obj = { foo: 1 }
const p = new Proxy(obj, {
  has(target, key) {
    track(target, key)
    return Reflect.has(target, key)
  }
})

来看看如何拦截 for...in 循环,为了搞清楚 for...in 循环依赖哪些基本语义方法,还需要看规范。 alt text

仔细观察第 6 步的第 c 子步骤:让 iterator 的值为 ? EnumerateObjectProperties(obj)。

关键点在于 EnumerateObjectProperties(obj)。这里的EnumerateObjectProperties 是一个抽象方法,该方法返回一个迭代器对象,规范的14.7.5.9 节给出了满足该抽象方法的示例实现,如下面的代码所示:

js
function* EnumerateObjectProperties(obj) {
  const visited = new Set();
  
  for (const key of Reflect.ownKeys(obj)) {
    if (typeof key === "symbol") continue;
    const desc = Reflect.getOwnPropertyDescriptor(obj, key);
    if (desc) {
      visited.add(key);
      if (desc.enumerable) yield key;
    }
  }
  const proto = Reflect.getPrototypeOf(obj);
  if (proto === null) return;
  for (const protoKey of EnumerateObjectProperties(proto)) {
    if (!visited.has(protoKey)) yield protoKey;
  }
}

可以看到,该方法是一个 generator 函数,接收一个参数 obj。实际上,obj 就是被for...in 循环遍历的对象,其关键点在于使用 Reflect.ownKeys(obj) 来获取只属于对象自身拥有的键。 有了这个线索,如何拦截 for...in 循环的答案已经很明显了,我们可以使用 ownKeys 拦截函数来拦截 Reflect.ownKeys 操作:

js
const obj = { foo: 1 }
const ITERATE_KEY = Symbol()

const p = new Proxy(obj, {
  ownKeys(target) {
    // 将副作用函数与 ITERATE_KEY 关联
    track(target, ITERATE_KEY)
    return Reflect.ownKeys(target)
  }
})

构造唯一的 key 作为标识,即 ITERATE_KEY,来标记 ownKeys(获取一个对象的所有属于自己的键值) 操作的 key

既然追踪的是 ITERATE_KEY,那么相应地,在触发响应的时候也应该触发它才行:

js
trigger(target, ITERATE_KEY)

但是在什么情况下,对数据的操作需要触发与 ITERATE_KEY 相关联的副作用函数重新执行呢?为了搞清楚这个问题,我们用一段代码来说明。假设副作用函数内有一段for...in 循环

js
const obj = { foo: 1 }
const p = new Proxy(obj, {/* ... */})
effect(() => {
  // for...in 循环
  for (const key in p) {
    console.log(key) // foo
  }
})
// 副作用函数执行后,会与 ITERATE_KEY 之间建立响应联系,接下来我们尝试为对象p 添加新的属性 bar:
p.bar = 2

由于对象 p 原本只有 foo 属性,因此 for...in 循环只会执行一次。现在为它添加了新的属性 bar,所以 for...in 循环就会由执行一次变成执行两次。也就是说,当为对象添加新属性时,会对 for...in 循环产生影响,所以需要触发与 ITERATE_KEY 相关联的副作用函数重新执行。但目前的实现还做不到这一点。当我们为对象 p 添加新的属性bar 时,并没有触发副作用函数重新执行,这是为什么呢?我们来看一下现在的 set 拦截函数的实现:

js
const p = new Proxy(obj, {
  // 拦截设置操作
  set(target, key, newVal, receiver) {
    // 设置属性值
    const res = Reflect.set(target, key, newVal, receiver)
    // 把副作用函数从桶里取出并执行
    trigger(target, key)

    return res
  },
  // 省略其他拦截函数
})
js
 function trigger(target, key) {

    // xxxx

   // 取得与 ITERATE_KEY 相关联的副作用函数
   const iterateEffects = depsMap.get(ITERATE_KEY)

   // 将与 ITERATE_KEY 相关联的副作用函数也添加到 effectsToRun
   iterateEffects && iterateEffects.forEach(effectFn => {
     if (effectFn !== activeEffect) {
       effectsToRun.add(effectFn)
     }
   })
  // xxxx
 }

解决了新增的响应式,若将新增修改为修改属性,应该不会对for in 产生影响,所以现在还需要继续处理,解决方案是 当设置属性操作发生时,就需要我们在 set 拦截函数内能够区分操作的类型,到底是添加新属性还是设置已有属性

js
const p = new Proxy(obj, {
  // 拦截设置操作
  set(target, key, newVal, receiver) {
    // 如果属性不存在,则说明是在添加新属性,否则是设置已有属性
    const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
    // 设置属性值
    const res = Reflect.set(target, key, newVal, receiver)
    // 将 type 作为第三个参数传递给 trigger 函数
    trigger(target, key, type)
    return res
  },
  // 省略其他拦截函数
})
js
function trigger(target, key, type) {

  // 只有当操作类型为 'ADD' 时,才触发与 ITERATE_KEY 相关联的副作用函数重新执行
  if (type === 'ADD') {
    const iterateEffects = depsMap.get(ITERATE_KEY)
    iterateEffects && iterateEffects.forEach(effectFn => {
      if (effectFn !== activeEffect) {
        effectsToRun.add(effectFn)
      }
    })
  }

}

处理删除操作

js
const p = new Proxy(obj, {
  deleteProperty(target, key) {
    // 检查被操作的属性是否是对象自己的属性
    const hadKey = Object.prototype.hasOwnProperty.call(target, key)
    // 使用 Reflect.deleteProperty 完成属性的删除
    const res = Reflect.deleteProperty(target, key)

    if (res && hadKey) {
      // 只有当被删除的属性是对象自己的属性并且成功删除时,才触发更新
      trigger(target, key, 'DELETE')
    }

    return res
  }
})
js
function trigger(target, key, type) {

  // 当操作类型为 ADD 或 DELETE 时,需要触发与 ITERATE_KEY 相关联的副作用函数重新执行
  if (type === 'ADD' || type === 'DELETE') {
    const iterateEffects = depsMap.get(ITERATE_KEY)
    iterateEffects && iterateEffects.forEach(effectFn => {
      if (effectFn !== activeEffect) {
        effectsToRun.add(effectFn)
      }
    })
  }
 
}

4 合理地触发响应

特殊情况 当值没有发生变化时 (不触发) NaN 继承

js
const p = new Proxy(obj, {
  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
  },
})
js
 const obj = {}
 const proto = { bar: 1 }
 const child = reactive(obj)
 const parent = reactive(proto)
 // 使用 parent 作为 child 的原型
 Object.setPrototypeOf(child, parent)

 effect(() => {
   console.log(child.bar) // 1
 })
 // 修改 child.bar 的值
 child.bar = 2 // 会导致副作用函数重新执行两次

原因 两层代理 两个拦截 两次执行 解决方案 运行副作用的时候 需要判断是否是当前对象