【温故而知新】你可能不知道的 Proxy

avatar
公众号「 微医大前端技术 」

作者:张宇航,微医前端技术部,一个不文艺的处女座程序员。

写在最前面

我们都知道Vue2的响应式系统是利用Object.defineProperty进行数据劫持实现的,但是其本身语法有如以下几个缺陷:

  • 对普通对象的监听需要遍历每一个属性
  • 无法监听数组的变动
  • 无法监听Map/Set数据结构的变动
  • 无法对对象新增/删除的属性进行监听

针对此,Vue3使用了Proxy实现的数据响应式,并将其独立成@vue/reactivity 模块。因此,要了解学习 Vue3的响应式系统,对Proxy的掌握尤为重要。阅读完本文,我们可以学习到:

  • Proxy对象的基本用法
  • Proxy 能实现对对象的代理的工作原理

Proxy简介

首先,我们来看下Proxy在MDN上的定义:

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

其基本语法如下:

const p = new Proxy(target, handler);

参数说明:
target: 即我们要代理的对象。我们都知道在JS里“万物皆对象”,因此这个target 可以是任何类型的对象,包括原生数组,函数,甚至另一个Proxy对象。同时,请注意到定义里的关键词“用于创建一个对象的代理”,因此Proxy只能代理对象,任何原始值类型都是无法代理的 。如对number, boolean类型的原始值代理都会得到 “Cannot create proxy with a non-object as target or handler”的错误:

image.png

**handler:其是一个属性全部为函数类型的对象。**这些函数类型的属性 ,也 称之为捕获器(trap),其作用就是为了实现定义里说的“基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)”,注意,这里的拦截其实是对代理对象p的基本操作拦截,而并不是对被代理的对象target的拦截(至于为什么,会在接下来的工作原理章节 进行解释)。handler对象总共有以下截图共计13个属性方法(trap):

image.png

基本用法如下 :

const obj = {
  foo: 'bar',
  fn () {
    console.log('fn调用了');
  }
};
const handler = {
  get (target, key) {
    console.log(`我被读取了${key}属性`);
    return target[key];
  },
  set (target, key, val) {
    console.log(`我被设置了${key}属性, val: ${val}`);
    target[key] = val;
  },
  apply (target, thisArg, argumentsList) {
    console.log('fn调用被拦截');
    return target.call(thisArg, ...argumentsList);
  }
};
const p = new Proxy(obj, handler);
p.foo; // 输出:我被读取了foo属性
p.foo = 'bar1'; // 输出:我被设置了foo属性, val: bar1
p.fn(); // 输出:我被读取了fn属性 fn调用了

在上述 代码中,我们只是实现了13个方法其中的get/set/apply,这3个trap的含义分别是:属性读取操作的捕捉器、属性设置操作的捕捉器、函数调用操作的捕捉器。关于其他10个方法(捕捉器 )的含义 在这里就不一一赘述了,感兴趣的同学可以去MDN了解。
值得注意的是,在上述代码中,并没有拦截到obj.fn()函数调用操作,而却是只是输出了“我被读取了fn属性”。究其原因,我们可以再次从Proxy的定义里的关键词“基本操作”找到答案 。那么何为基本操作呢?在上述代码中就表明了对象属性的读取(p.foo) 、设置(p.foo='xxx')就是基本操作,与之对应的就是非基本操作,我们可以称之为复合操作。而obj.fn()就是一个典型的复合操作,它是由两个基本操作组成的分别是读取操作(obj.fn), 和函数调用操作(取到obj.fn的值再进行调用),而我们代理的对象是obj,并不是obj.fn。因此,我们只能拦截到fn属性的读取操作。这也说明了Proxy只能对对象的基本操作进行代理,这点尤为重要。
下面的代码表明函数的调用也是基本操作,是可以被apply拦截到的:

const handler = {
  apply (target, thisArg, argumentsList) {
    console.log('函数调用被拦截');
    return target.call(thisArg, ...argumentsList);
  }
};
new Proxy(() => {}, handler)();  // 输出:函数调用被拦截

Reflex和 Proxy

首先还是要来看下Reflex在MDN里的定义:

Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与proxy handlers 的方法相同.

不难发现,Reflex对象的方法和proxy的拦截器(第二个入参handler)的方法完全一致,同样有着13个方法:
image.png
那么,Reflect对象的作用是 什么呢,拿Reflect.get举例简单来说其作用之一就是提供了访问一个对象属性的默认行为,如以下代码:

const obj = {foo: 'foo'};
obj.foo; 
// 等同于
Reflect.get(obj, 'foo');

既然 作用一致 ,那么使用Reflect.get有何意义呢,在回答这个问题之前,我们先看下以下代码:

const obj = {
  foo: 'foo',
  get bar () {
    return this.foo;
  }
};
const handler = {
  get (target, key, receiver) {
    console.log(`我被读取了${key}属性`);
    return target[key];
  },
  set (target, key, val, receiver) {
    console.log(`我被设置了${key}属性, val: ${val}`);
    target[key] = val;
  }
};
const p = new Proxy(obj, handler);
p.bar; // 输出:我被读取了bar属性
// Q: 为什么读取foo属性没有被拦截

在上述代码中我们定义了一个foo属性和bar属性,其中bar属性是一个访问器属性,通过get函数 return this.foo获取得到 的,因此按理来说我们在读取bar属性时候会触发读取foo属性,也同样会被get的trap所拦截到,但实际代码运行结果并没有拦截到foo属性。这是为什么呢,答案的关键在于bar访问器里的this指向。梳理下代码运行过程:p.bar 实际上会被handler的get捕获 返回 target['bar'],而这里的target实际上就是obj,所以这时候bar访问器里的this指向obj,this.foo,实际就是obj.foo。而obj并不是proxy对象p,所以访问其foo属性并不会被拦截到。
那么如何也能触发到foo属性的拦截呢,这时候Reflect就派上用场了,有以下代码:

const obj = {
  foo: 'foo',
  get bar () {
    return this.foo;
  }
};
const handler = {
  get (target, key, receiver) {
    console.log(`我被读取了${key}属性`);
    return Reflect.get(target, key, receiver);
  },
  set (target, key, val, receiver) {
    console.log(`我被设置了${key}属性, val: ${val}`);
    return Reflect.set(target, key, val, receiver);
  }
};
const p = new Proxy(obj, handler)
p.bar; // 输出:我被读取了bar属性   我被读取了foo属性

如上面代码所示,我们能正确地触发了foo属性的拦截,其实现的关键在于Reflect.get的第三个参数receiver ,其作用就是改变this指向,在MDN里有以下描述:

如果target对象中指定了getter,receiver则为getter调用时的this值。

而我们这里的receiver就是p对象,this.foo 等同于 p.foo,因此访问bar属性的 时候同样可以拦截得到。也正是因为this指向的问题,所以建议在proxy对象拦截器里的属性方法都通过Reflex.*去操作。

Proxy的工作原理

内部方法和内部槽

在Proxy简介章节里我们曾提到:“Proxy只能代理对象”。那么不知道你有没有想过这样的一个问题,在JS里对象的定义又是什么?关于这个问题的答案,我们需要从ECMAScript规范里找到答案 :
在ecma262规范6.1.7.2章节开头给出这样的定义:

The actual semantics of objects, in ECMAScript, are specified via algorithms called internal methods. Each object in an ECMAScript engine is associated with a set of internal methods that defines its runtime behaviour. These internal methods are not part of the ECMAScript language. They are defined by this specification purely for expository purposes. However, each object within an implementation of ECMAScript must behave as specified by the internal methods associated with it. The exact manner in which this is accomplished is determined by the implementation.

也就是说:对象的实际语义是通过称为内部方法(internal methods)的算法指定的。
那么 ,什么又是内部方法呢。阅读完本章节,我们不难发现,其实对象 不仅有内部 方法(internal methods)还有内部槽(Internal Slots),在ECMAScript规范里使用[[ xxx ]]来表示内部方法或者内部槽:

Internal methods and internal slots are identified within this specification using names enclosed in double square brackets [[ ]].

内部方法对JavaScript开发者来说是不可见的,但当我们 对一个对象进行操作时,JS引擎则会 调用其内部方法。举个例子来说:当我们访问一个对象的属性时:

const obj = { foo: 'foo'};
obj.foo;

引擎内部则会调用obj内部方法[[ Get ]] 来获取foo属性值
image.png
以下是 作为一个对象,其必要的11个基本内部方法,也就是说凡是对象,其必然部署了以下11个内部方法:
image.png
当然,不同的对象,可能部署了不同的内部方法。比如说函数也是对象,那如何区分函数和普通对象呢,或者说对象怎么能像函数一样被调用呢,答案是只要部署了[[ Call ]]这个内部方法,那么这个对象就是函数对象,同时如果这个函数对象也部署了[[ Construct ]]内部方法,那么这个函数对象也是构造函数对象也就意味着其可以使用new操作符:
image.png
同时内部方法又是具有多态性的,也就是说不同的对象在对相同的内部方法的实现可能有所差异:

Internal method names are polymorphic. This means that different object values may perform different algorithms when a common internal method name is invoked upon them. That actual object upon which an internal method is invoked is the “target” of the invocation. If, at runtime, the implementation of an algorithm attempts to use an internal method of an object that the object does not support, a TypeError exception is thrown.

举个例子来说:Proxy对象和普通对象其都有内部方法[[ Get ]] , 但是他们的 [[ Get ]]实现 逻辑却是不同的,Proxy对象 的[[ Get ]]实现逻辑是由ecma262规范 10.5.8章节里定义的,而普通对象的[[ Get ]]实现逻辑是由ecma262规范 10.1.8章节里定义的.

普通对象和异质对象

在上 一节我们了解到了对象都有内部方法和内部槽,不同的对象可能有不同的内部方法或者内部槽,而即便 有相同的内部 方法,但是其内部方法的内部实现逻辑可能也有所不同。
实际上,通过阅读ECMAScript规范,我们可以将JS的对象分为两大类:**普通对象(ordinary object)异质对象(exotic object),而区分一个对象是普通对象还是异质对象的标准就是:内部方法或者内部槽的不同。**那么什么是普通对象呢,根据定义满足以下要求即是:
image.png
也就是说,一个普通对象需要满足以下3点:

  1. 其内部方法的定义是符合ECMAScript规范10.1.x章节定义的,如下图所示10个内部方法:

image.png

  1. 如果这个对象有内部方法[[ Call ]] 那么其应该是由ECMAScript规范10.2.1章节定义的
  2. 如果这个对象有内部方法[[ Construct ]] 那么其应该是由ECMAScript规范10.2.2章节定义的

image.png
综上,就是 一个普通对象的定义。而异质对象的定义就较为简单了,只要一个对象不是普通对象,那它就是异质对象

An exotic object is an object that is not an ordinary object.

再聊Proxy

通过上两个小节我们了解到了普通对象和异质对象的定义,当我们再阅读规范时就不难发现其实Proxy对象就是一个异质对象,因为Proxy对象的内部方法是在10.5.x章节进行定义的,并不满足普通对象的定义:
image.png
Proxy是如何实现代理对象的,其实是和它的内部方法实现逻辑息息相关的。还是拿代码举例来说明:

const obj = {
  foo: 'foo',
};
const handler = {
};
const p = new Proxy(obj, {});
p.foo; // 输出:foo

在上述代码中,我们的handler是一个空对象,但是它具体是如何实现代理的,但是Proxy对象p仍能实现对对象obj的代理,具体点来讲p.foo 的值为什么和obj.foo的值等同。
通过上两节学习,我们知道对象属性的读取操作会触发引擎内部对这个对象的内部方法[[ Get ]]的调用,那就让我们看下Proxy的[[ Get ]]内部方法:
image.png
这里我们重点看第5-7步,结合我们的代码,简而言之,当我们读取p.foo时,首选会检查p对象有无get的trap 如果没有,则会调用被代理的对象obj(target)的[[ Get ]]内部方法,如果有则会调用handler的get 方法并将其调用结果 返回。
因此,我们可以得出一个结论:创建代理对象p时指定的拦截器handler,实际上是用来自定义这个代理对象p本身的操作行为,并不是拦截自定义被代理对象obj的操作行为的。这正是体现了代理透明性质,也解释了我们在 Proxy简介里提到的问题:拦截其实是对代理对象p的基本操作拦截,而并不是对被代理的对象target的拦截。

总结

本文主要是介绍Proxy以及配合Reflect的简单使用,再从ECMAScript规范讲起内部方法、内部槽以及普通对象、异质对象的定义,进而了解了Proxy能实现代理的内部实现逻辑。

参考文献

副本_副本_未命名_自定义px_2021-10-20-0.gif