【翻译】Proxy 与 Reflect

0 阅读14分钟

Proxy 与 Reflect

原文链接:piccalil.li/blog/proxy-…

原文作者:Mat Marquis(Piccalilli 作者、独立前端开发者)

文章头图

Mat Marquis2026 年 7 月 9 日

主题: JavaScript

我是 Mat,Piccalilli 自家课程 JavaScript for Everyone 的作者;这门课旨在帮你完成从初级到高级开发者的跃迁。一如既往,我来教你 JavaScript——不只是是什么,还有怎么做以及为什么

这一次,我要讲的是如何使用 Proxy 构造函数和 Reflect 对象。它们自然是值得放进工具箱的语言特性——但同样重要的是,它们让我们得以贴近 JavaScript 最内层的运作方式,从而更好地理解驱动这门语言的机制形态。这种认知,正是高级开发者该有的功底。

眼尖的读者可能已经发现,这篇文章的结构明显像是从上述课程里摘出来的一课——然而它在课时列表页上却无处可见。「这到底意味着什么?」你可能会问。

好吧,敬请期待

对象是一组属——!别冲我翻白眼!听着,我知道这句话快赶上「线粒体是细胞的动力工厂」那种陈词滥调了,但我保证这次要往新方向讲。

咳咳。对象是一组属性、内部槽(internal slots)以及内部方法(internal methods)的集合,让我们得以与这些属性交互。当你在开发者控制台里敲下 ({}).theProperty 时,你期待的是下面这种「自选冒险」式的流程启动:

  • 这个键是否位于对象原型链上的某处?
    • 是。

      • 它是数据属性(data property)吗?

        • 则返回属性描述符中的 value
      • 它是访问器属性(accessor property)吗?

        • 则调用 getter 方法,并返回该方法返回的值。
    • 否。

      • 则返回 undefined

这种属性访问器语法本身并不代表上述全部步骤——点号只是启动规范所定义的 [[Get]] 内部操作的 API,而 [[Get]] 操作所执行的步骤决定最终结果。

我们无法钻进去改动对象内部方法的具体步骤,大概也不想这么做——那是 JavaScript 引擎的地盘。我们能做的是通过**代理(proxy)**对象拦截这些操作,从而在最根本的层面上改变、扩展或彻底重定义对象的工作方式。

看吧?我还以为你会觉得这篇会很无聊呢——显然没有。

Proxy 构造函数可用来创建一个对象,作为目标对象(target object)的代理,让你借助前者作为中介,拦截并重定义对后者执行的操作。

new 调用时,Proxy 会返回一个对象——这并不意外。它接受两个参数:目标对象处理器对象(handler object)


const targetObject = {
  theProperty: "A string."
};
const handlerObject = {};
const theProxyObject = new Proxy( targetObject, handlerObject );

console.log( theProxyObject );

// 结果(展开):Proxy 对象及其 target、handler 内部引用

// 结果(展开):Proxy 对象及其 target、handler 内部引用

控制台里没什么出人意料的。我们新造的代理对象包含对目标对象的引用,以及一组内部槽;浏览器用 <>[[]] 记号标出——取决于浏览器——表明我们不应像操作字符串属性键那样直接碰这些槽。[[Target]] 是表示我们要作用的对象的内部槽,连同我们定义的属性。[[Handler]] 表示作为与目标对象交互中介的处理器对象。

我敢打赌 Chrome 里的 [[isRevoked]] 立刻跳进了你的视线。别担心,我们还没撞上什么 2010 年代那种浏览器行为大分歧——只是内部槽的展示方式不同。我们稍后会讲到。

若我们改掉目标对象上已定义属性关联的值,代理对象对它的引用也会反映这一变化:


const targetObject = {
  theProperty: "A string."
};
const handlerObject = {};
const theProxyObject = new Proxy( targetObject, handlerObject );

targetObject.theProperty = "Something else.";

console.log( theProxyObject );
// 结果(展开):Proxy 对象及其 target、handler 内部引用

乍一看,这像经典的「按引用」那一套——「对象值按引用存储」「对象是一组属性」「对象是脚本的动力工厂」,诸如此类。但记住,我们说的不是变量或属性——此处代理对象本身就是对目标对象的引用。这个代理可以整体替代目标对象:


const targetObject = {
  theProperty: "A string."
};
const handlerObject = {};
const theProxyObject = new Proxy( targetObject, handlerObject );

console.log( theProxyObject.theProperty );

// 结果:A string.

访问代理对象的属性,实质上就是经由代理对象访问目标对象上的该属性。你也不能像往常一样在代理对象上定义自有属性——属性会被定义在目标上:


const targetObject = {
  theProperty: "A string."
};
const handlerObject = {};
const theProxyObject = new Proxy( targetObject, handlerObject );

theProxyObject.theOtherProperty = "Another string";

console.log( theProxyObject );

// 结果(展开):Proxy 对象及其 target、handler 内部引用

按你目前所见的方式使用,我们基本上只是造了一个步骤更多的古怪引用值——那是因为我们还没让处理器对象通过代理任何事;这里的处理器对象基本上是在把一种语言翻译成同一种语言。一旦开始在处理器对象上创建处理器函数(handler functions)——有时也叫陷阱(traps)——用例就清晰一些了:


const targetObject = {
  theProperty: "A string."
};
const handlerObject = {
  get() {
    return "Something else entirely.";
  }
};
const theProxyObject = new Proxy( targetObject, handlerObject );

console.log( theProxyObject.theProperty );
// 结果:"Something else entirely." )

对象上可执行的每种操作都有对应的陷阱,命名都相当可预测。处理器对象上定义的 get() 方法,是针对 [[Get]] 内部方法的陷阱——每当你试图读取对象属性值时就会触发。像这样让它返回显式值,我们算是把一次本来好好的 [[Get]] 操作搞砸了——我们拦截了该操作并改变了它应得的结果。现在无论对 targetObject 做什么,访问任何属性值都会精确返回我们规定的内容:


const targetObject = {
  theProperty: "A string."
};
const handlerObject = {
  get() {
    return "You've just activated my trap function!";
  }
};
const theProxyObject = new Proxy( targetObject, handlerObject );

console.log( theProxyObject );

// 结果(展开):Proxy 对象及其 target、handler 内部引用

targetObject.newProperty = true;

console.log( theProxyObject );

// 结果(展开):Proxy 对象及其 target、handler 内部引用

console.log( theProxyObject.newProperty );
// 结果:You've just activated my trap function!

console.log( theProxyObject[ "newProperty" ] );
// 结果:You've just activated my trap function!

和任何函数一样,陷阱函数也可以执行我们想要的任何任务,没有例外:


const targetObject = {
  theProperty: true
};
const handlerObject = {
  get() {
    console.log( "Psych!" );
    return false;
  }
};
const theProxyObject = new Proxy( targetObject, handlerObject );

console.log( theProxyObject.theProperty );

// 结果(展开):Proxy 对象及其 target、handler 内部引用

自然,这还包括用比 console.log 加字符串更细腻的方式操纵这些操作的结果。处理器对象的 get() 方法接受三个参数:一个表示目标对象,一个表示被访问属性的键,一个表示「接收者(receiver)」——概念稍高一层:receiver 参数表示 getter 方法上下文中 this 的值——即get 方法被调用时与之绑定的对象的引用(关于 this 的更多说明)——听起来可能让人紧张,因为 this 向来如此,但多数情况下它会是目标对象。

有了这些参数,我们就能用 get 方法访问并操纵目标对象属性的值:


const targetObject = {
  theProperty: 10
};
const handlerObject = {
  get( target, propertyKey, receiver) {
    return target[ propertyKey ] * 2;
  }
};
const theDoubleObject = new Proxy( targetObject, handlerObject );

console.log( theDoubleObject.theProperty );
// 结果:20

好了,既然你知道什么会被撤销,现在回到 [[isRevoked]]。创建代理对象还有第二种方式:Proxy.revocable() 工厂函数。


const targetObject = {};
const handlerObject = {};
const revocableProxy = Proxy.revocable( targetObject, handlerObject );

调用 Proxy.revocable(与 new Proxy 使用相同的目标与处理器参数)得到的对象包含两个属性。第一个属性 proxy 相当可预测地包含一个代理对象,其值与用 Proxy 作构造函数、相同参数所得到的代理对象一致。

第二个属性是 revoke 方法,可将该代理对象与其目标对象彻底分离:


const revocableProxy = Proxy.revocable({}, {});

console.log( revocableProxy );

// 结果(展开):含 proxy 与 revoke 方法的对象

// 结果(展开):含 proxy 与 revoke 方法的对象

调用 revoke() 会取消对象的代理——一旦调用,Proxy.revocable() 返回的代理对象将不再持有对目标或处理器对象的引用:


const revocableProxy = Proxy.revocable({}, {});

revocableProxy.revoke();

console.log( revocableProxy );

// 结果(展开):含 proxy 与 revoke 方法的对象

// 结果(展开):含 proxy 与 revoke 方法的对象

请记住「已撤销」就是已撤销——一旦调用 revoke,没有反悔。若不存在对代理对象的其他引用,它就可被垃圾回收——目标与处理器对象若别处也无引用,同样如此。

我猜 Chrome 的 JavaScript 引擎在开发者控制台里暴露 [[isRevoked]] 内部槽是为了一眼调试——语言(目前)并没有给我们直接读取该内部槽值的途径。撤销代理在任一浏览器里都一样好用,不必担心。

对象的每个内部方法都有对应陷阱,这意味着你能在语言范围内、对任意对象、在任意层面改变其基底行为:


const targetObject = {
  theProperty: "Still here."
};

const handlerObject = {
  deleteProperty( target, key) {
    console.log( "No." );
    return false;
  },
  has( target, key ) {
    console.log( "None of your business." );
    return false;
  },
  getPrototypeOf( target ) {
    console.log( "Who knows?" );
    return null;
  }
};

const theImmovableObject = new Proxy( targetObject, handlerObject );

delete theImmovableObject.theProperty;
// 结果:控制台输出拒绝操作的文案与返回值

console.log( theImmovableObject.theProperty );
// 结果:Still here.

console.log( "theProperty" in theImmovableObject );
// 结果:控制台输出拒绝操作的文案与返回值

console.log( Object.getPrototypeOf( theImmovableObject ) );
// 结果:控制台输出拒绝操作的文案与返回值

所以,你知道的,只要把代理陷阱写对就行。

完全没压力。

哦对了,说到撤销代理这件事:

Reflect

你可能已经注意到迄今为止这些代码示例里的几件事。其一,我们像在 2009 年派对——用老派方括号记法访问属性。

其二,在改变 [[Get]] 工作方式的语境里,用我们正在改变的那些语法去 [[Get]] 属性值,有点令人不安。能跑,但严格从技术角度讲,氛围不对。

第三,也是远远最重要的一点:打破像「我在对象上创建属性时,会按我预期的方式发生」这类重大假设,绝对会在当下或日后某处给代码带来问题。能够改变对象本身的本质,在「令人兴奋」与「令人恐惧」之间划出一条剃刀般细的线,尤其关系到未来的可维护性;在一个遍布可能按也可能不按对象方式工作的对象的代码库里干活,简直是噩梦素材。

为此,ES-262 规范贴心地列出了 [[Set]] 的以下不变量(invariants)——即 [[Set]] 操作必须遵守的规则:

  • [[Set]] 的结果是布尔值。

  • 若对应的目标对象属性是不可写、不可配置的自有的数据属性,则不能把该属性的值改成与对应目标对象属性不同的值。

  • 若对应的目标对象属性是不可配置的自有的访问器属性,且其 [[Set]] 属性为 undefined,则不能设置该属性的值。

一旦你开始摆弄 [[Set]] 如何工作,遵守这些不变量规则就落在你身上了。若我写类似下面的东西:


const handlerObject = {
  set( target, propertyKey, value, receiver) {
    return target[ propertyKey ] = value * 2;
  }
};

const setDoubler = new Proxy( {}, handlerObject );

setDoubler.theProperty = 2;

console.log( setDoubler.theProperty );
// 结果:4

我的 [[Set]] 操作没有按 JavaScript 预期返回布尔值——按 [[Set]] 神圣的不变量规则本该如此。我是说,这段在非严格模式下仍然能跑,返回值被强制为布尔,而这一次恰好为真。并非每种语境都行:


"use strict";
const handlerObject = {
  set( target, propertyKey, value, receiver) {
    return target[ propertyKey ] = value * 2;
  }
};

const setDoubler = new Proxy( {}, handlerObject );

setDoubler.theProperty = 0;

console.log( setDoubler.theProperty );

// 结果:未捕获 TypeError——代理 set 处理器对属性返回了 false

我们当然可以重写,显式 return 预期的布尔值;但即便处理这个简单的处理器方法,我们也已进入「我必须时刻以这种方式做这件事」的地带,否则就会把根本有 bug 的对象引进代码库。没人需要那个。

这就引出了 Reflect 对象:一组静态方法的集合,每个方法与我们的代理处理器方法同名、参数相同。Reflect 通过提供一套与对象交互的方法,给我们一些护栏,解决上述所有顾虑(糟糕的氛围也算),并确保我们永远不会偏离对象理应的工作方式太远。

Reflect 是一个命名空间对象(namespace object)——由静态属性与方法组成的普通对象,像 MathTemporal 对象:


console.log( Reflect );

// 结果(展开):Reflect 命名空间对象的各静态方法列表

这些方法每一个都映射到你代理对象处理器方法的名称,并以相同方式期待相同参数——其语法比我们正在工作的语境贴切得多,远胜方括号记法。光这一点,在我看来就极大改善了氛围:


const handlerObject = {
  set( target, propertyKey, value) {
    return Reflect.set( target, propertyKey, value * 2 );
  }
};

const setDoubler = new Proxy( {}, handlerObject );

setDoubler.theProperty = 2;

console.log( setDoubler.theProperty );
// 结果:4

不折腾、不啰嗦——最重要的是,不必翻 ES-262 规范来确认我们没有意外偏离对象运作之规则,因为 Reflect.set() 执行我们想要的 [[Set]] 操作,按规范返回预期的布尔值。用 Proxy 我们可以改变对象如何工作,用 Reflect 我们可以确保改过的对象仍以对象理应的方式工作。

综合应用

把这些能力合在一起,我们就能用代理对象和 Reflect 执行诸如校验数据之类的任务:


const validationHandler = {
  set( target, propertyKey, value, receiver ) {
    if( typeof value === "string" ) {
      return Reflect.set( target, propertyKey, value );
    } else {
      console.error( "This object only accepts strings." );
      return false;
    }
  }
};
const validatedObject = new Proxy({}, validationHandler );

validatedObject.newProperty = true;
// 结果:This object only accepts strings.

console.log( validatedObject );
// 结果(展开):校验失败时的错误信息与 Proxy 结构

……或者设置并维护对象的内部状态——例如对象某属性被访问的次数:


const handlerObject = {
  accessCounter( target, accessed) {
    Reflect.set( target, "timesAccessed", accessed ? accessed + 1 : 1 );
  },
  set( target, key, value) {
    this.accessCounter( target, Reflect.get( target, "timesAccessed" ) );

    return Reflect.set( target, key, value );
  },
  get( target, key) {
    this.accessCounter( target, Reflect.get( target, "timesAccessed" ) );

    return Reflect.get( target, key );
  }
};

const stateObject = new Proxy({}, handlerObject );

console.log( stateObject );

// 结果(展开):含 timesAccessed 计数器的 Proxy 对象

stateObject.newProperty = true;
// 结果:true

console.log( stateObject );
// 结果(展开):含 timesAccessed 计数器的 Proxy 对象

console.log( stateObject.newProperty );
// 结果:true

console.log( stateObject );
// 结果(展开):含 timesAccessed 计数器的 Proxy 对象

只作用在单个对象上时,这些有点像新奇玩意儿——当然;但结合你已经学过的方法与语法,再发挥一点想象力,就不难看出代理对象如何再多几行代码就能搭出一套完整的状态系统,而无需笨重框架或第三方工具:


function reactiveState( target) {
  const subscribed = new Map();

  return new Proxy({
    ...target,
    subscribe( key, callbackFunc) {
        // 若尚未存在,则加入 `subscribed`:
        if( !subscribed.has( key ) ) {
          subscribed.set( key, [] );
        }
        // 将回调函数关联到订阅对象:
        subscribed.get( key ).push( callbackFunc );
      }
    }, {
      set( target, key, value, receiver) {
        const result = Reflect.set( target, key, value );

        // 若这是被订阅的属性……
        if( subscribed.has( key ) ) {
          // ……以原始对象的显式 `this` 值调用回调函数:
          subscribed.get( key ).forEach( callbackFunc => callbackFunc.call( receiver, key ) );
        }

        return result;
      }
    });
}

// 声明对象状态变化时调用的回调函数:
const callbackLogger = function( key) {
  const enCardinal = new Intl.PluralRules( "en-US" );
  const counter = this[ key ];
  const pluralize = count => enCardinal.select( counter ) === "one" ? `` : `s`;

  console.info(`${this.component }.${ key } has been changed ${ counter } time${ pluralize( counter ) }.`);
};

const widget = reactiveState({ component: "widget", count: 0 });
const gizmo = reactiveState({ component: "gizmo", otherCounter: 0 });

// 当 widget 对象的 `count` 属性变化时,调用 callbackLogger:
widget.subscribe( 'count', callbackLogger );

// 当 gizmo 对象的 `otherCounter` 属性变化时,调用 callbackLogger:
gizmo.subscribe( 'otherCounter', callbackLogger );

widget.count++;
// 结果:widget.count has been changed 1 time.

widget.count++;
// 结果:widget.count has been changed 2 times.

// 我们未订阅 gizmo 对象的 `count` 属性,因此此处无输出:
gizmo.count++;

// 但我们订阅了 gizmo 对象的 `otherCounter` 属性:
gizmo.otherCounter++;
// 结果:gizmo.otherCounter has been changed 1 time.

widget.count++;
// 结果:widget.count has been changed 3 times.

现在,听着。我不想再搬什么陈词滥调,但我也不会否认,收尾时有一种冲动想呼吁节制——告诫你代理对象「有多强大就有多危险」、「能力越大责任越大」诸如此类,也许再聊几句至少能在 Reflect 里找到一点冷安慰。这种冲动并非空穴来风——在最根本的层面改变一门语言的基本构件如何工作,确实能把东西搞得很糟。这一点毫无疑问。

但你了解我:若我想把皮质醇花在「担心搞错」上,我早就去读医学院或学 PHP 了。要我说,ProxyReflect 是 JavaScript 持久精神的崭新、闪亮例证:用语言去改变语言,用新方式解决自 1995 年以来数百位参与者接过那颗球以来都未曾设想的问题。

嗯,「崭新」是相对于 2015 年而言的。

Reflect 对象给你一些至关重要的、预防未来头痛的护栏,当然应该用——它们就是为这个存在的。

但我不会叫你们「小心」。进去折腾、把东西弄坏;没有更好的学法。再说,刷新页面啥修不好,对吧?

喜欢这篇文章? 你可以通过 Open Collective 给我们小费 支持我们