响应式 API 之 toRef 与 toRefs
前面讲了 ref 和 reactive 这两种响应式API ,为了方便开发者使用,vue3 还出了两个用来 reactive 转换为 ref 的API,分别是 toRef 和 toRefs 。
🌈什么是toRef 与 toRefs
这两个API看拼写能猜到,toRef
转换一个ref,toRefs
是转换所有 ref 。转换后将得到新的变量,并且新变量和原来的变量可以保持同步更新。
API | 作用 |
---|---|
toRef | 创建一个新的 Ref 变量,转换 Reactive 对象的某个字段为 Ref 变量 |
toRefs | 创建一个新的对象,它的每个字段都是 Reactive 对象各个字段的 Ref 变量 |
光看概念可能不容易理解,来看下面的例子,先声明一个 reactive
变量:
interface Member {
id: number
name: string
}
const userInfo : Member = reactive({
id: 1 ,
name: "张珊珊"
})
🌈为什么要进行转换
为什么要出这么两个 API ,官方文档没有特别说明,不过经过笔者在业务中的一些实际使用感受,可知道一些使用理由。
ref
和 reactive
在使用的过程中,各自都有不方便的地方:
ref
API 虽然在 <template />
里使用起来方便,但是在 <script />
里进行读取 / 赋值的时候,要一直记得加上 .value
,否则 BUG 就来了。
reactive
API 虽然在使用的时候,因为知道它本身是一个对象,所以不会忘记通过 foo.bar
这样的格式去操作,但是在 <template />
渲染的时候,又因此不得不每次都使用 foo.bar
的格式去渲染。
那么有没有办法,既可以在编写 <script />
的时候不容易出错,在写 <template />
的时候又比较简单呢?
于是, toRef
和 toRefs
因此诞生。
🌈toRef的使用
🚀API的TS类型和基本用法
// `toRef` API 的 TS 类型
function toRef<T extends object, K extends keyof T>(
object: T,
key: K,
defaultValue?: T[K]
): ToRef<T[K]>
// `toRef` API 的返回值的 TS 类型
type ToRef<T> = T extends Ref ? T : Ref<T>
通过接收两个必传的参数(第一个是 reactive
对象, 第二个是要转换的 key
),返回一个 Ref 变量,在适当的时候也可以传递第三个参数,为该变量设置默认值。
以上面声明好的 userInfo 为例子,如果想要转换 name 字段为 Ref 变量,需要:
const name = toRef(userInfo , 'name')
console.log(name.value)
等号左侧的 name
变量此时是一个 Ref 变量,这里因为 TypeScript 可以对其自动推导,因此声明时可以省略 TS 类型的显式指定,实际上该变量的类型是 Ref<string>
。
所以之后在读取和赋值时,就需要使用 name.value
来操作,在重新赋值时会同时更新 name
和 userInfo.name
的值:
// 修改前先查看初始值
const name = toRef(userInfo, 'name')
console.log(name.value) // Petter
console.log(userInfo.name) // Petter
// 修改 Ref 变量的值,两者同步更新
name.value = 'Tom'
console.log(name.value) // Tom
console.log(userInfo.name) // Tomty
// 修改 Reactive 对象上该属性的值,两者也是同步更新
userInfo.name = 'Jerry'
console.log(name.value) // Jerry
console.log(userInfo.name) // Jerry
这个 API 也可以接收一个 Reactive 数组,此时第二个参数应该传入数组的下标:
// 这一次声明的是数组
const words = reactive(['a', 'b', 'c'])
// 通过下标 `0` 转换第一个 item
const a = toRef(words, 0)
console.log(a.value) // a
console.log(words[0]) // a
// 通过下标 `2` 转换第三个 item
const c = toRef(words, 2)
console.log(c.value) // c
console.log(words[2]) // c
🚀基本用法-完整代码和演示
<script lang="ts">
import { defineComponent, toRef, reactive, toRefs } from "vue"
interface Member {
id: number
name: string
}
const userInfo: Member = reactive({
id: 1,
name: "张珊珊"
})
export default defineComponent({
setup() {
// 在这里声明数据,或者编写函数并在这里执行它
const name = toRef(userInfo, 'name');
function editRefName() {
name.value = '陈圆圆'
}
function editReactiveName() {
userInfo.name = '裴南苇'
}
return {
name,
userInfo,
editRefName,
editReactiveName,
}
},
})
</script>
<template>
<div>
<h2> ref: {{ name }}</h2>
<h2> reactive: {{ userInfo.name }}</h2>
<button @click="editRefName()">修改ref的name</button>
<br>
<button @click="editReactiveName()">修改reactive 的name</button>
</div>
</template>
<style scoped></style>
代码分别显示了 ref 和 reactive 的两种响应式的值的显示。又分别控制了,修改两个值。下面的演示结果可以看出,toRef
转换后将得到新的变量,并且新变量和原来的变量可以保持同步更新。
演示动图
🚀设置默认值
如果 Reactive 对象上有一个属性本身没有初始值,也可以传递第三个参数进行设置(默认值仅对 Ref 变量有效,即此次不会同步):
interface Member {
id: number
name: string
age?: number
}
const userInfo: Member = reactive({
id: 2,
name: "Tony"
})
// 此时为了避免程序运行错误,可以指定一个初始值
// 但初始值仅对 Ref 变量有效,不会影响 Reactive 字段的值
const age = toRef( userInfo , age , 18)
console.log(age.value) // 18
console.log(userInfo.age) // undefined
// 除非重新赋值,才会使两者同时更新
age.value = 25
console.log(age.value) // 25
console.log(userInfo.age) // 25
数组也是同理,对于可能不存在的下标,可以传入默认值避免项目的逻辑代码出现问题:
const words = reactive(['a','b','c'])
//当下标对应的值不存在时,返回 ”undefined“
const d = toRef(words,3)
console.log(d.value) // undefined
console.log(words[3]) // undefined
//设置了默认后,会仅对 Ref 变量有效
cosnt e = toRef(words,4,'e')
console.log(e.value) // e
console.log(words[4]) // undefined
🚀其他用法
这个 API 还有一个特殊用法,但不建议在 TypeScript 里使用。
在 toRef
的过程中,如果使用了原对象上面不存在的 key
,那么定义出来的 Ref 变量的 .value
值将会是 undefined
。
// 众所周知, Petter 是没有女朋友的
const girlfriend = toRef(userInfo, 'girlfriend')
console.log(girlfriend.value) // undefined
console.log(userInfo.girlfriend) // undefined
// 此时 Reactive 对象上只有两个 Key
console.log(Object.keys(userInfo)) // ['id', 'name']
如果对这个不存在的 key
的 Ref 变量进行赋值,那么原来的 Reactive 对象也会同步增加这个 key
,其值也会同步更新。
// 赋值后,不仅 Ref 变量得到了 `Marry` , Reactive 对象也得到了 `Marry`
girlfriend.value = 'Marry'
console.log(girlfriend.value) // 'Marry'
console.log(userInfo.girlfriend) // 'Marry'
// 此时 Reactive 对象上有了三个 Key
console.log(Object.keys(userInfo)) // ['id', 'name', 'girlfriend']
为什么强调不要在 TypeScript 里使用呢?因为在编译时,无法通过 TypeScript 的类型检查:
❯ npm run build
> hello-vue3@0.0.0 build
> vue-tsc --noEmit && vite build
src/views/home.vue:37:40 - error TS2345: Argument of type '"girlfriend"'
is not assignable to parameter of type 'keyof Member'.
37 const girlfriend = toRef(userInfo, 'girlfriend')
~~~~~~~~~~~~
src/views/home.vue:39:26 - error TS2339: Property 'girlfriend' does not exist
on type 'Member'.
39 console.log(userInfo.girlfriend) // undefined
~~~~~~~~~~
src/views/home.vue:45:26 - error TS2339: Property 'girlfriend' does not exist
on type 'Member'.
45 console.log(userInfo.girlfriend) // 'Marry'
~~~~~~~~~~
Found 3 errors in the same file, starting at: src/views/home.vue:37
如果不得不使用这种情况,可以考虑使用 any 类型:
// 将该类型直接指定为 `any`
type Member = any
// 当然一般都是 `const userInfo: any`
// 或者保持接口类型的情况下,允许任意键值
interface Member {
[key: string]: any
}
// 使用 `Record` 也是同理
type Member = Record<string, any>
但笔者还是更推荐保持良好的类型声明习惯,尽量避免这种用法。
🌈使用 toRefs
在了解了 toRef
API 之后,来看看 toRefs
的用法。
🚀API 类型和基本用法
先看看它的 TS 类型:
function toRefs<T extends object>(
object: T
): {
[K in keyof T]: ToRef<T[K]>
}
type ToRef = T extends Ref ? T : Ref<T>
与 toRef 不同, toRefs 只接收了一个参数:reactive 变量。
interface Member {
id: number
name: string
}
// 声明一个 Reactive 变量
const userInfo: Member = reactive({
id: 1,
name: 'Petter',
})
// 传给 `toRefs` 作为入参
const userInfoRefs = toRefs(userInfo)
此时这个新的 userInfoRefs
变量,它的 TS 类型就不再是 Member
了,而应该是:
// 导入 `toRefs` API 的类型
import type { ToRefs } from 'vue'
// 上下文代码省略...
// 将原来的类型传给 API 的类型
const userInfoRefs: ToRefs<Member> = toRefs(userInfo)
也可以重新编写一个新的类型来指定它,因为每个字段都是与原来关联的 Ref 变量,所以也可以这样声明:
// 导入 `ref` API 的类型
import type { Ref } from 'vue'
// 上下文代码省略...
// 新声明的类型每个字段都是一个 Ref 变量的类型
interface MemberRefs {
id: Ref<number>
name: Ref<string>
}
// 使用新的类型进行声明
const userInfoRefs: MemberRefs = toRefs(userInfo)
当然实际上日常使用时并不需要手动指定其类型, TypeScript 会自动推导,可以节约非常多的开发工作量。
和 toRef
API 一样,这个 API 也是可以对数组进行转换:
const words = reactive(['a', 'b', 'c'])
const wordsRefs = toRefs(words)
此时新数组的类型是 Ref<string>[]
,不再是原来的 string[]
类型。
🚀解构与赋值
转换后的 Reactive 对象或数组支持 ES6 的解构,并且不会失去响应性,因为解构后的每一个变量都具备响应性。
// 为了提高开发效率,可以直接将 Ref 变量直接解构出来使用
const { name } = toRefs(userInfo)
console.log(name.value) // Petter
// 此时对解构出来的变量重新赋值,原来的变量也可以同步更新
name.value = 'Tom'
console.log(name.value) // Tom
console.log(userInfo.name) // Tom
这一点和直接解构 Reactive 变量有非常大的不同,直接解构 Reactive 变量,得到的是一个普通的变量,不再具备响应性。
这个功能在使用 Hooks 函数非常好用(在 Vue 3 里也叫可组合函数, Composable Functions ),还是以一个计算器函数为例,这一次将其修改为内部有一个 Reactive 的数据状态中心,在函数返回时解构为多个 Ref 变量:
import { reactive, toRefs } from 'vue'
// 声明 `useCalculator` 数据状态类型
interface CalculatorState {
// 这是要用来计算操作的数据
num: number
// 这是每次计算时要增加的幅度
step: number
}
// 声明一个 “使用计算器” 的函数
function useCalculator() {
// 通过数据状态中心的形式,集中管理内部变量
const state: CalculatorState = reactive({
num: 0,
step: 10,
})
// 功能函数也是通过数据中心变量去调用
function add() {
state.num += state.step
}
return {
...toRefs(state),
add,
}
}
这样在调用 useCalculator
函数时,可以通过解构直接获取到 Ref 变量,不需要再进行额外的转换工作。
// 解构出来的 `num` 和 `step` 都是 Ref 变量
const { num, step, add } = useCalculator()
console.log(num.value) // 0
console.log(step.value) // 10
// 调用计算器的方法,数据也是会得到响应式更新
add()
console.log(num.value) // 10
🌈什么场景下比较适合使用它们
从便利性和可维护性来说,最好只在功能单一、代码量少的组件里使用,比如一个表单组件,通常表单的数据都放在一个对象里。
当然也可以把所有的数据都定义到一个 data
里,再去 data
里面取值,但是没有必要为了转换而转换,否则不如使用 Options API 风格。
🌈在业务中的具体运用
继续使用上文一直在使用的 userInfo
来当案例,以一个用户信息表的小 demo 做个演示。
在 <script />
部分:
- 先用
reactive
定义一个源数据,所有的数据更新,都是修改这个对象对应的值,按照对象的写法维护数据 - 再通过
toRefs
定义一个给<template />
使用的对象,这样可以得到一个每个字段都是 Ref 变量的新对象 - 在
return
的时候,对步骤 2 里的toRefs
对象进行解构,这样导出去就是各个字段对应的 Ref 变量,而不是一整个对象
import { defineComponent, reactive, toRefs } from 'vue'
interface Member {
id: number
name: string
age: number
gender: string
}
export default defineComponent({
setup() {
// 定义一个 reactive 对象
const userInfo = reactive({
id: 1,
name: 'Petter',
age: 18,
gender: 'male',
})
// 定义一个新的对象,它本身不具备响应性,但是它的字段全部是 Ref 变量
const userInfoRefs = toRefs(userInfo)
// 在 2s 后更新 `userInfo`
setTimeout(() => {
userInfo.id = 2
userInfo.name = 'Tom'
userInfo.age = 20
}, 2000)
// 在这里解构 `toRefs` 对象才能继续保持响应性
return {
...userInfoRefs,
}
},
})
在 <template />
部分:
由于 return
出来的都是 Ref 变量,所以在模板里可以直接使用 userInfo
各个字段的 key
,不再需要写很长的 userInfo.name
了。
<template>
<ul class="user-info">
<li class="item">
<span class="key">ID:</span>
<span class="value">{{ id }}</span>
</li>
<li class="item">
<span class="key">name:</span>
<span class="value">{{ name }}</span>
</li>
<li class="item">
<span class="key">age:</span>
<span class="value">{{ age }}</span>
</li>
<li class="item">
<span class="key">gender:</span>
<span class="value">{{ gender }}</span>
</li>
</ul>
</template>
🌈需要注意的问题
请注意是否有相同命名的变量存在,比如上面在 return
给 <template />
使用时,在解构 userInfoRefs
的时候已经包含了一个 name
字段,此时如果还有一个单独的变量也叫 name
,就会出现渲染上的数据显示问题。
此时它们在 <template />
里哪个会生效,取决于谁排在后面,因为 return
出去的其实是一个对象,在对象里,如果存在相同的 key
,则后面的会覆盖前面的。
下面这种情况,会以单独的 name
为渲染数据:
return {
...userInfoRefs,
name,
}
而下面这种情况,则是以 userInfoRefs
里的 name
为渲染数据:
return {
name,
...userInfoRefs,
}
所以当决定使用 toRef
和 toRefs
API 的时候,请注意这个特殊情况!