非原始值的响应式方案(一、对象、Proxy与Reflect)
从本章开始,我们正式的进入到 Vue3 各个响应式数据的具体实现方案。首先,先梳理一下 Proxy 与 Reflect 这两个 ES6 新特性的基本用法,以及明确 JavaScript 对象的内部方法与 Proxy 的对应关系。
在之前的文章中,我们从响应式的原理开始,一步步的使用 JavaScript 手写了一个简单版本的响应系统,并基于它实现了 computed
与 watch
这两个相对比较重要的 API。
但是,在之前我们仅仅是使用 obj
这个对象来进行响应式数据的模拟,在实际的应用开发过程中,我们要进行响应式代理的数据绝不仅仅是对象而已,肯定有不可避免的 Array
、 Map
、 WeakMap
、 WeakSet
等等的数据结构,而一个完善的响应系统肯定是必须得对它们都进行完整的代理。当然,对于这些 JS 内置的数据结构,要实现其响应式拦截绝不仅仅是 get
、 set
那么简单了。我们必须要查询 JS 的官方语言规范,看看它们的一些 API 底层到底是什么实现的。所以这也是一个深入理解 JS 数据结构的一次很好的机会(不是)!
那么我们就来开始读书吧!我要记笔记了!
Proxy与Reflect
什么是Proxy
Proxy 是 ES6 推出的一个新的官方特性,它能够创建一个 代理对象 以实现对 其他对象 的代理。也就是说 Proxy 能够实现对象的代理,而对于非对象值则无法进行代理,比如 string
、 number
、 boolean
等这些基本值。
那么代理本身是什么意思呢?我们平时科学上网时所说的网络 代理,实际上是把网络流量接管后到传输层统一转发到端口的过程,相当于是 对网络流量代为管理 的行为。而这里的代理的含义也是差不多,能后让用户 自己编写关于对象的一些基本操作并对其加以拦截。换句话说,我想要让某个对象的一些操作按我的想法走,不按你的默认操作来。而这里所谓的 一些操作,实际上就是一个对象的 基本语义。
在 JavaScript 中,基本语义指的是对一个对象的一些基本操作,比如 读取属性值 、 设置属性值 等。只要是基本操作就都可以被 Proxy 给拦截到。
在最新的 ECMAScript 标准中,Proxy 能够拦截的基本语义有 13 种:
拦截操作 | 描述 | 示例 |
---|---|---|
get | 拦截对对象属性的读取。返回值将是对象的属性值。 | proxy.name 读取 target.name 的值 |
set | 拦截对对象属性的设置。返回 true 表示成功,false 表示失败。 | proxy.name = 'Alice' 设置 target.name 为 'Alice' |
has | 拦截 in 操作符的使用,用于判断对象是否具有某个属性。返回布尔值。 | 'name' in proxy 判断 proxy 是否有 name 属性 |
deleteProperty | 拦截 delete 操作符,用于删除对象的属性。返回 true 表示成功,false 表示失败。 | delete proxy.name 删除 proxy.name 属性 |
ownKeys | 拦截 Object.getOwnPropertyNames 和 Object.getOwnPropertySymbols ,返回对象的所有自身属性。 | Object.keys(proxy) 返回 proxy 的所有键 |
getOwnPropertyDescriptor | 拦截 Object.getOwnPropertyDescriptor ,获取某个属性的描述符。 | Object.getOwnPropertyDescriptor(proxy, 'name') 获取 proxy.name 的描述符 |
defineProperty | 拦截 Object.defineProperty ,用于设置对象属性的属性描述符。 | Object.defineProperty(proxy, 'name', descriptor) 定义 proxy.name |
apply | 拦截函数调用。用于拦截 proxy() 或 Reflect.apply() 对函数的调用。 | proxy() 或 Reflect.apply(proxy, this, args) 调用 proxy 作为函数 |
construct | 拦截 new 操作符,用于拦截对象的实例化过程。返回构造函数的返回值。 | new proxy() 拦截构造函数调用 |
getPrototypeOf | 拦截 Object.getPrototypeOf ,用于获取对象的原型。 | Object.getPrototypeOf(proxy) 获取 proxy 的原型 |
setPrototypeOf | 拦截 Object.setPrototypeOf ,用于设置对象的原型。 | Object.setPrototypeOf(proxy, newPrototype) 设置 proxy 的原型 |
isExtensible | 拦截 Object.isExtensible ,用于检查对象是否可扩展。 | Object.isExtensible(proxy) 检查 proxy 是否可扩展 |
preventExtensions | 拦截 Object.preventExtensions ,用于禁止对象扩展。 |
基于以上基本语义,Proxy 的使用也非常简单。它本身是一个 class,需要通过 new 来调用其构造函数,并往里面传两个参数。第一个参数是被代理的对象,第二个参数是包含了上述 13 种基本语义的拦截 trap 函数。比如你如果想拦截 get 操作,你可以直接往里面传 get(){}
,然后自定义你想写的逻辑。
当然,除了传统的对象字面量以外,函数在 JS 中也是一个对象,自然也有提供专门对于它的一些基本语义。比如,我们可以使用 apply
对其的调用进行拦截:
不过在我们的日常开发中,很多时候操作一个对象 & 函数时,使用的都是 非基本操作。一个比较典型的非基本操作就是 调用一个对象下的方法,这也可以被称之为 复合操作:
这个操作又两个基本语义复合而成:
- get。先通过 get 拿到
obj.fn
属性。 - apply。调用这个函数。
在很多的 JavaScript 内置对象中的很多操作,都是属于复合操作。不过复合操作都能够被拆成一个个基本语义的复合 ,这也使得我们能够就不同的数据结构设计出不同的代理解决方案。
什么是Reflect
首先我们需要清楚,Reflect 和 Proxy 是 配套 的一对机制。配套是什么意思呢?实际上,Reflect 是 JavaScript 中的一个全局对象,其支持的方法刚好就是上面列出的那十三个基本语义。
Reflect 是一个内置的全局对象,提供了一些与对象相关的操作。它的作用其实就是提供给我们一种统一的形式去执行某个对象的基本语义。 你可以看成是 Reflect 驱动了一次某个对象的基本语义。
比如对于 get 操作而言,这样子写是等价的:
不过既然操作等价,那它为什么又需要存在呢?事实上,Reflect 本身 还允许接受第三个参数,即指定 receiver,就类似于我们函数调用过程中使用 bind
或者 apply
来指定 this 一样。
在这里,第三个参数我们自己手动传入了一个有 name 属性的对象,结果输出的就是新传入的对象属性值。而正是这一点,与 Vue 响应式数据的底层实现密切相关。
在前几篇文章中写的响应式 demo 中,在 Proxy 的 get 与 set 拦截函数里面,都是 直接使用原始对象 target 来完成对属性的读取与设置操作的。这就导致了,如果我们在原始对象本身如果设置了 getter 或者 setter,那么响应式系统就无法按照我们的预期正常工作了。
我们可以试着写一下这样的代码:
在这里,我们使用 ref 对原始数据进行 Proxy 包装后,obj 变成了响应式数据,然后用 effect 来注册副作用函数。
这个副作用函数,读取了 obj.username
属性,而这个属性是一个访问器属性,于是执行 getter 函数的逻辑,返回 this.name
,这个操作又读取了 name
这个属性值,因此我们觉得副作用函数应该也要与 name
建立联系。不过现在的响应式系统还做不到这一点,修改 obj.name
的值无法导致副作用函数的重新触发。 实际上,问题其实就出在原始数据的 getter 函数里面的 this 指向。
我们看一下 ref 函数的完整代码:
可以发现我们直接使用的是 target[key]
来完成属性值的返回,而 target 是 原始对象 data。所以,实际上返回的应该是 data.username
,然后就开始执行原始对象 data 内部的 getter 函数,这个时候的 this 肯定指的就是原始对象,最终访问的也就是 data.name
。而在副作用函数里面,原始数据属性的访问无法触发我们 Proxy 的 get 依赖收集,当然无法建立响应联系。
为了能够正确地建立响应联系,我们必须得把 return 出去的对象改为我们包装好的响应式对象也就是 obj。怎么做到这一点呢?当然就是利用 Reflect 的第三个参数 receiver。
我们看一下是怎么解决的这个问题,非常简单,改一行代码就可以了:
让我们用直观点的方式去理解。我们刚刚提到了,Proxy 和 Reflect 实际上是 配套的,这个配套性不仅体现在对于 基本语义 的处理上面,也体现在方法调用的参数上面。
刚刚我们使用 Reflect 指定了第三个参数 receiver,实际上 Proxy 的每个 trap 也都有第三个参数(不过我们刚刚没用)receiver。这里的 receiver 指的就是当前这个被 Proxy 包装好的对象,也就是响应式对象。
所以,我们可以来梳理一下了。在这里我们传给 Reflect 的 receiver 是这个响应式对象本身,然后我们访问了这个响应式对象的 username
属性,然后触发了响应式对象的 getter 函数,最终读取的 this 也就是响应式对象 obj 。这样就完美解决依赖收集不到的问题了!
JavaScript对象与Proxy的工作原理
JS对象的定义
根据 ECMAScript 规范,我们知道在 JavaScript 中存在两种对象类型:
- 常规对象(ordinary object);
- 异质对象(exotic object)。
任何不属于常规对象的对象都是异质对象。 那么最后的问题就很明确了:什么是常规对象呢?
我们应该或多或少,在使用 JS 对象的时候都用听说过其 内部方法(internal method),也就是用 [[]]
括起来的、不能直接进行调用的对象方法。这些方法是我们在对一个对象进行操作时,JavaScript 引擎在内部为我们调用的方法。比如我们最最最熟悉的 [[Get]]
,就是内部方法其中之一。
一个常规对象,必须具备 ECMAScript 规范要求的所有必要的内部方法。 这些内部方法本质是一个个函数,并且各自有他们的函数签名。一个对象必须部署 11 个必要的内部方法,而对于函数,需要在 11 个方法之外新增两个内部方法:[[Call]]
和 [[Construct]]
,同时也可以通过判断一个对象上是否具有这两个方法来判断是否是一个函数。
我们可以浏览一下这 13 个内部方法的具体信息。先看一下必备的 11 个方法:
内部方法 | 函数签名 | 描述 |
---|---|---|
[[Get]] | (P: PropertyKey) => Object | 用于获取对象属性 P 的值。 |
[[Set]] | (P: PropertyKey, V: Object) => boolean | 用于设置对象属性 P 的值为 V 。 |
[[HasProperty]] | (P: PropertyKey) => boolean | 判断对象是否具有属性 P 。 |
[[Delete]] | (P: PropertyKey) => boolean | 删除对象的属性 P 。 |
[[DefineOwnProperty]] | (P: PropertyKey, Desc: PropertyDescriptor) => boolean | 定义对象属性 P ,并设置其描述符 Desc 。 |
[[GetOwnPropertyDescriptor]] | (P: PropertyKey) => PropertyDescriptor | 获取对象属性 P 的描述符。 |
[[OwnPropertyKeys]] | () => Array<PropertyKey> | 返回对象的所有自身属性的名称列表。 |
[[PreventExtensions]] | () => boolean | 防止对象添加新的属性。 |
[[IsExtensible]] | () => boolean | 判断对象是否可扩展(是否可以添加新属性)。 |
[[GetPrototypeOf]] | () => Object | null | 查明为该对象提供继承属性的对象,null 则代表没有继承属性。 |
[[SetPrototypeOf]] | (V: Object | null) => boolean | 将该对象与提供继承属性的另一个对象相关联。传递 null 表示没有继承属性,返回 true 表示操作完成,返回 false 表示操作失败。 |
下面两个是专门给函数用的:
内部方法 | 函数签名 | 描述 |
---|---|---|
[[Call]] | (thisArg: Object, ...args: any[]) => any | 当函数被调用时,执行该函数,并传入 thisArg 作为 this 和其余的参数 args 。 |
[[Construct]] | (args: any[]) => Object | 当函数作为构造函数使用时(通过 new 关键字),创建一个新的对象并执行函数。 |
并且这些内部方法同时也具有多态性,不同类型的对象部署了相同的内部方法,但是其具体执行的逻辑却是不一样的。
基于以上的理解,我们可以给出 JavaScript 中常规对象的定义:
- 对于 11 个对象必须实现的内部方法,必须使用 ECMA 规范 10.1.x 节给出的定义来实现;
- 对于
[[Call]]
内部方法,必须使用 ECMA 规范 10.2.1 节给出的定义实现; - 对于
[[Construct]]
内部方法,必须使用 ECMA 规范 10.2.2 节给出的定义实现。
所有不符合三点要求的都是异质对象。比如 Proxy,它的内部方法 [[Get]]
就不符合规范,所以它是一个异质对象。
Proxy工作原理
比如,我们将一个对象用 Proxy 进行代理之后什么都不做:
这个时候,我们用 obj.name
去读取这个代理对象的属性值,由于没有指定具体的 get trap,所以实际上代理对象会直接执行原始对象的 [[Get]]
方法来讲值给返回。这就是 代理透明性质,同时也是对象内部方法多态性的体现。
而换言之,如果我们直接传了 get trap,代理对象就会直接执行我们传入的 trap 函数 。所以实际上,我们创建 Proxy 时指定的 trap 就是来自定义代理对象本身的内部方法的行为,相当于是进行重载。而原始对象则还是那个原始对象。相当于 Proxy 给你返回了一个内部方法全空的对象,你得自定义它的内部方法。如果不指定,执行的就是原始对象的内部方法。
有了上面两个表,我们可以把 内部方法 以及 Proxy 对应的 trap 函数 来做一个对照表:
内部方法 | Proxy 对应的 trap 函数 |
---|---|
[[Get]] | get |
[[Set]] | set |
[[HasProperty]] | has |
[[Delete]] | deleteProperty |
[[DefineOwnProperty]] | defineProperty |
[[GetOwnPropertyDescriptor]] | getOwnPropertyDescriptor |
[[OwnPropertyKeys]] | ownKeys |
[[PreventExtensions]] | preventExtensions |
[[IsExtensible]] | isExtensible |
[[GetPrototypeOf]] | getPrototypeOf |
[[SetPrototypeOf]] | setPrototypeOf |
[[Call]] | apply |
[[Construct]] | construct |
至此,我们对 JavaScript 对象、Proxy 的工作原理以及 Reflect 的含义,都有了一些初步的理解。接下来,就一边着手实现响应式数据,一遍加深印象吧。