Vue3系列二:如何实现对响应式数据的代理

news2024/11/23 21:55:39

上一篇文章中,我们讲解了 Vue3 中对响应式系统的实现,本章节会更进一步的从数据层面分享 Vue3 中对响应式数据是如何进行代理的,本文主要从引用类型数据基本类型数据两个方面进行讲解。

实现数据代理的基础

理解 Proxy 和 Reflect

首先,我们需要介绍一下实现代理的基础 API。

众所周知,Vue3 中的响应式数据是基于 Proxy 实现的,那什么是 Proxy 呢?

简单地说,使用 Proxy 可以创建一个代理对象。它能够实现对其他对象的代理,这里的关键词是其他对象,也就是说,Proxy 只能代理对象,无法代理非对象值,例如字符串、布尔值等。

那什么是对象的代理呢?

代理的含义: 对一个对象基本语义的代理,拦截并重新定义对一个对象的基本操作。

基本语义的操作: 读取、设置属性值的操作,就属于基本语义的操作。

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

因为在 JavaScript 的世界里,万物皆对象。一个函数也是一个对象,所以调用函数也是对一个对象的基本操作,所以我们也可以用 Proxy 来拦截函数的调用操作:

const fn = (name) => {
  console.log('我是:', name)
}
​
const p2 = new Proxy(fn, {
  // 使用 apply 拦截函数调用
  apply(target, thisArg, argArray) {
    target.call(thisArg, ...argArray)}
})
​
p2('lc') // 输出:'我是:lc' 

所以,Proxy 对一个对象的代理,也就是能够拦截对一个对象的基本操作。

但是对一个对象的复合操作是无法代理的,调用对象下的方法就是典型的复合操作:

obj.fn() 

它是由两个基本语义组成的。第一个基本语义是get,即先通过 get 操作得到 obj.fn 属性。第二个基本语义是函数调用,即通过get 得到 obj.fn 的值后再调用它。

我们再来介绍一下在 Vue3 响应式数据的代理中会使用到的 Reflect

Reflect 是一个全局对象,在其下面有很多方法:

Reflect.get()
Reflect.set()
Reflect.apply()
// ... 

任何在 Proxy 的拦截器中能够找到的方法,都能够在 Reflect 中找到同名函数。

例如下面的两个操作是等价的:

const obj = { foo: 1 }
​
// 直接读取
console.log(obj.foo) // 1
// 使用 Reflect.get 读取
console.log(Reflect.get(obj, 'foo')) // 1 

既然操作是等价的,那么 Vue3 中使用 Reflect 的意义是什么呢?

实际上 Reflect.get 函数还能接收第三个参数,即指定接收者 receiver,你可以把它理解为函数调用过程中的 this,例如:

const obj = { foo: 1 }
console.log(Reflect.get(obj, 'foo', { foo: 2 }))  // 输出的是 2 而不是 1 

Reflect 方法虽然还有很多其他方面的意义,但是在 Vue3 中唯一关心的只有这一点,因为它与响应式数据的实现密切相关。我们在使用 Proxy 进行代理的过程中,原对象和代理对象之间会出现 this 指向混乱的问题,这会导致收集依赖时出错,使用 Reflect 传递 this 可以帮助避免在响应式代理阶段产生的 this 指向问题。

下面举个例子帮助大家理解。

const obj = { foo: 1 }
​
const p = new Proxy(obj, {
  get(target, key) {
    track(target, key) // 收集依赖
    // 注意,这里我们没有使用 Reflect.get 完成读取
    return target[key]},
  set(target, key, newVal) {
    // 这里同样没有使用 Reflect.set 完成设置
    target[key] = newVal
    trigger(target, key) // 分发依赖,触发相关的响应式函数的执行}
}) 

这是一个用来实现响应式数据的最基本的代码。

在 get 和 set 拦截函数中,我们都是直接使用原始对象 target 来完成对属性的读取和设置操作的,其中原始对象 target 就是上述代码中的 obj 对象。

接下来我们为 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 属性值,因此我们认为副作用函数与属性 foo 之间也会建立联系。当我们修改 p.foo 的值时应该能够触发响应,使得副作用函数重新执行才对。

然而实际并非如此,当我们尝试修改 p.foo 的值时:

p.foo++ 

副作用函数并不会重新执行,问题出在哪里呢?实际上,问题就出在 bar 属性的访问器函数 getter 里:

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

当我们使用 this.foo 读取 foo 属性值时,这里的 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 函数就派上用场了:

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。接着关键的一步发生了,我们使用 Reflect.get(target, key, receiver) 代替之前的 target[key],这里的关键点就是第三个参数 receiver。我们已经知道它就是代理对象 p,所以访问器属性 bar 的 getter 函数内的 this 指向代理对象 p。

很显然,这样的 this 指向才会在副作用函数与响应式数据之间建立响应联系,从而达到依赖收集的效果。如果此时再对 p.foo 进行自增操作,会发现已经能够触发副作用函数重新执行了。

正是基于上述原因,Vue3 中统一使用了 Reflect.* 方法来进行对象的各类操作。

JavaScript 对象及 Proxy 的工作原理

在 JavaScript 中有两种对象,其中一种叫作常规对象(ordinary object),另一种叫作异质对象(exotic object)。这两种对象包含了 JavaScript 世界中的所有对象,任何不属于常规对象的对象都是异质对象。

我们可以通过对象的内部方法/内部槽来区分这两者。

所谓内部方法,指的是当我们对一个对象进行操作时在引擎内部调用的方法,这些方法对于JavaScript 使用者来说是不可见的。

举个例子,当我们访问对象属性时:

obj.foo 

引擎内部会调用 [[Get]] 这个内部方法来读取属性值。

补充说明:在 ECMAScript 规范中使用 [[xxx]] 来代表内部方法或内部槽。

如何区分一个对象是普通对象还是函数呢?一个对象在什么情况下才能作为函数调用呢?答案是,通过内部方法和内部槽来区分对象,例如函数对象会部署内部方法 [[Call]],而普通对象则不会。

内部方法具有多态性。

不同类型的对象可能部署了相同的内部方法,却具有不同的逻辑。

例如,普通对象和 Proxy 对象都部署了 [[Get]]这个内部方法,但它们的逻辑是不同的,普通对象部署的 [[Get]] 内部方法的逻辑是由 ECMA 规范的10.1.8 节定义的,而 Proxy 对象部署的 [[Get]] 内部方法的逻辑是由 ECMA 规范的 10.5.8 节来定义的。对象必要的内部方法如下表:

所以,常规对象和异质对象的区别如下,满足以下三点要求的对象就是常规对象:

  • 对于上表中列出的内部方法,必须使用 ECMA 规范 10.1.x 节给出的定义实现
  • 对于额外的内部方法 [[Call]],必须使用 ECMA 规范 10.2.1 节给出的定义实现
  • 对于额外的内部方法 [[Construct]],必须使用 ECMA 规范 10.2.2 节给出的定义实现

而所有不符合这三点要求的对象都是异质对象。例如,由于 Proxy 对象的内部方法 [[Get]] 没有使用ECMA 规范的 10.1.8 节给出的定义实现,所以 Proxy 是一个异质对象。

如果在创建 Proxy 代理对象时没有指定对应的拦截函数,例如没有指定 get() 拦截函数,那么当我们通过 Proxy 代理对象访问属性值时,代理对象的内部方法 [[Get]] 会调用原始对象的内部方法 [[Get]] 来获取属性值。

由此我们可以知道,创建 Proxy 代理对象时指定的拦截函数,实际上是用来自定义 Proxy 代理对象本身的内部方法和行为的,而不是用来指定被代理对象的内部方法和行为的。

举个例子,当我们要拦截删除属性操作时,可以使用 deleteProperty 拦截函数(delete操作符的捕捉器)实现:

const obj = { foo: 1 }
const p = new Proxy(obj, {
  deleteProperty(target, key) {
    return Reflect.deleteProperty(target, key)}
})
​
console.log(p.foo) // 1
delete p.foo
console.log(p.foo) // 未定义 

根据上述所知,这里 deleteProperty 实现的是代理对象 p 的内部方法和行为(而不是 obj),所以为了删除被代理对象 obj 上的属性值,我们需要使用 Reflect.deleteProperty(target, key) 来完成(所以这里不需要通过第三个参数将 this 指向 p)。

引用类型数据的代理

如何代理 Object

从本节开始,我们将着手实现响应式数据。

响应式系统应该拦截一切读取操作,以便当数据变化时能够正确地触发响应。但是“读取”是一个很宽泛的概念,例如使用 in 操作符检查对象上是否具有给定的 key 也属于“读取”操作。

我们列举一下对一个普通对象的所有可能的读取操作:

1.访问属性:obj.foo
2.判断对象或原型上是否存在给定的 key:key in obj
3.使用 for…in 循环遍历对象:for (const key in obj) {}

obj.foo

对于属性的读取,例如 obj.foo,我们可以直接通过 get 拦截函数实现:

const obj = { foo: 1 }
const p = new Proxy(obj, {get(target, key, receiver) {// 建立联系track(target, key)// 返回属性值return Reflect.get(target, key, receiver)}
}) 

key in obj

而对于 in 操作符呢?通过查找 ECMA-262 规范,我们最终知道 in 操作符的运算结果是通过调用一个叫作 HasProperty 的抽象方法得到的,它对应的拦截函数名叫 has,因此我们可以通过 has 拦截函数实现对 in 操作符的代理:

const obj = { foo: 1 }
const p = new Proxy(obj, {has(target, key) {track(target, key)return Reflect.has(target, key)}
}) 

这样,当我们在副作用函数中通过 in 操作符操作响应式数据时,就能够建立依赖关系:

effect(() => {'foo' in p // 将会建立依赖关系
}) 

for (const key in obj) {}

再看看 for…in 循环,通过查找 ECMA-262 规范,我们再次得知可以使用 ownKeys 拦截函数来间接拦截 for…in 循环:

const obj = { foo: 1 }
const ITERATE_KEY = Symbol()

const p = new Proxy(obj, {ownKeys(target) {// 将副作用函数与 ITERATE_KEY 关联track(target, ITERATE_KEY)return Reflect.ownKeys(target)}
})

// 副作用函数
effect(() => {// for...in 循环for (const key in p) {console.log(key) // foo}
}) 

但是,这个拦截函数和 get 不一样,它无法获取 key 值。

上述代码中,副作用函数中总共进行了一次 for…in 循环,那么 ownKeys 总共会拦截一次,并且得到的这个 target 就是 { foo: 1 } (有多少次 for…in 循环 ownKeys 就会拦截多少次,并不是 obj 中的每个 key 都拦截一次)。ownKeys 这个函数是用来获取一个对象上所有属于自己的键值,这个操作是不与任何具体的键进行绑定的,也就是说每个 for…in 循环我们都需要一个 key 来绑定它。

因此 Vue3 中手动构造了唯一的 key 作为标识,即上述代码中声明的 ITERATE_KEY

随之而言,既然追踪的是 ITERATE_KEY,那么相应地,在触发响应的时候也应该触发它才行:

trigger(target, ITERATE_KEY) 

但是在什么场景下,对数据的操作需要触发与 ITERATE_KEY 相关联的副作用函数重新执行呢?

场景如下:

  • 对象添加了新的属性(修改属性时并不需要触发)
  • 对象删除了属性

例如一个对象 { foo: 1 },由于其原本只有 foo 属性,因此 for…in 循环只会执行一次。现在我们为它添加一个新的属性 bar,for…in 循环就会由执行一次变成执行两次。也就是说,当为对象添加新属性时,会对 for…in 循环产生影响,所以需要触发与 ITERATE_KEY 相关联的副作用函数重新执行。同理,删除对象上的属性时,也需要触发。

依上所述,对 for…in 读取和设置操作的拦截与通常的 get/set 拦截不同,需要注意实现以下几点:

  • 当为对象 p 添加新的属性 bar 时,不仅需要触发与原先已有属性 key (通过 obj.key 访问时绑定的副作用函数)相关联的副作用函数,还需要触发与 ITERATE_KEY 相关联的副作用函数。
  • 当修改已有属性的值时,不会对 for…in 循环产生影响,因为无论怎么修改一个属性的值,对于for…in 循环来说都只会循环一次。所以在这种情况下,我们不需要触发副作用函数重新执行,否则会造成不必要的性能开销。所以就需要我们在 set 拦截函数内能够区分操作的类型,到底是添加新属性还是设置已有属性。
  • 当删除属性时,会使得对象的键变少,它会影响 for…in 循环的次数,此时我们也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行。
  • 当删除属性时,需要首先检查被删除的属性是否属于对象自身(而不是原型上的),然后调用 Reflect.deleteProperty 函数完成属性的删除工作。当这两步的结果都满足条件时,才触发副作用函数的重新执行。

先看下通常对对象设置的操作拦截是如何写的:

const p = new Proxy(obj, {// 拦截设置操作set(target, key, newVal, receiver) {// 设置属性值const res = Reflect.set(target, key, newVal, receiver)// 把副作用函数从桶里取出并执行trigger(target, key)return res},// 省略其他拦截函数
}) 

再看对 for…in 操作的拦截详细实现如下:

const p = new Proxy(obj, {// 拦截设置操作(因为对obj某个属性的修改或者添加都是通过set拦截的,而删除是通过deleteProperty拦截的)set(target, key, newVal, receiver) {// 如果属性不存在,则说明是在添加新属性,否则是设置已有属性const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'// 设置属性值const res = Reflect.set(target, key, newVal, receiver)// 将 type 作为第三个参数传递给 trigger 函数trigger(target, key, type)return res},// 省略其他拦截函数
}) 

如以上代码所示,我们优先使用 Object.prototype.hasOwnProperty 检查当前操作的属性是否已经存在于目标对象上,如果存在,则说明当前操作类型为 ‘SET’,即修改属性值;否则认为当前操作类型为 ‘ADD’,即添加新属性。最后,我们把类型结果 type 作为第三个参数传递给 trigger 函数。

const p = new Proxy(obj, {deleteProperty(target, key) {// 检查被操作的属性是否是对象自己的属性const hadKey = Object.prototype.hasOwnProperty.call(target, key)// 使用 Reflect.deleteProperty 完成属性的删除const res = Reflect.deleteProperty(target, key)if (res && hadKey) {// 只有当被删除的属性是对象自己的属性并且成功删除时,才触发更新trigger(target, key, 'DELETE')}return res}
}) 

同样的,对 delete 的操作也需要进行拦截。

接下来在 trigger 函数内就可以通过类型 type 来区分当前的操作类型,并且只有当操作类型 type 为 ‘ADD’ 或 ‘DELETE’ 时,才会触发与 ITERATE_KEY 相关联的副作用函数重新执行,这样就避免了不必要的性能损耗:

function trigger(target, key, type) {const depsMap = bucket.get(target)if (!depsMap) returnconst effects = depsMap.get(key)const effectsToRun = new Set()effects && effects.forEach(effectFn => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn)}})console.log(type, key)// 只有当操作类型为 'ADD' 或 'DELETE' 时,才需要触发与 ITERATE_KEY 相关联的副作用函数重新执行if (type === 'ADD' || type === 'DELETE') {const iterateEffects = depsMap.get(ITERATE_KEY)iterateEffects && iterateEffects.forEach(effectFn => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn)}})}effectsToRun.forEach(effectFn => {if (effectFn.options.scheduler) {effectFn.options.scheduler(effectFn)} else {effectFn()}})
} 

浅响应与深响应

在 Vue3 中,我们不仅可以进行深响应式的监听,还能进行浅响应式的监听。reactive 是深响应,shallowReactive 是浅响应。

例如,我们代理一个多层级的对象:

const obj = {foo: {bar: 1}
} 

该对象的 foo 属性也是一个对象 { bar: 1 },我们先看一下像上述章节那样简单的监听会是什么效果:

function reactive(obj) {return new Proxy(obj, {get(target, key, receiver) {track(target, key)// 当读取属性值时,直接返回结果return Reflect.get(target, key, receiver)}// 省略其他拦截函数})
} 

由上述代码可知,我们用 get 进行拦截 obj 时,首先会读取 obj.foo 的值,这里我们直接使用 Reflect.get 函数返回 obj.foo 的结果,这样得到的 obj.foo 就只是一个普通对象,他并不是一个响应式对象,所以当我们在副作用函数中访问 obj.foo.bar 时,是无法建立响应联系的。

所以我们需要对 Reflect.get 返回的结果再做一层包装:

function reactive(obj) {return new Proxy(obj, {get(target, key, receiver) {track(target, key)// 得到原始值结果const res = Reflect.get(target, key, receiver)if (typeof res === 'object' && res !== null) {// 调用 reactive 将结果包装成响应式数据并返回return reactive(res)}// 返回 resreturn res}// 省略其他拦截函数})
} 

上述代码就是一个深响应,也就是 reactive 的实现原理。

如上所示,当读取属性值时,我们首先检测该值是否是对象,如果是对象,则递归地调用 reactive 函数将其包装成响应式数据并返回。这样当使用 obj.foo 读取 foo 属性值时,得到的就会是一个响应式数据。当修改obj.foo.bar 的值时,就能够触发副作用函数的重新执行了。

但是,考虑到并不是所有情况我们都希望是深响应的,于是 Vue3 中就促生了另一个叫 shallowReactive 的函数来进行浅响应的监听。

所谓浅响应,指的是只有对象的第一层属性是响应式的。

为此,Vue3 中统一使用了一个 createReactive 函数来执行响应式的监听:

function reactive(obj) {return createReactive(obj)
}
function shallowReactive(obj) {return createReactive(obj, true)
} 
// 封装 createReactive 函数,接收一个参数 isShallow,代表是否为浅响应,默认为 false,即非浅响应
function createReactive(obj, isShallow = false) {return new Proxy(obj, {// 拦截读取操作get(target, key, receiver) {const res = Reflect.get(target, key, receiver)track(target, key)// 如果是浅响应,则直接返回原始值if (isShallow) {return res}if (typeof res === 'object' && res !== null) {return reactive(res)} return res} // 省略其他拦截函数})
} 

只读和浅只读

Vue 开发中,我们会希望一些数据是只读的,例如组件接收到的 props 对象,当用户尝试修改只读数据时,会收到一条警告信息,这样就实现了对数据的保护。这时就要用到接下来要讨论的 readonly 函数,它能够将一个数据变成只读的:

const obj = readonly({ foo: 1 })
// 尝试修改数据,会得到警告
obj.foo = 2 

当一个数据是只读的,我们需要实现以下几点:

  • 不可以通过 set 拦截设置对象的属性值
  • 不可以通过 deleteProperty 拦截删除对象的属性
  • 不用建立响应式联系(如果一个数据是只读的,那就意味着任何方式都无法修改它)
  • 只读也分为深只读和浅只读

Vue3 基于以上几点实现了对只读数据的代理,只读数据本质上也是对数据对象的代理,所以同样可以使用 createReactive 函数来实现。如下面的代码所示,我们为 createReactive 函数增加第三个参数 isReadonly:

function readonly(obj) {return createReactive(obj, false, true)
}

function shallowReadonly(obj) {return createReactive(obj, true /* shallow */, true)
} 

这样,我们在 shallowReadonly 函数内调用 createReactive 函数创建代理对象时,将第二个参数 isShallow 设置为 true,这样就可以创建一个浅只读的代理对象了。

具体实现如下:

// 增加第三个参数 isReadonly,代表是否只读,默认为false,即非只读
function createReactive(obj, isShallow = false, isReadonly = false) {return new Proxy(obj, {// 拦截读取操作get(target, key, receiver) {// 如果一个数据是只读的,则不用建立响应式联系if (!isReadonly) {track(target, key)}const res = Reflect.get(target, key, receiver)if (isShallow) {return res}if (typeof res === 'object' && res !== null) {// 如果数据为只读,则调用 readonly 对值进行包装return isReadonly ? readonly(res) : reactive(res)}return res},set(target, key, newVal, receiver) {// 如果是只读的,则打印警告信息并返回if (isReadonly) {console.warn(`属性 ${key} 是只读的`)return true}const oldVal = target[key]const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'const res = Reflect.set(target, key, newVal, receiver)if (target === receiver.raw) {if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {trigger(target, key, type)}}return res},deleteProperty(target, key) {// 如果是只读的,则打印警告信息并返回if (isReadonly) {console.warn(`属性 ${key} 是只读的`)return true}const hadKey = Object.prototype.hasOwnProperty.call(target, key)const res = Reflect.deleteProperty(target, key)if (res && hadKey) {trigger(target, key, 'DELETE')}return res} // 省略其他拦截函数})
} 

基本类型数据的代理

在 JavaScript 中,原始值是按值传递的,而非按引用传递。这意味着,如果一个函数接收原始值作为参数,那么形参与实参之间没有引用关系,它们是两个完全独立的值,对形参的修改不会影响实参。

另外,JavaScript 中的 Proxy 无法提供对原始值的代理,因此想要将原始值变成响应式数据,就必须对其做一层包裹,也就是我们接下来要介绍的 ref

ref 的概念

由于 Proxy 的代理目标必须是非原始值,所以我们没有任何手段拦截对原始值的操作。对于这个问题,Vue3 中使用的方法是,使用一个非原始值去“包裹”原始值,例如使用一个对象包裹原始值:

const wrapper = {
  value: 'vue'
}
// 可以使用 Proxy 代理 wrapper,间接实现对原始值的拦截
const name = reactive(wrapper)
name.value // vue
// 修改值可以触发响应
name.value = 'vue3' 

这样的设计就是我们日常开发写响应式的原始值时需要写 .value 的原因。

不过上述这么简陋的写法肯定不行,这样做会产生两个问题:

  • 用户为了创建一个响应式的原始值,不得不顺带创建一个包裹对象
  • 包裹对象由用户定义,而这意味着不规范。用户可以随意命名,例如 wrapper.value、wrapper.val 等。

为了解决以上问题,Vue3 使用到了工厂模式,工厂模式中我们可以封装一个函数,将包裹对象的创建工作都封装到该函数中:

// 封装一个 ref 函数
function ref(val) {
  // 在 ref 函数内部创建包裹对象
  const wrapper = {
    value: val}
  // 将包裹对象变成响应式数据
  return reactive(wrapper)
} 

这就是使用 ref 函数来代理原始值的方法。

但是这样的 ref 值其实和 reactive 值是没法做区分的,像下面代码中的 refVal1 和 refVal2 就是完全一样的:

const refVal1 = ref(1)
const refVal2 = reactive({ value: 1 }) 

所以我们还需要在 ref 值中加上一个专门的标志来区分它们,这是必要的,因为这涉及到下文写到的自动脱 ref 能力:

function ref(val) {
  const wrapper = {
    value: val}
  // 使用 Object.defineProperty 在 wrapper 对象上定义一个不可枚举的属性 __v_isRef,并且值为 true
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true})
​
  return reactive(wrapper)
} 

我们使用 Object.defineProperty 为包裹对象 wrapper 定义了一个不可枚举且不可写的属性 v_isRef,它的值为 true,代表这个对象是一个 ref,而非普通对象。这样我们就可以通过检查 v_isRef 属性来判断一个数据是否是 ref 了。如下图所示,我们项目中的 ref 值都有这个属性:

响应丢失问题

ref 除了能够用于原始值的响应式方案之外,还能用来解决响应丢失问题

响应丢失问题是 Vue3 中的一个很常见的问题,官方文档中都有提醒,例如在编写 Vue.js 组件时,我们通常会使用展开运算符(…)把数据暴露到模板中使用:

export default {
  setup() {
    // 响应式数据
    const obj = reactive({ foo: 1, bar: 2 })
​
    // 将数据暴露到模板中
    return {
      ...obj
  }}
} 

接着,我们就可以在模板中访问从 setup 中暴露出来的数据:

<template>
  <p>{{ foo }} / {{ bar }}</p>
</template> 

然而,这么做会导致响应丢失。其表现是,当我们修改响应式数据的值时,不会触发重新渲染。所以为什么会导致响应丢失呢?其原理如下:

return {
  foo: 1,
  bar: 2
} 

...obj 之后的结果就相当于上述的代码,可以发现,这其实就是返回了一个普通对象,它不具有任何响应式能力。把一个普通对象暴露到模板中使用,是不会在渲染函数与响应式数据之间建立响应联系的。所以当我们尝试修改 obj.foo 的值时,不会触发重新渲染。

我们也可以用另一种方式来描述响应丢失问题:

// obj 是响应式数据
const obj = reactive({ foo: 1, bar: 2 })
​
// 将响应式数据展开到一个新的对象 newObj
const newObj = {
  ...obj
}
​
// 副作用函数
effect(() => {
  console.log(newObj.foo)
})
​
// 很显然,此时修改 obj.foo 并不会触发上面副作用函数的响应
obj.foo = 100 

我们在 setup 中 return 的对象就相当于上述代码中的 newObj 对象,这个对象并不是一个响应式的对象,所以会出现响应丢失的现象。

如何解决这个问题?解决这个问题我们需要实现的是:在副作用函数内,即使通过普通对象 newObj 来访问属性值,也能够建立响应联系?其实是可以的,实现的代码如下:

// obj 是响应式数据
const obj = reactive({ foo: 1, bar: 2 })

// newObj 对象下具有与 obj 对象同名的属性,并且每个属性值都是一个对象,
// 该对象具有一个访问器属性 value,当读取 value 的值时,其实读取的是 obj 对象下相应的属性值
const newObj = {foo: {get value() {return obj.foo}},bar: {get value() {return obj.bar}}
}

effect(() => {// 在副作用函数内通过新的对象 newObj 读取 foo 属性值console.log(newObj.foo.value)
})

// 这时能够触发响应了
obj.foo = 100 

这样当我们在副作用函数内读取 newObj.foo 时,等价于间接读取了 obj.foo 的值。这样响应式数据自然能够与副作用函数建立响应联系。于是,当我们尝试修改 obj.foo 的值时,能够触发副作用函数重新执行。

在 Vue3 中,将这种结构抽象出来并封装成了函数,也就是官方提供用来解决响应丢失问题的 api,即 toRef

function toRef(obj, key) {const wrapper = {get value() {return obj[key]}}return wrapper
} 

toRef 函数接收两个参数,第一个参数 obj 是一个响应式数据,第二个参数 key 是 obj 对象的一个键。该函数会返回一个类似于 ref 结构的 wrapper 对象。

不过这样返回的 wrapper 对象的 value 属性只有 getter,没有 setter。为了功能的完整性,我们应该为它加上 setter 函数,所以最终的实现如下:

function toRef(obj, key) {const wrapper = {get value() {return obj[key]},// 允许设置值set value(val) {obj[key] = val}}Object.defineProperty(wrapper, '__v_isRef', {value: true})return wrapper
} 

这样当设置 value 属性的值时,最终设置的是响应式数据的同名属性的值,这样就能正确地触发响应了。

有了 toRef 函数后,我们就可以重新实现 newObj 对象了:

const newObj = {foo: toRef(obj, 'foo'),bar: toRef(obj, 'bar')
} 

官方同样还提供了一个 toRefs 函数,来批量地完成转换(我们平常在项目中使用 toRefs 更多),其原理如下:

function toRefs(obj) {const ret = {}// 使用 for...in 循环遍历对象for (const key in obj) {// 逐个调用 toRef 完成转换ret[key] = toRef(obj, key)}return ret
} 

所以我们在 setup 中返回的对象会这么写:

return {...toRefs(obj)
} 

总结一下,Vue3 解决响应丢失问题的思路是:将响应式数据转换成类似于 ref 结构的数据。但为了概念上的统一,Vue3 中其实会将通过 toRef 或 toRefs 转换后得到的结果视为真正的 ref 数据,因为我们也需要为 toRef 函数中的 wrapper 包裹对象增加上文提到的 __v_isRef 属性。

由此,我们可得知,ref 的作用不仅仅是实现原始值的响应式方案,它还用来解决响应丢失问题。

自动脱 ref

toRefs 虽然解决了响应丢失问题,但因为 toRefs 会把响应式数据的第一层属性值转换为 ref,因此必须通过 value 属性访问值,如以下代码所示:

<p>{{ foo.value }} / {{ bar.value }}</p> 

但用户肯定是不希望像上面这样编写代码的,通常情况下用户是在模板中直接访问数据的,例如:

<p>{{ foo }} / {{ bar }}</p> 

因此,我们需要自动脱 ref 的能力。所谓自动脱 ref,指的是属性的访问行为,即如果读取的属性是一个 ref,则直接将该 ref 对应的 value 属性值返回。

为了实现此功能,Vue3 中使用 Proxy 为 newObj(setup中return的对象)新创建一个代理对象 ProxyRefs,通过代理来实现最终目标,这时就用到了上文中介绍的 ref 标识,即 __v_isRef 属性,如下面的代码所示:

function proxyRefs(target) {return new Proxy(target, {get(target, key, receiver) {const value = Reflect.get(target, key, receiver)// 自动脱 ref 实现:如果读取的值是 ref,则返回它的 value 属性值return value.__v_isRef ? value.value : value}})
}

// 调用 proxyRefs 函数创建代理
const newObj = proxyRefs({ ...toRefs(obj) }) 

上面这段代码中定义了 proxyRefs 函数,该函数接收一个对象作为参数,并返回该对象的代理对象。代理对象的作用就是拦截 get 操作,当读取的属性是一个 ref 时,则直接返回该 ref 的 value 属性值,这样就实现了自动脱 ref。

项目中我们在编写 Vue 组件时,组件中的 setup 函数所返回的数据会自动传递给 proxyRefs 函数进行处理:

const MyComponent = {setup() {const count = ref(0)// 返回的这个对象会传递给 proxyRefsreturn { count }}
} 

所以我们在模版中使用它们的时候,无须再通过 value 属性来访问:

<p>{{ count }}</p> 

既然读取属性的时候有自动脱 ref 的能力,那么对应地,设置属性的值也应该有自动为 ref 设置值的能力,所以我们还需要添加对应的 set 拦截函数:

function proxyRefs(target) {return new Proxy(target, {get(target, key, receiver) {const value = Reflect.get(target, key, receiver)return value.__v_isRef ? value.value : value},set(target, key, newValue, receiver) {// 通过 target 读取真实值const value = target[key]// 如果值是 Ref,则设置其对应的 value 属性值if (value.__v_isRef) {value.value = newValuereturn true}return Reflect.set(target, key, newValue, receiver)}})
} 

实际上,自动脱 ref 不仅存在于上述场景。在 Vue3 中,reactive 函数也有自动脱 ref 的能力,如下代码所示:

const count = ref(0)
const obj = reactive({ count })

obj.count // 0 

上述代码中,obj.count 本应该是一个 ref,但由于自动脱 ref 能力的存在,使得我们无须通过 value 属性即可读取 ref 的值。

Vue3 的这个设计旨在减轻用户的心智负担,因为在大部分情况下,用户并不知道一个值到底是不是 ref。有了自动脱 ref 的能力后,用户在模板中使用响应式数据时,就不再需要关心哪些是 ref,哪些不是 ref 了。

总结

本次我们大体介绍了 Vue3 中对响应式数据的代理,其中分为引用类型数据和基本类型数据。我们发现其中的核心设计思路其实并不是很难,但是如果从整个系统来看,要完美无缺的实现对数据的代理,就需要完善的考虑很多因素。Vue3 中对响应式数据的代理与触发就实现的很完善,这也是 Vue3 框架设计的一个重点与难点,也是其魅力所在。

最后

整理了一套《前端大厂面试宝典》,包含了HTML、CSS、JavaScript、HTTP、TCP协议、浏览器、VUE、React、数据结构和算法,一共201道面试题,并对每个问题作出了回答和解析。

有需要的小伙伴,可以点击文末卡片领取这份文档,无偿分享

部分文档展示:



文章篇幅有限,后面的内容就不一一展示了

有需要的小伙伴,可以点下方卡片免费领取

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/169826.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

26.Isaac教程--导航算法

导航算法 本节详细介绍导航算法。 ISAAC教程合集地址: https://blog.csdn.net/kunhe0512/category_12163211.html 文章目录导航算法全局路径规划器规划器模型可见性图算法优化器轨迹规划器全局路径规划器 Isaac 框架中的全局规划器问题被分解为三类&#xff1a;规划器模型、…

SpringBoot使用Swagger2

SpringBoot使用Swagger21.引入swagger依赖2.添加swagger配置类3.测试Controller4.测试5.swagger的注解Api注解ApiOperation注解ApiImplicitParam、ApiImplicitParams注解ApiParam注解ApiResponse、ApiResponses注解ResponseHeader注解ApiModel、ApiModelProperty注解6.更多1.引…

Redis 分布式锁实现文章集锦

前言近两年来微服务变得越来越热门&#xff0c;越来越多的应用部署在分布式环境中&#xff0c;在分布式环境中&#xff0c;数据一致性是一直以来需要关注并且去解决的问题&#xff0c;分布式锁也就成为了一种广泛使用的技术&#xff0c;常用的分布式实现方式为Redis&#xff0c…

PDF压缩在线怎么操作?这几个操作谁还不知道

我们在工作里经常处理非常多的文件&#xff0c;如果每个文件都要储存到设备上是非常困难的&#xff0c;因为这需要占用大量的内存&#xff0c;所以我们需要将PDF文件进行压缩&#xff0c;这样就可以释放我们设备的储存空间&#xff0c;不过对于很多人来说&#xff0c;压缩文件并…

自学Java篇之JFrame创建《石头迷阵小游戏》

自学Java篇之JFrame创建《石头迷阵小游戏》 根据黑马程序员java教程自学完java基础&#xff0c;觉得石头迷阵小游戏案例具有一定的编程练习价值&#xff0c;记录之。 最终效果&#xff1a; 案例主要思想流程&#xff1a; ​ 主要是思想是创建一个4*4的二维数组data&#xff…

【openGauss实战5】表管理及CURD

&#x1f4e2;&#x1f4e2;&#x1f4e2;&#x1f4e3;&#x1f4e3;&#x1f4e3; 哈喽&#xff01;大家好&#xff0c;我是【IT邦德】&#xff0c;江湖人称jeames007&#xff0c;10余年DBA工作经验 一位上进心十足的【大数据领域博主】&#xff01;&#x1f61c;&#x1f61…

汽车网络技术概述

车辆总线是一个专门的内部通信网络&#xff0c;将车辆&#xff08;如汽车、公共汽车、火车、工业或农业车辆、船舶或飞机&#xff09;内的部件相互连接。在电子学中&#xff0c;总线只是一个将多个电气或电子设备连接在一起的设备。车辆控制的特殊要求&#xff0c;如保证信息传…

数据分析-深度学习 Pytorch Day7

图像识别&#xff1a;CIFAR10图形识别1.CIFAR10数据集共有60000张彩色图像&#xff0c;这些图像式32*32*3&#xff0c;分为10个类&#xff0c;每个类6000张2.这里面有50000张用于训练&#xff0c;构成5个训练批&#xff0c;每一批10000张图&#xff1b;另外10000张用于测试&…

vhdx中的win10进行大版本系统升级

文章目录前言普通的win10大版本iso升级方式vhdx中的win10大版本升级方式难点分析 - 无法在虚拟驱动器上安装windows解决方案 - HyperV升级vhdx win10过程效果图hyperV虚机创建mbr引导启动项hyperV虚机设置在hyperV中升级过程图问题集锦问题一&#xff1a;hyverV虚机中升级报错&…

力扣刷题记录——561. 数组拆分、566. 重塑矩阵、575. 分糖果

本专栏主要记录力扣的刷题记录&#xff0c;备战蓝桥杯&#xff0c;供复盘和优化算法使用&#xff0c;也希望给大家带来帮助&#xff0c;博主是算法小白&#xff0c;希望各位大佬不要见笑&#xff0c;今天要分享的是——《力扣刷题记录——561. 数组拆分、566. 重塑矩阵、575. 分…

IDEA远程调试

1 概述 原理&#xff1a;本机和远程主机的两个 VM 之间使用 Debug 协议通过 Socket 通信&#xff0c;传递调试指令和调试信息。 被调试程序的远程虚拟机&#xff1a;作为 Debug 服务端&#xff0c;监听 Debug 调试指令。jdwp是Java Debug Wire Protocol的缩写。 调试程序的本…

初识redis

1.初识Redis Redis是一种键值型的NoSql数据库&#xff0c;这里有两个关键字&#xff1a; 键值型 NoSql 其中键值型&#xff0c;是指Redis中存储的数据都是以key、value对的形式存储&#xff0c;而value的形式多种多样&#xff0c;可以是字符串、数值、甚至json&#xff1a;…

HTTPS一定可靠吗?

HTTPS一定可靠吗&#xff1f;中间人伪装服务器首先我们先看看客户端是如何验证证书的&#xff1f;数字证书签发和验证流程客户端校验服务端数字证书的过程如何出现中间人伪装服务器成服务器的情况&#xff1f;避免该情况中间人伪装服务器 客户端向服务端发起HTTPS建立连接请求时…

你知道吗?python lxml 库也能用于操作 svg 图片

在大多数场景中&#xff0c;我们都用 lxml 库解析网页源码&#xff0c;但你是否知道&#xff0c;lxml 库也是可以操作 svg 图片的。我们可以使用 lxml 中的 etree 模块来解析 SVG 文件&#xff0c;然后使用 SVG 中的各种元素和属性来进行操作。 python lxml 库操作 svg 图片lxm…

传输层协议:TCP协议(上)——协议结构、主要特点以及应用场景

简介 传输控制协议&#xff08;英语&#xff1a;Transmission Control Protocol&#xff0c;缩写&#xff1a;TCP&#xff09;是一种面向连接的、可靠的、基于字节流的传输层通信协议&#xff0c;由IETF的RFC 793定义。在简化的计算机网络OSI模型中&#xff0c;它完成第四层传…

xubuntu系统偶发自动登出

项目场景&#xff1a; 系统&#xff1a;xubuntu-16.04.3-desktop 问题描述 使用xubuntu系统期间&#xff0c;在root用户下进行相关开发&#xff0c;突然系统会回到普通用户登录界面&#xff0c;需要输入密码进入到普通用户下   它会终止所有打开的应用程序和进程&#xff0…

【Vue组件通信方式】

文章目录前言一、父子组件通信1、父传子①使用props接收父组件传递的属性② 使用$attrs接收父组件未在 props 和 emits 中定义的属性和事件③使用 $parent获取父组件的信息2、子传父① 使用 $emit传递信息给父组件② 使用$refs获取子组件的属性和事件二、自定义事件&#xff1a…

独家丨DeepMind科学家、AlphaTensor一作解读背后的故事与实现细节

一直以来&#xff0c;DeepMind的Alpha系列工作&#xff0c;AlphaGo、AlphaStar等致力于棋类和游戏应用中战胜人类&#xff0c;而两个月前发布的AlphaTensor则把目标指向了科学计算领域&#xff0c;意在为矩阵乘法等基本计算任务自动设计更高效的经典算法&#xff0c;这一工作一…

Burpsuite超详细安装教程(附安装包)

写在开头 Burp Suite 是用于攻击web 应用程序的集成平台&#xff0c;包含了许多工具。Burp Suite为这些工具设计了许多接口&#xff0c;以加快攻击应用程序的过程。所有工具都共享一个请求&#xff0c;并能处理对应的HTTP 消息、持久性、认证、代理、日志、警报。 接下来我来…

软件测试面试经 | 双非院校,从外包到外企涨薪85%,他的涨薪秘籍全公开

本文为霍格沃兹测试开发学社优秀学员跳槽笔记&#xff0c;测试开发进阶学习文末加群。 本身是一所不入流的院校毕业的一名建工类专业的瓜娃子&#xff0c;至今记得当初是因为找工作被培训公司忽悠才加入到这个行业的&#xff0c;抱着做着试试的想法这一干在深圳就是6年&#xf…