拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue3 + TypeScript 中 ref 与 reactive 的选择指南

Vue3 + TypeScript 中 ref 与 reactive 的选择指南

接触Vue3 + TypeScript也有一段时间了,团队里每次有新人进来,几乎都会问我同一个问题:ref和reactive到底什么时候用哪个?说实话,这个困惑我一开始也有,因为API文档里讲了自动解包、嵌套响应式这些概念,但真正写业务的时候,还是会犹豫。有人说基础类型用ref、对象用reactive,可实际项目里一个对象里既有字符串又有数组,甚至还有嵌套对象,这个规则根本不够用。如果理解了这两个API在Proxy之上各自做了什么,选择就不再靠死记,而是变成很自然的判断。

这篇文章我会从Vue3响应式原理的边界讲起,然后对比在TypeScript环境下两者的类型差异、访问方式、常见坑位,最后结合一个搜索表单的实战Demo,给出我踩过坑之后总结出来的选择标准。希望能帮正在从Options API过渡到组合式API,或者刚上手Vue3+TS的朋友少走弯路。

1. 从源头说起:为什么Vue3需要ref和reactive两套响应式API

1.1 Proxy的边界:能代理对象,不能代理原始值

Vue3的响应式系统是基于ES6 Proxy实现的。Proxy可以对一个对象进行代理,拦截它的属性读取、赋值、删除、遍历等操作,从而在数据变动时触发依赖更新。但Proxy只能接收对象类型作为参数,如果你直接写new Proxy(1, {}),浏览器立刻报错:Cannot create proxy with a non-object as target or handler。

这里有一个很关键的边界:JavaScript中的原始类型(string、number、boolean、null、undefined、symbol、bigint)本身没有属性,也不存在可以被拦截的“读写”操作。假如你有一个let count = 1,后续执行count = 2,这本质上是在重新绑定一个变量,机器根本没有机会在其中插入响应式逻辑。所以Vue3必须另想办法处理原始值。

正是这个原因,Vue3设计了ref,把它变成一个包装对象:{ value: 1 }。这个包装对象是一个对象,可以被Proxy代理,于是对value的读写就能被拦截,才能实现响应式。这里的value字段本质上是“为了被Proxy代理而创造出来的一个属性”。

1.2 ref的本质是一个“值包装器”

ref的实现逻辑比想象中简单。当你调用ref(1)时,你会得到一个Ref类型的对象,结构上是{ value: 1 },并且对该对象的value属性做了响应式代理。在TypeScript里,这个类型是Ref<number>,它内部大致是这样:

interface Ref<T> { value: T }

如果你传一个对象给ref,比如ref({ name: '张三' }),ref内部会调用reactive({ value: { name: '张三' } })之类的逻辑,把该对象放到value属性上进行深度代理。也就是说,ref并不只是“原始值的专属方案”,它也可以接收对象,只不过有一层value的外壳。

这个外壳在模板中使用时会被自动去掉,但在JavaScript中必须通过.value来读写。很多初学者的不习惯来于此:“为什么模板里能直接写count,而在setup里必须写count.value?”其实这是Vue的模板编译器帮我们做了自动解包,让你感觉不到外壳的存在。

1.3 reactive的定位:直接代理对象

reactive就不一样了。它接收一个对象,返回一个新的代理对象,这个代理对象的所有嵌套属性都会被拦截,所以你拿到的是“看起来一样,但每个属性都是响应式”的对象。在TypeScript中,它的类型签名大概是这样:

function reactive<T extends object>(target: T): UnwrapNestedRefs<T>

注意这里的约束T extends object,也就是说reactive根本无法接收原始类型。你写reactive(1)会直接报错——不仅是TypeScript类型检查不通过,运行时也会给出警告。

所以,这两个API从出生的那一天起就已经划好了分工:reactive负责“对象世界”,ref负责“原始值世界”,但ref通过value外壳,把原始值也拉进了能被代理的范畴。从这个角度理解,两者不是对立关系,而是一种互补。

1.4 一个容易理解的生活类比

想象Vue3是一个快递驿站,一个包裹就是一份数据。Proxy是扫描仪,能扫描快递盒里的东西。问题是,一张纸片(原始值)不是快递盒,没法直接扫描。于是ref就做了个空盒子(对象),把纸片放进去,再扫描这个盒子。而reactive本身就是一个大快递盒,里面可以装各种小盒子,扫描仪可以直接对整个盒子进行扫描。

这个类比也解释了为什么reactive对数组、Map、Set这类内置对象都能生效,因为它们本质上都是对象。而你如果需要响应式的数字、字符串、布尔值,没有盒子可不行,只能靠ref。

2. 实战用法对比:TypeScript环境下的定义、访问与类型差异

2.1 定义和赋值:.value的困扰从哪来

先看最基础的代码:

import { ref, reactive } from 'vue' const count = ref(0) const user = reactive({ name: '张三', age: 30 }) const changeCount = () => { count.value++ // 必须加 .value user.age++ // 不需要 .value }

在TypeScript中,count的类型是Ref<number>,所以如果误写成count++,编辑器会直接提示'count' is of type 'number'之类的错误。而user的类型是{ name: string; age: number }(实际上是UnwrapNestedRefs包裹之后的类型,但结构保持原样),因此访问属性不需要.value。

很多开始用组合式API的人会觉得这两种写法不一致很难受,但换个角度看,这也让代码的意图变成了显式的:如果一个值带着.value,说明它是一个响应式引用;如果不带,说明你正在访问响应式对象的属性。

赋值时还有一个小细节:ref可以整体替换,比如count.value = 10,而reactive返回的代理对象不能直接重新赋值为一个新对象,否则就丢失了响应式。后面第3部分会详细说这个坑。

2.2 类型标注:Ref 与UnwrapNestedRefs

在TypeScript里写组合式函数时,类型推断其实已经做得很好了。但有些场景需要显式声明类型,这时候两者就体现出差异。

对于ref,你可以明确的标注一个ref类型:

import { ref, type Ref } from 'vue' function useCounter(): Ref<number> { return ref(0) }

如果你不写类型,TypeScript也会自动推断为Ref<number>,所以很多时候不需要手动标注。不过我发现一个常被忽略的点:当ref的初始值不是确定的类型时,最好显式给泛型。比如:

const count = ref<number | null>(null)

如果初始值是null,不写泛型会被推断成Ref<null>,后面赋值数字就会报错。

对于reactive,返回值类型是UnwrapNestedRefs<T>,这是一个工具类型,简单说就是把嵌套在reactive中的ref全部“解开”。比如:

const form = reactive<{ name: string; age?: number }>({ name: '李四' })

这里显式声明了泛型,方便后续对可选属性做判断。如果不声明,TypeScript会从初始对象自动推断完整结构。但要注意,reactive的type参数必须是对象类型,写reactive<number>(1)会直接类型报错。

2.3 模板中的自动解包:不等于所有地方都能解包

在template里,ref会自动解包,这是Vue3的一个糖。比如:

<template> <p>{{ count }}</p> </template> <script setup lang="ts"> const count = ref(0) </script>

运行时template会被编译成count.value,所以你不需要写count.value。但这里有个隐藏很深的坑:自动解包只对“顶层property”生效。什么意思?如果你把一个ref塞进了reactive对象里,模板里访问这个reactive对象的属性时,会优先取到ref内部解开后的值;而如果你把ref塞进一个普通对象(非reactive、非ref),那么模板访问该对象的那个属性时,不会自动解包。

举个例子:

<script setup lang="ts"> import { ref } from 'vue' const wrap = { count: ref(0) } </script> <template> <p>{{ wrap.count }} <!-- 显示的是 RefImpl 对象,不是数字 --></p> </template>

这种情况是因为自动解包机制只发生在ref作为顶层变量,或者作为reactive对象属性时。当你把ref放在一个普通对象里,模板编译器不会去做额外的.value解析。我自己第一次遇到的时候,页面上显示[object Object],排查了好一会儿才想起来这个规则。解决办法是给普通对象里的ref手动加.value,或者干脆把这个普通对象改成reactive对象。

2.4 数组、Map、Set:reactive的强项还是坑项?

很多人习惯用ref([])定义数组,这样做完全可行。当你执行ref([])时,返回的value是一个响应式数组,通过arr.value.push(...)操作是响应式的。而reactive([])也能用,但不推荐作为全局状态,因为它更容易踩到“整个替换”的坑。

先看数组替换的问题:

const list = reactive<string[]>(['a']) list = ['b'] // 编译报错:无法分配给 'list',因为它不是可变的

因为reactive返回的是代理对象,你如果直接给list赋一个新数组,等于把整个代理对象换成普通数组,响应式就没了。要修改只能用list.splice(0, list.length, 'b')或者list.length = 0; list.push('b'),十分别扭。

而用ref定义数组,替换就非常自然:

const list = ref<string[]>(['a']) list.value = ['b'] // 完美,因为list本身是个Ref,value替换了内部数组,响应式依然保留

所以如果你需要频繁整体替换一个数组或对象,用ref更加顺手。Map和Set同理,ref(new Map())比reactive(new Map())更容易控制。

3. 团队开发中最容易翻车的几个响应式场景

3.1 解构reactive对象导致响应式丢失

这是Vue3新手最容易犯的错误,没有之一。假设有一个响应式对象:

const user = reactive({ name: '张三', age: 30 })

如果你想在setup中取出name和age单独使用:

const { name, age } = user

恭喜,你现在拿到的name和age是两个普通字符串/数字,改动它们不会影响user,也不会触发视图更新。因为解构操作本质上是把属性值复制给了新变量,而user.name在那一刻是普通字符串,不是响应式引用。

正确的做法有几种。如果你是为了在模板里直接使用,用user.name即可,不要解构。如果你是希望取出独立变量且保持响应式,就用toRefs:

import { toRefs } from 'vue' const { name, age } = toRefs(user)

此时name和age都是Ref<string>和Ref<number>,在script里要使用.value。如果你只希望某一个属性独立响应,可以用toRef:

const name = toRef(user, 'name')

toRef会返回一个ref,它和源对象属性保持同步——修改name.value会改到user.name,反之亦然。这个方法在处理props解构时特别有用,后面第5章会用到。

3.2 reactive里嵌套ref的自动解包陷阱

reactive有一个特性:当你把一个ref赋值给reactive对象的某个属性时,这个ref会被自动解包,让你不必写.value。例如:

const loading = ref(false) const state = reactive({ loading }) state.loading = true // 等价于 loading.value = true

这看起来很方便,但会让调试变难。因为很多人在修改state.loading时,并不知道自己其实在改另一个ref。更麻烦的是,如果整个替换state里引用ref的那个属性,原ref的关联就断了:

const loadingRef = ref(false) const state = reactive({ loading: loadingRef }) state.loading = true // loadingRef.value 变成了 true,没问题 state.loading = false // 注意:这里直接把属性从ref换成布尔值,不再绑定loadingRef console.log(loadingRef.value) // 仍然是 true

这种隐式解包在特定场景下会引发难以追踪的bug。我的建议是:不要把ref硬塞进reactive里去“省事”。要么全部用reactive管理对象,内部属性直接是原始值;要么整个状态用ref来管理,分离清楚。混合使用看起来灵活,但维护成本极高。

3.3 整个替换reactive对象导致“失效”

前面提到过reactive对象的重新赋值问题。假设你在一个列表页里,希望点击按钮后把整个列表数据替换成后端返回的新数组:

const list = reactive<Item[]>([]) const fetchData = async () => { const data = await api.getList() list = data // 错误! }

list本身是常量代理引用,不能这样赋。我见过同事改成:

list.length = 0 list.push(...data)

这样虽然可行,但代码很不优雅,而且如果data是很大一个数组,展开操作开销也不小。更合理的方案是干脆把list定义为ref<Item[]>([]),然后直接list.value = data。这也是我在实际项目中越来越倾向于使用ref的原因之一——现代前端开发里“整个替换状态”的场景太多了,reactive在“局部修改”时很好用,但在“整体替换”时显得僵硬。

3.4 把一个reactive对象直接传进子组件props

当你把一个reactive对象作为props传给子组件时,子组件拿到的是同一个代理对象,但Vue的props应该是单向数据流,子组件不应该直接修改props。很多团队为了图方便,在父组件里const state = reactive({...}),然后<Child :state="state" />,子组件里直接props.state.xxx = ...。这在Vue3中虽然能触发更新,但违反了单项数据流原则,而且由于TypeScript的props类型不好写,还容易造成命名冲突。

我的建议是:如果要传对象给子组件,正常用ref定义然后用.value传递,或者用reactive定义但只传递其中的几个字段。在子组件中尽量通过事件机制去修改,或者让父组件提供一个修改函数。这样做代码清晰,也不会踩到响应式代理的深水区。

4. 我的选择标准:什么时候无脑用ref,什么时候必须用reactive

4.1 常见说法“基础类型用ref,对象用reactive”为什么不够用

很多教程会告诉你:基本类型(number、string、boolean)用ref,对象、数组用reactive。这句话在简单demo里看着没毛病,但进入真实项目就会发现不够。因为一个业务状态往往不是纯对象也不是纯基础值。比如一个表单,既有name、age这种基础值,也有hobbies数组、address嵌套对象。如果你用reactive定义一整个form,后续给某个字段赋值时,写着确实方便,但如果要整体重置表单,就需要走Object.assign(form, defaultValue)之类的操作:

const form = reactive<LoginForm>({ username: '', password: '' }) const resetForm = () => { Object.assign(form, { username: '', password: '' }) }

Object.assign还能用,但如果嵌套了好几层,重置起来简直折磨。而如果用ref定义form:

const form = ref<LoginForm>(defaultForm) const resetForm = () => { form.value = { ...defaultForm } }

舒服太多了。所以我的基础判断不是按类型分,而是按“是否需要整体替换”来分。

4.2 我的个人偏好:组合式函数里一律用ref

最近一两年,我在自己负责的项目里基本形成了一个规范:除了非常明确的是局部状态且不会整体替换的情况,其他地方一律用ref来定义响应式状态。原因有三个:

第一,ref的类型语义非常明确,Ref<T>能直接看出来这是一个响应式引用,而reactive返回的类型和原始对象长得一样,容易让人忘记它是代理。

第二,ref在传递、解构、替换时都可以用.value直接操作,不会像reactive那样因为整体替换或解构操作丢失响应式。虽然多写几个.value,但换来的是心智负担的下降。

第三,在写组合式函数时,返回一个ref还是返回reactive对象,对调用者的体验完全不同。比如我写一个useCountdown(),如果返回的是{ seconds, start, stop },其中seconds是ref,调用方可以放心解构,因为解构出来的还是ref,响应式不会丢。而如果返回的是一个reactive对象,调用方解构后就等于拆掉了响应式,容易踩坑。

4.3 必须用reactive的场景:多层嵌套数据的局部变更

既然这么推荐ref,那reactive是不是可以不用了?也不是。有一些场景用reactive更顺手,比如处理大型嵌套数据结构时,频繁操作深层属性,每次都用someRef.value.a.b.c.d += 1会很烦,而reactive的方式是state.a.b.c.d += 1,视觉上轻巧很多。

另外,如果你有一个Map<string, SomeObject>,并且希望整个Map对象不换、只改其中某个key对应的value,用reactive(new Map())是合法的,但ref(new Map())也可以。这里不是必须。

我必须提一个场景:当你使用provide/inject跨层级共享状态时,reactive有一个优势——注入到子组件中的对象会自动保留响应式,而且子组件无需关心.value。如果你用ref提供,子组件使用时要写成count.value,也还好。但如果你共享的是一个包含多个字段的对象,用reactive来统一管理更直观。

type UserStore = { name: string age: number } const store = reactive<UserStore>({ name: '张三', age: 30 }) provide('userStore', store)

这样无论是父组件还是孙组件,都能通过inject拿到同一个响应式对象,直接改字段。总体而言,我的标准是:

  • 单个基础值、数组、需要整体替换的复杂对象:用ref。
  • 大型嵌套对象、频繁修改深层单个字段、作为全局状态store共享:用reactive。
  • 原来在Vue2中习惯把data写成一个对象的地方:如果你的业务逻辑不涉及整体替换,用reactive会让你感觉和Vue2的data很像。

4.4 性能层面:两者有差别吗

性能上,Vue3官方的响应式系统在初始化时会对reactive对象进行递归代理,而ref对象如果是基础类型,只代理一个value,初始化成本更小。但如果ref接收一个大型对象,内部也会调用reactive,所以性能差异并不取决于你用的是ref还是reactive,而取决于你传入的数据结构嵌套深度。

在实际渲染更新方面,Vue的依赖追踪粒度已经足够细,ref和reactive在绝大多数页面里性能差异可以忽略。真正的性能瓶颈往往在别处,比如过度使用深层响应式、或者把大数组渲染到界面上没有用v-memo。所以选型时不用太纠结性能,更多考虑维护和类型友好即可。不过在多个组合式函数之间传递状态时,ref因为总是返回引用,存储开销反而更可预测。

5. 收尾:一个完整的TypeScript+Vue3搜索表单Demo

理论说再多,不如一个完整例子来收尾。我用一个常见的列表搜索页来说明,状态用ref和reactive混合管理的思路,同时展示TypeScript如何贯穿整个过程。

5.1 定义类型与状态

假设有一个用户搜索页面,需要输入关键词、选择状态、然后请求列表。先定义类型:

interface SearchParams { keyword: string status: 'active' | 'inactive' | '' page: number pageSize: number } interface UserItem { id: number name: string status: 'active' | 'inactive' }

在这个页面里,searchParams是一个经常会被整体修改的对象(比如点击“重置”按钮),所以我会优先用ref:

import { ref, reactive, watch, type Ref } from 'vue' const searchParams = ref<SearchParams>({ keyword: '', status: '', page: 1, pageSize: 20 }) const userList = ref<UserItem[]>([]) const loading = ref(false)

这里你会发现,我把对象类型一样用ref包了起来。好处是重置表单只需要:

const resetForm = () => { searchParams.value = { keyword: '', status: '', page: 1, pageSize: 20 } }

如果这里是reactive,我可能要用Object.assign,代码丑,还容易漏字段。

5.2 搜索、防抖与watch的使用

在组合式API里,我一般用watch去监听搜索参数的变化,然后触发请求。TypeScript会帮我们推导出searchParams的类型是Ref<SearchParams>,所以在watch里拿到的newVal就是SearchParams:

watch(searchParams, async (newParams) => { loading.value = true try { const { data } = await fetchUserList(newParams) userList.value = data } finally { loading.value = false } }, { deep: true })

不知道为什么,很多人一听到watch就默认要写deep: true。这里因为searchParams是一个ref,它内部的value是一个对象,Vue默认会监听ref.value的引用变化。如果只改了searchParams.value.keyword,引用没变,就触发不了。所以我这里显式加了deep: true。如果你不希望任何一次输入都触发请求,可以给watch加上延时,或者用watchDebounced这类工具函数。

5.3 模板中的展示与toRefs的妙用

模板里有一个独立的筛选状态,这里我想展示一下reactive + toRefs的配合。假设页面上还有一个“当前选中的用户”弹窗状态:

const selectedUser = ref<UserItem | null>(null)

这个用reactive就反而别扭,因为选中的用户经常被整体赋值或置空,用ref更自然。

另一个比较典型的场景是:表单里有单独的搜索框<input v-model="keyword">,这里的keyword其实是searchParams.value.keyword。模板里可以直接用v-model="searchParams.keyword",因为Vue的模板编译器会自动解包ref,这个是顶层ref属性,没问题。

但是如果你在script里写一个组合式函数返回多个ref,调用方想要解构使用时,直接const { keyword, status } = useSearch()是可以的,因为解构出来的还是ref。不过如果useSearch内部是reactive对象,解构就会丢响应式。所以我在实现useSearch时,统一返回ref:

function useSearch() { const searchParams = ref<SearchParams>({ ... }) const keyword = computed({ get: () => searchParams.value.keyword, set: (val: string) => { searchParams.value.keyword = val } }) return { keyword, searchParams, userList, loading } }

调用方可以自由解构,不会有响应式丢失的问题。

5.4 从Demo回头看选择标准

在这个Demo里,我用ref管理了几乎所有状态:searchParams、userList、loading、selectedUser。代码的可读性比用reactive高,因为每个变量都能看出它是一个响应式引用,.value提醒我“这种修改是有代价的”。

而如果我需要的是一个大型的、结构稳定的配置对象,举例来说,一个应用级的菜单权限配置,包含100多个字段,而且只会在初始化时设置一次,之后只有少数几个深层字段会变化,这时候我就会改用reactive:

const appConfig = reactive<AppConfig>({ theme: 'dark', permissions: [...], footer: {...} })

因为这种状态几乎不会整体替换,reactive的直接访问方式对于深层属性的读写更舒服。

这套选择标准不是什么官方规范,而是我这几年在项目中经过反复试错形成的个人偏好。如果你的团队已经有约定,请优先遵守团队的规范。如果还没有,我强烈建议在项目早期定下简单的一条规则:没有特殊理由,一律用ref;确实需要处理复杂嵌套对象且不会整体替换时,用reactive,并且注释里写清楚为什么不用ref。这样团队里代码风格统一,review起来也轻松很多。

最后再分享一个调试小技巧:当你发现视图不更新时,先别急着怀疑性能,打开Vue Devtools,看看目标数据在Setup面板中显示的类型。如果它是一个RefImpl,你要检查是不是没写.value;如果它显示的是Proxy,那你正在操作的可能是一个reactive代理,要检查是不是整体替换或解构导致的问题。这些细节,往往比换一个API更能解决问题。

返回列表