尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

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

发布时间:2026/9/29 17:13:13

资讯中心
01
ARTICLE

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

Vue3 + TypeScript 中 ref 与 reactive 的选择指南
接触Vue3 TypeScript也有一段时间了团队里每次有新人进来几乎都会问我同一个问题ref和reactive到底什么时候用哪个说实话这个困惑我一开始也有因为API文档里讲了自动解包、嵌套响应式这些概念但真正写业务的时候还是会犹豫。有人说基础类型用ref、对象用reactive可实际项目里一个对象里既有字符串又有数组甚至还有嵌套对象这个规则根本不够用。如果理解了这两个API在Proxy之上各自做了什么选择就不再靠死记而是变成很自然的判断。这篇文章我会从Vue3响应式原理的边界讲起然后对比在TypeScript环境下两者的类型差异、访问方式、常见坑位最后结合一个搜索表单的实战Demo给出我踩过坑之后总结出来的选择标准。希望能帮正在从Options API过渡到组合式API或者刚上手Vue3TS的朋友少走弯路。1. 从源头说起为什么Vue3需要ref和reactive两套响应式API1.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里这个类型是Refnumber它内部大致是这样interface RefT { 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 reactiveT extends object(target: T): UnwrapNestedRefsT注意这里的约束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的类型是Refnumber所以如果误写成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(): Refnumber { return ref(0) }如果你不写类型TypeScript也会自动推断为Refnumber所以很多时候不需要手动标注。不过我发现一个常被忽略的点当ref的初始值不是确定的类型时最好显式给泛型。比如const count refnumber | null(null)如果初始值是null不写泛型会被推断成Refnull后面赋值数字就会报错。对于reactive返回值类型是UnwrapNestedRefsT这是一个工具类型简单说就是把嵌套在reactive中的ref全部“解开”。比如const form reactive{ name: string; age?: number }({ name: 李四 })这里显式声明了泛型方便后续对可选属性做判断。如果不声明TypeScript会从初始对象自动推断完整结构。但要注意reactive的type参数必须是对象类型写reactivenumber(1)会直接类型报错。2.3 模板中的自动解包不等于所有地方都能解包在template里ref会自动解包这是Vue3的一个糖。比如template p{{ count }}/p /template script setup langts const count ref(0) /script运行时template会被编译成count.value所以你不需要写count.value。但这里有个隐藏很深的坑自动解包只对“顶层property”生效。什么意思如果你把一个ref塞进了reactive对象里模板里访问这个reactive对象的属性时会优先取到ref内部解开后的值而如果你把ref塞进一个普通对象非reactive、非ref那么模板访问该对象的那个属性时不会自动解包。举个例子script setup langts 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、Setreactive的强项还是坑项很多人习惯用ref([])定义数组这样做完全可行。当你执行ref([])时返回的value是一个响应式数组通过arr.value.push(...)操作是响应式的。而reactive([])也能用但不推荐作为全局状态因为它更容易踩到“整个替换”的坑。先看数组替换的问题const list reactivestring[]([a]) list [b] // 编译报错无法分配给 list因为它不是可变的因为reactive返回的是代理对象你如果直接给list赋一个新数组等于把整个代理对象换成普通数组响应式就没了。要修改只能用list.splice(0, list.length, b)或者list.length 0; list.push(b)十分别扭。而用ref定义数组替换就非常自然const list refstring[]([a]) list.value [b] // 完美因为list本身是个Refvalue替换了内部数组响应式依然保留所以如果你需要频繁整体替换一个数组或对象用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即可不要解构。如果你是希望取出独立变量且保持响应式就用toRefsimport { toRefs } from vue const { name, age } toRefs(user)此时name和age都是Refstring和Refnumber在script里要使用.value。如果你只希望某一个属性独立响应可以用toRefconst 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 reactiveItem[]([]) const fetchData async () { const data await api.getList() list data // 错误 }list本身是常量代理引用不能这样赋。我见过同事改成list.length 0 list.push(...data)这样虽然可行但代码很不优雅而且如果data是很大一个数组展开操作开销也不小。更合理的方案是干脆把list定义为refItem[]([])然后直接list.value data。这也是我在实际项目中越来越倾向于使用ref的原因之一——现代前端开发里“整个替换状态”的场景太多了reactive在“局部修改”时很好用但在“整体替换”时显得僵硬。3.4 把一个reactive对象直接传进子组件props当你把一个reactive对象作为props传给子组件时子组件拿到的是同一个代理对象但Vue的props应该是单向数据流子组件不应该直接修改props。很多团队为了图方便在父组件里const state reactive({...})然后Child :statestate /子组件里直接props.state.xxx ...。这在Vue3中虽然能触发更新但违反了单项数据流原则而且由于TypeScript的props类型不好写还容易造成命名冲突。我的建议是如果要传对象给子组件正常用ref定义然后用.value传递或者用reactive定义但只传递其中的几个字段。在子组件中尽量通过事件机制去修改或者让父组件提供一个修改函数。这样做代码清晰也不会踩到响应式代理的深水区。4. 我的选择标准什么时候无脑用ref什么时候必须用reactive4.1 常见说法“基础类型用ref对象用reactive”为什么不够用很多教程会告诉你基本类型number、string、boolean用ref对象、数组用reactive。这句话在简单demo里看着没毛病但进入真实项目就会发现不够。因为一个业务状态往往不是纯对象也不是纯基础值。比如一个表单既有name、age这种基础值也有hobbies数组、address嵌套对象。如果你用reactive定义一整个form后续给某个字段赋值时写着确实方便但如果要整体重置表单就需要走Object.assign(form, defaultValue)之类的操作const form reactiveLoginForm({ username: , password: }) const resetForm () { Object.assign(form, { username: , password: }) }Object.assign还能用但如果嵌套了好几层重置起来简直折磨。而如果用ref定义formconst form refLoginForm(defaultForm) const resetForm () { form.value { ...defaultForm } }舒服太多了。所以我的基础判断不是按类型分而是按“是否需要整体替换”来分。4.2 我的个人偏好组合式函数里一律用ref最近一两年我在自己负责的项目里基本形成了一个规范除了非常明确的是局部状态且不会整体替换的情况其他地方一律用ref来定义响应式状态。原因有三个第一ref的类型语义非常明确RefT能直接看出来这是一个响应式引用而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视觉上轻巧很多。另外如果你有一个Mapstring, 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 reactiveUserStore({ 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. 收尾一个完整的TypeScriptVue3搜索表单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是一个经常会被整体修改的对象比如点击“重置”按钮所以我会优先用refimport { ref, reactive, watch, type Ref } from vue const searchParams refSearchParams({ keyword: , status: , page: 1, pageSize: 20 }) const userList refUserItem[]([]) 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的类型是RefSearchParams所以在watch里拿到的newVal就是SearchParamswatch(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 refUserItem | null(null)这个用reactive就反而别扭因为选中的用户经常被整体赋值或置空用ref更自然。另一个比较典型的场景是表单里有单独的搜索框input v-modelkeyword这里的keyword其实是searchParams.value.keyword。模板里可以直接用v-modelsearchParams.keyword因为Vue的模板编译器会自动解包ref这个是顶层ref属性没问题。但是如果你在script里写一个组合式函数返回多个ref调用方想要解构使用时直接const { keyword, status } useSearch()是可以的因为解构出来的还是ref。不过如果useSearch内部是reactive对象解构就会丢响应式。所以我在实现useSearch时统一返回reffunction useSearch() { const searchParams refSearchParams({ ... }) 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多个字段而且只会在初始化时设置一次之后只有少数几个深层字段会变化这时候我就会改用reactiveconst appConfig reactiveAppConfig({ theme: dark, permissions: [...], footer: {...} })因为这种状态几乎不会整体替换reactive的直接访问方式对于深层属性的读写更舒服。这套选择标准不是什么官方规范而是我这几年在项目中经过反复试错形成的个人偏好。如果你的团队已经有约定请优先遵守团队的规范。如果还没有我强烈建议在项目早期定下简单的一条规则没有特殊理由一律用ref确实需要处理复杂嵌套对象且不会整体替换时用reactive并且注释里写清楚为什么不用ref。这样团队里代码风格统一review起来也轻松很多。最后再分享一个调试小技巧当你发现视图不更新时先别急着怀疑性能打开Vue Devtools看看目标数据在Setup面板中显示的类型。如果它是一个RefImpl你要检查是不是没写.value如果它显示的是Proxy那你正在操作的可能是一个reactive代理要检查是不是整体替换或解构导致的问题。这些细节往往比换一个API更能解决问题。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。