6.原始值的响应式方案

2024/9/3 Vue

原始值:指的是 Boolean、Number、BigInt、String、Symbol、undefined 和 null 等类型的值。

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

JavaScript 中的 Proxy 无法提供对原始值的代理,因此想要将原始值变成响应式数据,就必须对其做一层包裹,也就是 ref。

# 引入 ref 的概念

​ 由于 Proxy 的代理目标必须是非原始值,所以我们没有办法拦截对原始值的操作,例如:

let str = 'vue'
// 无法拦截对值的修改
str = 'vue3'
1
2
3

​ 对于这个问题,我们能够想到的唯一办法是,使用一个非原始值去“包裹”原始值。例如:

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

​ 但这样做会导致两个问题:

① 用户为了创建一个响应式的原始值,不得不顺带创建一个包裹对象;

② 包裹对象由用户定义,而这意味着不规范。用户可以随意命名,例如 wrapper.value、wrapper.val 都是可以的。

​ 为了解决这两个问题,我们可以封装一个函数,将包裹对象的创建工作都封装到该函数中:

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

把创建 wrapper 对象的工作封装到 ref 函数内部,然后使用 reactive 函数将包裹对象变成响应式数据并返回。

​ 这样我们就解决了上述两个问题。运行如下测试代码能够按照预期工作:

 // 创建原始值的响应式数据
 const refVal = ref(1)

 effect(() => {
   // 在副作用函数内通过 value 属性读取原始值
   console.log(refVal.value)
 })
 // 修改值能够触发副作用函数重新执行
 refVal.value = 2
1
2
3
4
5
6
7
8
9

​ 接下来我们面临的第一个问题是,如何区分 refVal 到底是原始值的包裹对象,还是一个非原始值的响应式数据,如以下代码所示:

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

​ 从我们的实现来看,这段代码中的 refVal1 和 refVal2 并无任何区别。但是,我们有必要区分一个数据到底是不是 ref,因为这涉及自动脱 ref 能力。

​ 通过给 ref 增加一个不可枚举也不可写的属性 __v_isRef 来区分一个数据是否是 ref:

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

   return reactive(wrapper)
 }
1
2
3
4
5
6
7
8
9
10
11

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

Vue 源码中,ref 返回的是一个 Reflmpl 类的实例。

实例化 Reflmpl 类时执行构造函数,判断当前数据是对象还是基本数据类型,对象的话用 reactive 包裹再赋值给 value 属性,基本数据类型就直接赋值给 value 属性。

有一个只读属性 __v_isRef,为 true 表示其是 re f对象,同时 value 属性是以 Refmpl 类的 get value 和 set value 方法的形式定义的。

当读取 ref 对象.value 时,执行实例的 get value 方法,进行 track 依赖收集,然后返回实例属性 value 值。

同时,Refmpl 实例有一个 dep 属性,这个属性是一个 set 集合,用来存储读取 ref 对象(常规数据类型)的不同的 effect 回调,如果是 ref(响应式对象,那么存储的是在对象下的属性依赖集合中)

var r = ref(5), 当执行 r.value = 666 时,触发 ref 实例的 set value 方法,在 set value 方法中执行 trigger,trigger 从当前 ref 实例的 dep 属性依赖集合中获取所有的 effect 回调进行执行。

# 响应丢失问题

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

# 什么是响应丢失问题?

​ 在编写 Vue.js 组件时,我们通常要把数据暴露到模板中使用,例如:

<template>
   <p>{{ foo }} / {{ bar }}</p>
</template>
export default {
  setup() {
    // 响应式数据
    const obj = reactive({ foo: 1, bar: 2 })

    // 将数据暴露到模板中
    return {
      ...obj
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 然而,这么做会导致响应丢失。其表现是,当我们修改响应式数据的值时,不会触发重新渲染:

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

     // 1s 后修改响应式数据的值,不会触发重新渲染
     setTimeout(() => {
       obj.foo = 100
     }, 1000)

     return {
       ...obj
     }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

​ 为什么会导致响应丢失呢?这是由展开运算符(...)导致的。

return {
	...obj
}
// 实际上,上面这段代码等价于:
return {
	foo: 1,
	bar: 2
}
1
2
3
4
5
6
7
8

可以发现,这其实就是返回了一个普通对象,它不具有任何响应式能力。

​ 把一个普通对象暴露到模板中使用,是不会在渲染函数与响应式数据之间建立响应联系的。所以当我们尝试在一个定时器中修改 obj.foo 的值时,不会触发重新渲染。

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

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

 // 将响应式数据展开到一个新的对象 newObj
 const newObj = {
   ...obj
 }

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

 // 很显然,此时修改 obj.foo 并不会触发响应
 obj.foo = 100
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

​ 在这里,使用展开运算符得到一个新的对象 newObj,它是一个普通对象,不具有响应能力。副作用函数内访问的是普通对象 newObj,它没有任何响应能力,所以当我们尝试修改 obj.foo 的值时,不会触发副作用函数重新执行。

​ 其实,我们可以修改 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

可以看到,在现在的 newObj 对象下,具有与 obj 对象同名的属性,而且每个属性的值都是一个对象,例如 foo 属性的值是:

{
 get value() {
     return obj.foo
 }
}
1
2
3
4
5

该对象有一个访问器属性 value,当读取 value 的值时,最终读取的是响应式数据 obj 下的同名属性值。所以,当在副作用函数内读取 newObj.foo 时,等价于间接读取了 obj.foo 的值。这样响应式数据自然能够与副作用函数建立响应联系。

于是,当我们尝试修改 obj.foo 的值时,能够触发副作用函数重新执行。

​ 观察 newObj 对象,可以发现 foo 和 bar 这两个属性的结构非常像,这启发我们将这种结构抽象出来并封装成函数:

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

   return wrapper
 }
1
2
3
4
5
6
7
8
9

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

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

const newObj = {
    foo: toRef(obj, 'foo'),
    bar: toRef(obj, 'bar')
}
1
2
3
4

​ 可以看到,代码变得非常简洁。但如果响应式数据 obj 的键非常多,我们还是要花费很大力气来做这一层转换。为此,我们可以封装 toRefs 函数,来批量地完成转换:

 function toRefs(obj) {
   const ret = {}
   // 使用 for...in 循环遍历对象
   for (const key in obj) {
     // 逐个调用 toRef 完成转换
     ret[key] = toRef(obj, key)
   }
   return ret
 }
1
2
3
4
5
6
7
8
9

​ 现在,我们只需要一步操作即可完成对一个对象的转换:

const newObj = { ...toRefs(obj) }
1

​ 可以使用如下代码进行测试:

 const obj = reactive({ foo: 1, bar: 2 })

 const newObj = { ...toRefs(obj) }
 console.log(newObj.foo.value) // 1
 console.log(newObj.bar.value) // 2
1
2
3
4
5

​ 现在,响应丢失问题就被我们彻底解决了,思路是:将响应式数据转换成类似于 ref 结构的数据。但为了概念上的统一,我们会将通过 toRef 或 toRefs 转换后得到的结果视为真正的 ref 数据,为此我们需要为 toRef 函数增加一段代码:

 function toRef(obj, key) {
   const wrapper = {
     get value() {
       return obj[key]
     }
   }
   // 定义 __v_isRef 属性
   Object.defineProperty(wrapper, '__v_isRef', {
     value: true
   })

   return wrapper
 }
1
2
3
4
5
6
7
8
9
10
11
12
13

使用 Object.defineProperty 函数为 wrapper 对象定义了**__v_isRef 属性**。这样,toRef 函数的返回值就是真正意义上的 ref 了。通过上述讲解我们能注意到,ref 的作用不仅仅是实现原始值的响应式方案,它还用来解决响应丢失问题

​ 但当前实现的 toRef 函数存在缺陷,即通过 toRef 函数创建的 ref 是只读的,无法修改值,例如:

const obj = reactive({ foo: 1, bar: 2 })
const refFoo = toRef(obj, 'foo')

refFoo.value = 100 // 无效
1
2
3
4

​ 这是因为 toRef 返回的 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
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

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

# 自动脱 ref

​ toRefs 函数的确解决了响应丢失问题,但也带来了新的问题。由于 toRefs 会把响应式数据的第一层属性值转换为 ref,而 ref 必须通过 value 属性访问

 const obj = reactive({ foo: 1, bar: 2 })
 obj.foo // 1
 obj.bar // 2

 const newObj = { ...toRefs(obj) }
 // 必须使用 value 访问值
 newObj.foo.value // 1
 newObj.bar.value // 2
1
2
3
4
5
6
7
8

​ 这其实增加了用户的心智负担,因为通常情况下用户是在模板中访问数据的,例如:

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

​ 用户肯定不希望编写下面这样的代码:

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

​ 因此,我们需要自动脱 ref 的能力。

​ 所谓自动脱 ref,指的是属性的访问行为,即如果读取的属性是一个 ref,则直接将该 ref 对应的 value 属性值返回

例如:

newObj.foo // 1
1

可以看到,即使 newObj.foo 是一个 ref,也无须通过 newObj.foo.value来访问它的值。

​ 要实现此功能,需要使用 Proxy 为 newObj 创建一个代理对象,通过代理来实现最终目标,这时就用到了 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) })
1
2
3
4
5
6
7
8
9
10
11
12

定义了 proxyRefs 函数,该函数接收一个对象作为参数,并返回该对象的代理对象。

代理对象的作用是拦截 get 操作,当读取的属性是一个 ref 时,则直接返回该 ref 的 value 属性值,这样就实现了自动脱 ref:

console.log(newObj.foo) // 1
console.log(newObj.bar) // 2
1
2

​ 实际上,在 Vue.js 组件中的 setup 函数所返回的数据会传递给 proxyRefs 函数进行处理

 const MyComponent = {
   setup() {
     const count = ref(0)

     // 返回的这个对象会传递给 proxyRefs
     return { count }
   }
 }
1
2
3
4
5
6
7
8

​ 这也是为什么我们可以在模板直接访问一个 ref 的值,而无须通过 value 属性来访问

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

​ setup 中返回的 ref 值会进行脱 ref 处理,这就是为什么模板中不需要使用 .value。

​ 既然读取属性的值有自动脱 ref 的能力,对应地,设置属性的值也应该有自动为 ref 设置值的能力,例如:

newObj.foo = 100 // 应该生效
1

​ 实现此功能很简单,只需要添加对应的 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 = newValue
         return true
       }
       return Reflect.set(target, key, newValue, receiver)
     }
   })
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

我们为 proxyRefs 函数返回的代理对象添加了 set 拦截函数。如果设置的属性是一个 ref,则间接设置该 ref 的 value 属性的值即可。

​ 实际上,自动脱 ref 不仅存在于上述场景。在 Vue.js 中,reactive 函数也有自动脱 ref 的能力:

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

 obj.count // 0
1
2
3
4

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

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

# 总结

  • ref 本质上是一个“包裹对象”。因为 JavaScript 的 Proxy 无法提供对原始值的代理,所以我们需要使用一层对象作为包裹,间接实现原始值的响应式方案。
  • 由于“包裹对象”本质上与普通对象没有任何区别,因此为了区分 ref 与普通响应式对象,还为“包裹对象”定义了一个值为 true 的属性,即 __v_isRef,用它作为 ref 的标识。
  • ref 除了能够用于原始值的响应式方案之外,还能用来解决响应丢失问题。
  • toRef 以及 toRefs 这两个函数,本质上是对响应式数据做了一层包装,或者叫作“访问代理”。
  • 自动脱 ref 的能力:为了减轻用户的心智负担,自动对暴露到模板中的响应式数据进行脱 ref 处理。这样,用户在模板中使用响应式数据时,就无须关心一个值是不是 ref 了。
  • 自动脱 ref,需要在 proxyRefs 中拦截对象属性的 get 和 set 行为。get 时判断属性值是不是一个ref,如果是 ref,就返回 ref 的 value,否则返回属性值。set 赋值时也判断属性值是不是 ref,如果是,就直接给 ref 的 value 赋值,否则赋值到 target 的 value 上。
上次更新: 2024/9/7 02:18:38