03_02_理解 Proxy 和 Reflect


theme: orange

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情

03_02_理解 Proxy 和 Reflect

一、开始之前:

为什么还会有这一篇文章呢?不是手写mini-vue吗?
其实可以理解成支线任务、番外篇,是对主线内容的补充。

这一篇文章可能文字比较多,理论知识比较多,参考了4本书相关的章节写的。
可以泡杯咖啡或者喝杯茶,坐下来慢慢看哦。☕️

二、为什么使用Proxy?

众所周知,vue3的响应式是靠Proxy代理对象实现的。

代理是使用Proxy构造函数创建的。
这个构造函数接收两个参数:目标对象target和处理程序对象handler
缺少其中任何一个参数都会抛出TypeError

使用代理的主要目的是可以定义捕获器(trap)。
捕获器就是在处理程序对象中定义的“基本操作的拦截器”。

每个处理程序对象可以包含零个或多个捕获器,每个捕获器都对应一种基本操作,可以直接或间接在代理对象上调用。例如:get和set都知道就不说了,apply可以用来捕获函数的调用操作。

每次在代理对象上调用这些基本操作时,代理可以在这些操作传播到目标对象之前先调用捕获器函数,从而拦截并修改相应的行为。大致可以理解为代理对象目标对象前设置了一层“拦截”层。

那这样,对于对象属性的读取和设置,我们就可以感知到,只有在这个基础之上,我们才能去实现响应式。

既然我们知道了为什么用Proxy,那接下来就来看看Proxy到底是什么?

三、Proxy是什么?

《JavaScript高级程序设计(第4版)》

ECMAScript 6
新增的代理和反射为开发者提供了拦截并向基本操作嵌入额外行为的能力。具体地说,可以给目标对象定义一个关联的代理对象,而这个代理对象可以作为抽象的目标对象来使用。在对目标对象的各种操作影响目标对象之前,可以在代理对象中对这些操作加以控制。

从很多方面看,代理类似 C++指针,因为它可以用作目标对象的替身,但又完全独立于目标对象。目标对象既可以直接被操作,也可以通过代理来操作。
但直接操作会绕过代理施予的行为。

《ES6标准入门》

Proxy 用于修改某些操作的默认行为,等同于在语言层面做出修改,所以属于 种“元编程”( meta programming ),即对编程语言进行编程。

Proxy 可以理解成在目标对象前架设 个“拦截”层 ,外界对该对象的访问都必须先通过 这层拦截,因此提供了一种机制可以对外界的访问进行过滤和改写。

Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。

《深入理解ES6》

通过调用 new Proxy() ,你可以创建一个代理用来替代另一个对象(被称为目标),这个代理对目标对象进行了虚拟,因此该代理与该目标对象表面上可以被当作同一个对象来对待。

代理允许你拦截在目标对象上的底层操作,而这原本是 JS 引擎的内部能力。拦截行为使用了一个能够响应特定操作的函数(被称为陷阱)。

(一)概述

从以上书籍中的描述,我们可以大概总结一下:
使用 Proxy 可以创建一个代理对象,它能够实现对 其他对象 的代理。

这里的关键词有两个:

  1. “创建” : 意为代理对象这是一个新对象。
  2. “其他对象” : 只能代理对象,无法代理非对象值,例如:数字、字符串、布尔类型。

那么,代理指的是什么呢?

所谓代理,指的是对一个对象基本语义的代理。它允许我们拦截并重新定义对一个对象的基本操作。
这句话的关键词比较多,我们逐一解释。

(二)基本操作

前文也提到了基本操作,这里又说到了基本语义,那么什么样的才是基本的呢?

const obj = { foo: 1 };

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

给出一个对象,我们可以读取某个属性的值,同样也可以设置某个属性的值。

类似这种读取、设置属性值的操作,就属于基本语义的操作,即基本操作。当然,勿6!
可以理解成单步最简动作,而不是复合动作

既然是基本操作,那么它就可以使用Proxy拦截:

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

JavaScript中,万物皆对象

那么函数自然也不例外,例如一个函数也是一个对象,所以调用函数也是对一个对象的基本操作

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

// 调用函数是对对象的基本操作
fn()

因此,我们可以用 Proxy 来拦截函数的调用操作,这里我们使用 apply 拦截函数的调用:

const p2 = new Proxy(fn, {
  // 使用 apply 拦截函数调用
  apply(target, thisArg, ...argumentsList) {
    return Reflect.apply(...arguments);
  }
})

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

(三)复合操作

既然有基本操作,那对应的就有复合操作

调用一个对象下的方法就是典型的复合操作

objj.fn();

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

第一个基本操作是 get,即先通过 get 操作得到 obj.fn 属性。
第二个基本操作是 函数调用,即通过 get 得到 obj.fn 的值后再调用它,也就是我们上面说到的 Reflect.apply

四、Reflect又是什么?

Reflect又叫反射,设计的目的主要有以下几个:

(1)将Object对象的一些明显属于语言内部的方法(比如Object.defineProperty),放到Reflect对象上。现阶段,某些方法同时在ObjectReflect对象上部署,未来的新方法将只部署在Reflect对象上。也就是说,从Reflect对象上可以拿到语言内部的方法。

(2)修改某些Object方法的返回结果,让其变得更合理。比如,Object.defineProperty(obj, name, desc) 在无法定义属性时,会抛出一个错误,而Reflect.defineProperty(obj, name, desc)则会返回false

// 老写法
try {
  Object.defineProperty(target, property, attributes);
  // success
} catch (e) {
  // failure
}

// 新写法
if (Reflect.defineProperty(target, property, attributes)) {
  // success
} else {
  // failure
}

(3)让Object操作都变成函数行为。某些Object操作是命令式,比如name in objdelete obj[name],而Reflect.has(obj, name)Reflect.deleteProperty(obj, name)让它们变成了函数行为,。

(4)其实可能你已经注意到了,Reflect对象的方法与Proxy对象的方法一一对应,只要是Proxy对象的方法,就能在Reflect对象上找到对应的方法。这就让Proxy对象可以方便地调用对应的Reflect方法,完成默认行为,作为修改行为的基础。Proxy可以捕获13种不同的基本操作,这些操作有各自不同的Reflect API方法。

这里稍微列举一下:

  • Reflect.get() → 读取属性
  • Reflect.set() → 设置属性
  • Reflect.has() → 属性是否存在,等同于in
  • Reflect.defineProperty() → 定义属性
  • Reflect.getOwnPropertyDescriptor() → 获取指定属性的描述对象
  • Reflect.deleteProperty() → 删除属性,等同于delete
  • Reflect.ownKeys()() → 返回自身属性的枚举
  • Reflect.getPrototypeOf() → 用于读取对象的__proto__属性
  • Reflect.setPrototypeOf() → 设置目标对象的原型(prototype)
  • Reflect.isExtensible() → 表示当前对象是否可扩展
  • Reflect.preventExtensions() → 将一个对象变为不可扩展
  • Reflect.apply() → 调用函数,等同于等同于 Function.prototype.apply.call(),但借用原型方法可读性太差
  • Reflect.construct() → 等同于new

到现在,可能有人要说了,你说了这么一大堆七七八八的,看也没怎么看明白。
上篇文章的坑不还是没填,到现在还是不清楚为什么要用Reflect.getReflect.set

五、vue3中为什么使用Reflect?

不要着急,有了上篇文章的响应式基础和这些前置知识,我们就能知道为什么要使用Reflect了。

其实一句话就能总结:Reflect.get还有第三个参数,即指定接收者receiver,你可以把它理解为函数调用过程中的 this

const obj = { foo: 1 };

const result = Reflect.get(obj, 'foo', { foo: 2 });

console.log(result); // 输出的是 2 而不是 1

我们看一下reactive的实现里面不用Reflect的情况:

const obj = { foo: 1 }

const p = new Proxy(obj, { 
  get(target, key) {
    track(target, key);
    return target[key];
  },
  set(target, key, newVal) {
    target[key] = newVal;
    trigger(target, key);
  }
})

乍一看,似乎没什么问题。
确实,大多数情况下,两者没什么区别。
那么到底什么时候会出问题呢?接着往下看。

首先,我们修改一下obj对象,为它添加bar属性:

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

可以看到:bar属性是一个访问器属性,它返回了this.foo属性的值。
接着,我们在effect中通过代理对象p访问bar属性。

effect(() => {
  console.log(p.bar); // 1
})

首先effect首次执行收集依赖的时候,会读取p.bar属性,它发现p.bar是一个访问器属性,因此执行getter函数。由于在getter函数中通过this.foo读取了foo属性值,因此我们认为effect中的依赖会被作为与foo属性的依赖收集起来。

转而当我们修改p.foo的值时,依赖应该被重新触发,p.bar应该是2才对。
然而实际并非如此,当我们尝试修改p.foo的值时:

p.foo = 2;

依赖并没有重新执行,奇了怪了?
实际上,问题就出在bar属性的访问器函数getter里,也就是代理中的this指向问题

const obj = {
  foo: 1,
  get bar() {
    // 这里的this指向哪里
    return this.foo;
  }
}

当我们使用obj读取bar属性值时,这里的this指向哪里呢?
那当我们用代理对象p访问bar,这时候this又指向的哪里呢?

很显然,方法中的this通常指向调用这个方法的对象。

那接着,我们来回顾一下整个流程。
首先,我们通过代理对象p访问p.bar,这会触发代理对象的get拦截函数执行:

const p = new Proxy(obj, {
  get(target, key) {
    track(target, key)
    // 注意,这里我们没有使用 Reflect.get 完成读取
    return target[key];
   },
   // 省略部分代码
})

get拦截函数内,通过target[key]返回属性值。
其中target是原始对象obj,而key就是字符串'bar',所以target[key]相当于obj.bar
因此,当我们使用p.bar访问bar属性时,它的getter函数内的this指向的其实是原始对象obj,这说明我们最终访问的其实是obj.foo

很显然,这里没有响应式对象,所以自然依赖也不会进行收集。
因为在副作用函数内通过原始对象访问它的某个属性,这等价于:

effect(() => {
  // obj 是原始数据,不是代理过的对象,这样的访问不能够建立响应联系
  // 这里也就是上文中开头引用中提到的:直接操作会绕过代理施予的行为。
  obj.foo;
})

因为这样做不会收集依赖,所以无法触发响应的问题也就明了了。

那么这个问题应该如何解决呢?这时Reflect.get的第三个参数receiver就派上用场了。

const p = new Proxy(obj, {
  // 拦截读取操作,接收第三个参数 receiver
  get(target, key, receiver) {
    track(target, key)
    // 使用 Reflect.get 返回读取到的属性值
    return Reflect.get(target, key, receiver)
  },
  // 省略部分代码
})

如上面的代码所示,代理对象的get拦截函数接收第三个参数receiver,它代表谁在读取属性,例如:

p.bar // 代理对象 p 在读取 bar 属性

当我们使用代理对象p访问bar属性时,那么receiver就是p,你可以把它简单地理解为函数调用中的this
那么此时,访问器属性bargetter函数内的this的指向就是代理对象p

const obj = {
  foo: 1,
  get bar() {
    // 现在这里的 this 为代理对象 p
    return this.foo;
  }
}

this由原始对象obj变成了代理对象p。那么,依赖此时就能正常进行收集。
如果此时再对p.foo进行set操作,会发现已经能够触发依赖重新执行了。

正是基于上述原因,vue3的响应式系统采用了Reflect.*方法,而我们的mini-vue也同样如此。

ps

这是我组织的一个 早起俱乐部

⭐️ 适合人群:所有想有所改变的人,可以先从早起半小时开始!抽出30分钟,从初心开始!!

⭐️ 没有任何吸粉、宣传、收费等等其它意味,只是本人想寻找一起早起、志同道合的小伙伴。
⭐️ 有意向的小伙伴,可以看我首页用户名VX同名,加我拉你进群哦!

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容