首页 阅读DAY19 JavaScript高级程序设计 9章 代理与反射
文章
取消

阅读DAY19 JavaScript高级程序设计 9章 代理与反射

开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。

JavaScript高级程序设计(第5版)微信读书

红宝书《JavaScript高级程序设计(第5版)》学习大纲 - 大前端全栈开发 - SegmentFault 思否


Proxy 与 Reflect 的分工

Proxy 和 Reflect 是 ES6 同期引入的一对互补 API:Proxy 负责”拦截”,Reflect 负责”在拦截后正确地执行默认操作”。

但并非所有场景都需要 Reflect。以下按对象属性的三种形态,逐步说明 target[key]Reflect.get(target, key, receiver) 的差异。

前置概念:属性存取器与 Proxy 陷阱的同名异质

getset 以两种不同面目出现在 JavaScript 中,但二者并非名称的巧合:handler 中的 get/set 陷阱与属性存取器的 [[Get]]/[[Set]],底层拦截的正是同一条规范内部方法调用链。区别在于挂载的层级不同——一个挂在属性描述符层,一个挂在对象代理层。明确这两个层次的差异,对后续讨论 Proxy 与 Reflect 的协作至关重要。

第一种面目是属性描述符中的存取器(accessor descriptor)。当通过 Object.defineProperty 或对象字面量中的 get key() {} 语法为某个属性配置 getter/setter 时,属性本身的性质发生了根本改变:该属性不再拥有 [[Value]] 槽位,取而代之的是 [[Get]][[Set]] 两个函数引用。读取该属性时,引擎不再从 [[Value]] 中取值,而是调用 [[Get]] 并返回其返回值。属性键名依旧存在,但”键值对”这一概念在此处已不适用,因为值不是存储的,而是每次读取时由函数临时计算产出的。此机制作用于单个属性级别,一个对象的不同属性可以各自独立地选择数据描述符或存取描述符。

第二种面目是 Proxy 构造函数的 handler 对象中的 get/set 陷阱(trap)。handler 中的 get(target, key, receiver) 并非某个属性的描述符,而是一道拦截整个对象所有属性读写操作的关卡。其核心性质可概括为三点:其一,拦截发生在对象层面,而非属性层面,所有属性(无论新增还是已有)的读写一律经过这同一道陷阱;其二,陷阱本身不改变目标对象的属性描述符,目标对象上使用数据描述符的属性依然保有 [[Value]],使用存取描述符的属性依然保有 [[Get]]/[[Set]],陷阱仅在读写操作到达目标对象之前插入一段自定义逻辑;其三,陷阱的返回值是最终暴露给外部的值,但源数据仍然完好地存储在目标对象的对应槽位中。

这两种机制的核心差异在于”值”的归属。存取描述符将值的产出逻辑直接写在属性自身,属性不再持有 [[Value]];Proxy 陷阱则将拦截逻辑写在对象的外部包装层上,目标对象的 [[Value]] 以及其他描述符字段全程未被触碰。因此,同一个属性在被 Proxy 包装后,通过 proxy 访问会经过陷阱并触发依赖收集,通过原始对象直接访问则不受任何影响,依然返回原始的 [[Value]]。存取描述符不具备这种”隔离性”,getter 一旦被定义,无论从哪个引用读取该属性,走的是同一条 [[Get]] 函数路径。

从 ECMAScript 规范的角度看,这两种机制对应的内部方法调用路径不同。存取描述符的 getter 在 [[Get]](P, receiver) 内部方法的第 5 步被调用,属于”属性解析完成后发现描述符为存取型”这一分支。Proxy 的 get 陷阱则在 [[Get]] 内部方法更早的阶段触发:当 [[Get]] 发现目标对象是 Proxy 实例时,跳转至 [[GetP]] 内部方法,进而调用 handler 中注册的 get 函数。同样的名称(get),挂载的位置和时机完全不同。

情况一:纯数据属性::target[key] 可以直接工作

假设原始对象仅包含普通属性(data descriptor,无 getter/setter):

1
2
3
4
5
6
7
const obj = { name: 'hello', age: 18 };
const proxy = new Proxy(obj, {
  get(target, key) {
    return target[key];  // 正常工作
  }
});
proxy.name;  // "hello"

普通的属性访问不涉及函数调用,不存在 this 绑定对象的问题。target[key] 直接取到属性的值,两种写法在此场景下行为完全一致。

情况二:原始对象自身存在 getter/setter::target[key] 失效

getter 和 setter 并非 Proxy 的专属机制。ES5 的 Object.defineProperty() 即可将属性的描述符从数据描述符改为存取描述符,使属性的读写行为由一个函数定义(参见本书 read10 篇):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const obj = {};
Object.defineProperty(obj, 'nickname', {
  get() {
    return this._nickname;  // 读取 this._nickname 时触发什么?取决于 this 指向谁
  }
});

const proxy = new Proxy(obj, {
  get(target, key) {
    return target[key];  // getter 调用时 this = obj(原始对象),不是 proxy
  }
});

proxy.nickname;
// 内部:obj.nickname 的 getter 被调用
// getter 中的 this 指向 obj
// this._nickname → obj._nickname :: 绕过了 proxy 的 get 陷阱

target[key] 的本质是 target.[[Get]](key, target)::[[Get]] 内部方法收到的 receiver 是 target(原始对象)。当引擎沿属性描述符找到 getter 时,以 receiver(即原始对象)为 this 调用该 getter。proxy 在此流程中被完全跳过。

情况三:getter/setter 在原型链上::同样失效

若 getter 定义在原型链上层对象上,问题相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const parent = {
  get nickname() {
    return this._nickname;  // this 指向谁,谁就需要过代理陷阱
  }
};

const child = Object.create(parent);
const proxy = new Proxy(child, {
  get(target, key) {
    return target[key];  // 引擎沿原型链找到 parent.nickname 的 getter
  }
});

proxy.nickname;
// target[key] → target.[[Get]](key, target)
// 原型链查找 → 在 parent 上找到 nickname 的 getter
// getter 的 this = target = child(原始对象),非 proxy
// this._nickname 的读取不经过 proxy 的 get 陷阱

根因一致:target[key][[Get]] 的 receiver 固定为原始对象,无论 getter 定义在自身还是原型链上,this 均绑定为原始对象。

Reflect 的修正

Reflect.get(target, key, receiver) 将 proxy 作为 receiver 传入 [[Get]]

1
2
3
4
5
6
7
8
9
10
const proxy = new Proxy(child, {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver);  // receiver = proxy
  }
});

proxy.nickname;
// Reflect.get(target, key, receiver) → target.[[Get]](key, receiver=proxy)
// getter 的 this = receiver = proxy
// getter 内部 this._nickname → proxy._nickname → 再次触发 proxy 的 get 陷阱 ✅

路线差异可以精确总结为:

写法[[Get]] 的 receivergetter 的 thisgetter 内部的属性访问是否过 proxy
target[key]原始对象原始对象
Reflect.get(target, key, receiver)proxyproxy

结论:何时需要 Reflect

Reflect.get/set 的必要性仅限于一种情形::原始对象或其原型链上存在 getter/setter。对于仅含普通属性的对象,target[key] 行为正确。

那么为何 Vue 3 的响应式系统在任何场景都走 Reflect.get?因为框架无法预先知道使用者会不会在某处定义了 getter、或者某个对象的原型链上是否潜伏了一个 getter。逐属性检查”是否有 getter”会引入性能开销和代码分支复杂度。走 Reflect.get 是一条统一切换线::它不挑场景,对任何属性访问都保持行为统一,是一种”无需判断”的设计选择。

Proxy 的 13 个陷阱方法与 Reflect 的 13 个方法一一对应,属于规范层面的刻意设计:每个 Proxy 陷阱的默认行为恰好是其对应的 Reflect 方法。Reflect 本身不执行任何显式的 this 修正动作::它仅仅请求引擎”按规范完成一次属性读取/写入”,receiver 是这次请求中附带的一个参数。receiver 在属性为普通 data descriptor 时不参与行为,仅在遇到 getter/setter 时被引擎用于 this 绑定。


属性访问与函数调用触发两次陷阱

Proxy 的 get 陷阱在属性访问时触发,但在方法调用场景中存在一个值得注意的现象:proxy.method() 会触发两次 get 陷阱,而非一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const obj = {
  name: '小明',
  getName() {
    return this.name;
  }
};

const proxy = new Proxy(obj, {
  get(target, key, receiver) {
    console.log('读取:', key);
    return Reflect.get(target, key, receiver);
  }
});

proxy.getName();
// 读取: getName
// 读取: name

两次触发的原因在于 JavaScript 引擎对 proxy.getName() 的处理天然分为两个阶段:

第一阶段:proxy.getName,属性访问。引擎先获取 getName 属性的值,触发 get 陷阱,key = "getName"return Reflect.get(target, key, receiver) 返回 obj.getName 所指向的函数对象。

第二阶段:(),函数调用。引擎执行获取到的函数。函数体内部的 return this.name 是一次独立的属性访问:this 指向 proxy,访问 this.name 再次触发 get 陷阱,key = "name"

两次陷阱并非 Proxy 的特殊机制,而是普通 JavaScript 语义在代理层上的投影:属性访问和函数调用是两个独立步骤,代理忠实地为每一步执行拦截。

若不用 Reflect 而直接 return target[key],内部 this.namethis 将指向谁?这正是下一节讨论的 Reflect 核心价值所在。


不用 Reflect 时的 this 绑定问题

一个常见的误解是”不用 Reflect 时 this 就一定指向原始对象”。实际上,对于普通的 proxy.method() 调用而言,不用 Reflect 也不会产生错误:因为 proxy.method() 的执行上下文天然是 proxy,JavaScript 引擎按调用点规则将 this 正确绑定。真正暴露问题的是继承链回调传递两种场景。

场景一:普通方法不受影响,getter 才会暴露问题

对于普通的原型链方法调用,target[key] 没有问题::因为 child.getName() 的调用点语法让 JS 引擎将 this 绑给 child(即 proxy),跟函数定义在原型链上还是自身无关:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const parent = {
  name: '父级',
  getName() {
    return this.name;
  }
};

const child = new Proxy(Object.create(parent), {
  get(target, key) {
    console.log('读取:', key);
    return target[key];  // 不使用 Reflect
  }
});

child.getName();
// 读取: getName
// 读取: name
// 两次 get 陷阱正常触发::this 就是 proxy,没有问题

真正暴露 receiver 缺失的场景是原型链上的 getter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const parent = {
  get nickname() {
    return this._nickname || '匿名';
  }
};

const child = new Proxy(Object.create(parent), {
  get(target, key) {
    return target[key];  // ❌ 缺失 receiver
  }
});

child.nickname;
// getter 触发时 this = parent(不是 proxy)
// 若 getter 内进一步访问 this.xxx,不会过代理陷阱

修正方式相同::传入 receiver

1
2
3
4
5
6
7
const child2 = new Proxy(Object.create(parent), {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver);
  }
});
// 读取: nickname
// getter 内 this 被 receiver(proxy)修正,再次访问 this._nickname 也会过 proxy 陷阱

Reflect.get 的第三个参数 receiver 即为此问题而设。receiver 在 Proxy 的 get 陷阱中始终是 proxy 自身。Reflect.get(target, key, receiver) 向 JavaScript 引擎指明:访问 getter 时,this 应指向调用链最前端的 receiver,而非属性真实所在的那个原型链上层对象。

场景二:方法被当作回调传递

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const obj = {
  name: '小明',
  getName() {
    return this.name;
  }
};

const proxy = new Proxy(obj, {
  get(target, key) {
    return target[key];  // 不使用 Reflect
  }
});

const fn = proxy.getName;  // 只触发一次 get 陷阱
fn();  // this === undefined(严格模式),this.name 报错

此处的问题根源不在 Proxy 也不在 Reflect:fn() 是裸函数调用,任何函数以这种方式调用,其 this 均为 undefined(严格模式下)。但这揭示了一个更深层的事实:Proxy 的 get 陷阱中 return target[key] 返回的是原始函数对象,该函数丢失了”最初从 proxy 上调出”的调用上下文。使用 .bind(proxy) 或在 get 陷阱中返回已绑定 this 的函数可以弥补,但真正系统性的解决方案是始终使用 Reflect.get 保持 receiver 链的完整性。

在实际框架代码中(如 Vue 3 的响应式系统),Reflect 的使用是标准做法:Reflect.get(target, key, receiver) 确保所有属性访问(包括方法内部的 this.xxx)全部经过代理管道,依赖收集不产生遗漏。


Reflect 的设计目的与核心价值

Reflect 的设计目的

Reflect 是 ES6 与 Proxy 同时引入的内置对象,其方法集合与 Proxy 的陷阱方法一一对应:

Proxy 陷阱Reflect 方法操作
getReflect.get()读取属性
setReflect.set()设置属性
hasReflect.has()in 操作符
deletePropertyReflect.deleteProperty()delete 操作符
getPrototypeOfReflect.getPrototypeOf()获取原型
setPrototypeOfReflect.setPrototypeOf()设置原型
applyReflect.apply()函数调用
constructReflect.construct()new 操作符
等共 13 个等共 13 个 

该设计的逻辑在于:每一个 Proxy 陷阱的默认行为恰好是对应的 Reflect 方法。因此 Proxy 中写 return Reflect.get(target, key, receiver) 的语义是”除自定义逻辑外,其余行为与原对象一致”。若直接使用 target[key],将丢失 receiver 这一关键参数,以及 getterthis 绑定等若干边界行为。

Reflect 的三个核心价值(一个核心,两个附属)

核心:纠正 this 指向。如前两节所述:Reflect.get(target, key, receiver) 的第三个参数确保属性访问的 this 指向 receiver(proxy),而非原始对象或原型链上级的对象。与 Proxy 搭配使用时,这一能力是无可替代的::除 Reflect.getReflect.set 外,没有任何其他机制能将 receiver 递入 ECMAScript 规范的 [[Get]][[Set]] 内部方法。

其运作机制可精确描述如下。当执行 proxy.nickname 时,引擎调用 proxy.[[Get]]('nickname', proxy),进入 Proxy 的 get 陷阱,参数 receiver 为 proxy 自身。若陷阱中写 return target[key],等价于 target.[[Get]]('nickname', target)::引擎沿原型链查找到 nickname 对应的 getter,以 target(即 parent)为 this 调用该 getter。而 return Reflect.get(target, key, receiver) 等价于 target.[[Get]]('nickname', receiver)::引擎以 receiver(即 proxy)为 this 调用 getter,this 由此被导向 proxy。

关键事实:Reflect.get 并未执行任何显式的 this 修正动作。它仅仅将 receiver 原样传递给 [[Get]] 内部方法,而引擎在遇到 accessor descriptor(getter/setter)时使用 receiver 作为 getter.call(receiver)this。对于普通 data descriptor(无 getter/setter 的属性),receiver[[Get]] 内部流程中无任何用途::这正是前一节中普通方法调用不受影响的根本原因。

Reflect.get/set 的 receiver 传递是核心能力。另外两个附属价值并非与 Proxy 绑定::Reflect 的其他方法在不使用 Proxy 的场景同样可用。但 receiver 这唯一的活,没有 Reflect.get/set 就无法完成。

附属一:返回布尔值代替抛异常。若干传统操作符在失败时抛出异常或静默失败,Reflect 对应方法统一返回布尔值或结果值:

1
2
3
4
5
6
7
// 传统写法:失败抛异常
Object.defineProperty(obj, 'name', { value: 'test' });  // 可能抛 TypeError

// Reflect 写法:失败返回 false,不中断执行流
Reflect.defineProperty(obj, 'name', { value: 'test' });  // 返回 true 或 false
Reflect.set(obj, 'name', 'value');                        // 返回布尔值
Reflect.deleteProperty(obj, 'name');                      // 返回布尔值

此设计使 Reflect 在框架代码中免于大量 try-catch,且与 Proxy 对应陷阱的布尔返回值约定天然对齐::陷阱中直接 return Reflect.xxx(...) 即可贯通默认行为与自定义逻辑。

附属二:将元操作统一为函数调用。部分语言操作符在 ES5 时代缺少对应的函数形式,Reflect 完成了这一补充:

操作符Reflect 函数
delete obj.keyReflect.deleteProperty(obj, key)
key in objReflect.has(obj, key)
new F(...)Reflect.construct(F, args)
obj.keyReflect.get(obj, key)
func.apply(thisArg, args)Reflect.apply(func, thisArg, args)

这使元操作可被作为一等公民传递,例如 ['name', 'age'].map(Reflect.get.bind(null, obj))。同时 Reflect.applyReflect.construct 作为 Reflect 上的静态方法,不会被对象自有属性覆盖,对比 Function.prototype.apply 可能被同名实例属性遮蔽,具有更高的操作安全性。

Reflect 与 Proxy 在 Vue 3 中的协同

Vue 3 响应式系统中,reactive() 使用 Proxy 创建响应式对象时,每一个陷阱方法均配套了一个 Reflect 调用。这不是可选的优化,而是功能性前提。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Vue 3 reactive 的极简骨架
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      track(target, key);                         // 依赖收集
      return Reflect.get(target, key, receiver);  // 默认读取行为 + receiver 绑定
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver);
      trigger(target, key);                       // 派发更新
      return result;                              // 返回操作是否成功
    },
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key);
      trigger(target, key);
      return result;
    }
  });
}

Reflect 在此处的角色是让 Proxy 的默认行为正确发生的必要条件。若移除 Reflect,Vue 的响应式依赖收集在嵌套对象和继承场景下会产生遗漏:其后果不是”不够好”,而是直接失效。

Proxy 可理解为”修改行为的钩子”,Reflect 则为”执行默认行为的标准路径”。Proxy 负责在读写时附加自定义逻辑,Reflect 负责按规范执行默认操作并保持 this 指向正确。两者从 ES6 设计之初即为互补:Reflect 的 13 个方法与 Proxy 的 13 个陷阱一一对应,属于规范层面的刻意设计。

本文由作者按照 CC BY 4.0 进行授权

Jekyll 侧边栏自定义「最近」Tab 页实现记录

网络知识体系:从基础协议到 Web 安全