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

Vue 3 响应式核心:ref 与 reactive 的底层原理与实战选择

发布时间:2026/9/24 22:56:29

资讯中心
01
ARTICLE

Vue 3 响应式核心:ref 与 reactive 的底层原理与实战选择

Vue 3 响应式核心:ref 与 reactive 的底层原理与实战选择
说实话Vue 3 出来这么久了网上讲 ref 和 reactive 的教程一抓一大把但大多数都是念文档式的罗列。我刚从 Vue 2 项目组切过来那阵子也经常纠结到底什么时候用 ref什么时候用 reactive为什么表单数据用 reactive 好好的换个场景就失灵了代码里 .value 到底该不该加这些疑问如果你也有那这篇文章应该能帮到你。我会从响应式原理讲起把 ref 和 reactive 的底层差异、实战用法、TypeScript 类型配合还有我踩过的坑全部拆开揉碎讲清楚。不整虚的全是可以直接抄进代码里的经验。1. 响应式原理先看底层才不会被表象骗了1.1 从 Vue 2 到 Vue 3为什么要重写响应式系统聊 ref 和 reactive 之前得先搞清楚它们背后的响应式机制。Vue 2 时代的响应式是Object.defineProperty做的对就是那个需要提前声明好 key、遍历对象所有属性、对数组还要单独 hack 的 API。它有两个特别烦人的缺陷新增属性不会触发更新直接按下标改数组也不会触发更新。很多 Vue 2 老开发应该都记得“用 this.$set 才能让页面刷新”这种羞耻操作。Vue 3 把整个响应式系统换成了Proxy。Proxy 不是去定义某个对象的某个属性而是直接代理整个对象你对对象做的任何操作——读取属性、修改属性、删除属性、甚至in操作符——都能被拦截到。这样一来新增属性、删除属性、数组索引操作这些原来做不到的响应式行为全都能原生支持了。ref 和 reactive 就是基于 Proxy 构建的两套对外 API。它们只是使用方式的差异底层的响应式追踪机制是一套。1.2 ref 和 reactive 在底层存储上的本质区别很多人把 ref 和 reactive 理解成“一个管基本类型一个管对象类型”这个说法对但不够本质。真正的关键区别在底层数据结构和响应式实现方式。reactive 接收一个对象直接对这个对象做 Proxy 代理。也就是说你传入的原始对象会被转换成响应式代理对象访问state.name时实际上是访问 Proxy 的 get 陷阱修改时走 set 陷阱依赖收集和派发更新都发生在这个代理层上。ref 就不太一样。ref 接收任意类型的值如果传的是对象内部会立刻丢给 reactive 处理如果传的是基本类型比如数字、字符串、布尔值那 Proxy 就无能为力了——Proxy 只能代理对象没法代理一个数字。所以 ref 内部是搞了一个包装对象// 伪代码帮理解用 class RefImplT { private _value: T public get value() { track(this, value) return this._value } public set value(newVal: T) { this._value newVal trigger(this, value) } }也就是说ref 把基本类型值塞进了一个带有 getter/setter 的类实例里访问.value时触发 get 做依赖收集修改.value时触发 set 做派发更新。这就是为什么 script 里用 ref 必须.value而 reactive 不需要——因为 ref 的本质是一个包装对象这个对象只有一个响应式属性value。这个小节看懂之后后面所有困扰你的问题基本都能自己推导出答案。2. 基础用法拆解ref 和 reactive 的各自边界2.1 ref简单、直接、什么都敢存先说结论ref 是我现在默认的选择没有之一。它最核心的优势是接得住所有类型基本类型、对象、数组、Map、Set随便丢给它。import { ref } from vue // 基本类型 const count ref(0) const name refstring(Vue 3) // 对象 const user ref({ name: 张三, age: 30 }) // 数组 const list refstring[]([a, b, c]) // 修改 count.value user.value.name 李四 list.value.push(d)注意上面这段代码user.value.name 李四是响应式的。因为 ref 内部帮我把对象转成了 reactive 代理修改深层属性完全没问题。数组的push也能正常触发更新因为响应式已经交给了 Proxy 处理。我在实际项目里习惯把 ref 当成“一个自带响应式状态的盒子”。组件里乱七八糟的变量只要是会变的就用 ref 包一层。这样做的好处是代码风格高度统一不会出现一会儿.value一会儿没.value的精神分裂。2.2 reactive对象专属别拿来包单个值reactive 的边界非常清晰它只能接收对象类型包括普通对象、数组、Map、Set。你要是把基本类型丢给它返回的结果直接无法触发响应式更新import { reactive } from vue const state reactive(0) // 警告value cannot be made reactive: 0 state // 页面不会更新因为 state 就是个死数字这是很多人刚上手时掉进去的第一个坑。reactive 的参数必须是可被 Proxy 包装的类型基本类型装不进去。而如果你给 reactive 传一个已有的 reactive 对象它不会重复代理而是直接返回同一个代理对象。const raw { count: 0 } const state1 reactive(raw) const state2 reactive(state1) // state1 state2同一个代理 console.log(state1 state2) // truereactive 的使用场景通常是一个集中的、结构固定的状态对象import { reactive } from vue const state reactive({ userInfo: null as null | { name: string; age: number }, token: , roles: [] as string[] }) state.userInfo { name: 张三, age: 30 } state.roles.push(admin)从代码直观度来看reactive 的写法确实更“顺滑”——不用到处.value。但这种顺滑是有代价的后面我会在踩坑部分详细展开。2.3 ref 和 reactive 的一个隐藏关系ref 内部会调用 reactive前面提过ref 传入对象时内部会走 reactive。这里有个实际影响你给 ref 传一个已经被 reactive 处理过的对象ref 不会重复包装而是直接复用现有的代理。const raw { count: 0 } const reactiveState reactive(raw) const refState ref(reactiveState) // refState.value reactiveState console.log(refState.value reactiveState) // true refState.value.count // 响应式正常利用这一点可以把一个巨型状态对象先用 reactive 封装好再丢给 ref 挂到组件上。但说实话日常开发中没必要这么绕直接二选一就行。3. 实战场景同一业务两种写法的差异对比3.1 表单场景reactive 顺手但 ref 更抗造表单是前端开发里最高频的场景。我先给一个 reactive 版本的经典写法script setup langts import { reactive, onMounted } from vue interface LoginForm { username: string password: string remember: boolean } // reactive 版本 const form reactiveLoginForm({ username: admin, password: , remember: false }) function submit() { console.log(form.username, form.password, form.remember) } /script template input v-modelform.username placeholder用户名 / input v-modelform.password typepassword placeholder密码 / input v-modelform.remember typecheckbox / button clicksubmit提交/button /template这个写法在模板里不用.value非常清爽是 reactive 最典型的适用场景。但是问题来了。如果你需要做表单重置比如用户点“重置”按钮清空所有字段reactive 版本会写得很别扭// reactive 重置 function reset() { form.username form.password form.remember false }字段少还行字段多了呢十几个字段挨个赋空值有人说可以用Object.assign一次性覆盖function reset() { Object.assign(form, { username: , password: , remember: false }) }这个方案能跑但你要记得维护一份默认值结构而且如果你在 reset 之后又新增了一个字段忘了同步重置就会漏掉它。换成 ref 版本配合函数返回默认值重置就非常优雅const defaultForm (): LoginForm ({ username: , password: , remember: false }) const form refLoginForm(defaultForm()) function reset() { // 直接整个替换 value form.value defaultForm() }ref 允许你整体替换.value指向新对象这是它碾压 reactive 的关键优势之一。reactive 不能整体替换因为代理对象一旦创建就固定了你给state newObj根本不是一个响应式操作甚至会让页面失去更新能力。3.2 列表加载场景ref 的绝对主场列表页、表格页是后台管理系统的核心场景。接口返回数据 → 赋值给响应式变量 → 渲染表格。这种情况我用 ref 用得最多import { ref, onMounted } from vue interface ArticleItem { id: number title: string author: string publishTime: string } const loading ref(false) const list refArticleItem[]([]) const total ref(0) const currentPage ref(1) const pageSize ref(10) async function fetchList() { loading.value true try { const res await fetch(/api/articles?page${currentPage.value}pageSize${pageSize.value}) const data await res.json() list.value data.list total.value data.total } finally { loading.value false } } onMounted(fetchList)这段代码里我同时用了三个 refloading、list、total。为什么不用 reactive 包一个大对象原因有两个。第一reactive 不能整体替换。如果我用 reactive 包{ loading, list, total }接口返回后我想一次性把三个值更新掉写state.list data.list可以但state { ...state, ...data }不行。第二个更实际的问题是可复用性。列表页的逻辑往往要抽成 composable// usePagination.ts import { ref } from vue export function usePagination(apiFn: (params: any) Promiseany) { const loading ref(false) const list ref([]) const total ref(0) const currentPage ref(1) async function fetchData() { loading.value true try { const data await apiFn({ page: currentPage.value, pageSize: 10 }) list.value data.list total.value data.total } finally { loading.value false } } return { loading, list, total, currentPage, fetchData } }ref 的每个变量都是独立状态源解构返回后互不干扰天然适合组合式抽离。reactive 用这种方式拆就麻烦得多——你解构出来的list是普通数组已经完全失去响应式能力了。3.3 JSX / TSX 场景ref 的.value反而更明确如果你在写 Vue 3 TSX比如封装高阶组件、函数式组件模板里的自动解包就失效了。TSX 渲染函数里访问 ref 必须显式.value这时 ref 的语义反而更清晰——一眼就能看出哪个是响应式数据import { defineComponent, ref } from vue export default defineComponent({ setup() { const count ref(0) return () ( button onClick{() count.value} count is: {count.value} /button ) } })reactive 在 TSX 里反而不如 ref 直观因为 reactive 对象在模板和 TSX 里访问方式完全一样你分不清state.count到底是普通对象还是响应式对象。我在团队代码规范里明确了写 TSX 一律用 ref。4. TypeScript 类型集成让类型推导帮你提前找 bug4.1 ref 的泛型推导和显式类型标注ref 的类型推导已经做得非常好了。你写ref(0)TypeScript 自动把它推断成Refnumber写ref(hello)推断成Refstring。大多数场景不用手动标注类型。但有些场景必须显式给泛型比如接口数据是联合类型初始值为 nullimport { ref } from vue interface UserInfo { id: number name: string email: string } // 不写泛型userInfo 会被推成 Refnull // 后面访问 userInfo.value.name 会直接 TS 报错 const userInfo refUserInfo | null(null) // 请求成功后赋值 userInfo.value await fetchUserInfo() console.log(userInfo.value.name) // 这里 TS 能提示你 userInfo.value 可能为 null上面这段代码的体验非常重要。因为.value是显式的TypeScript 能准确追踪到userInfo.value联合类型收窄的过程赋值之前是null赋值之后可以安全访问。如果你用条件判断对它做了收窄编辑器会立刻提示类型错误。这种开发体验是 Vue 2 JS 时代想都不敢想的。数组场景的泛型标注同样重要// 这样写是 string[]正常 const tags refstring[]([]) // 页面渲染前先 push 一个默认项 tags.value.push(默认标签)还有一种常见用法是给 ref 传一个指定类型但可能是 undefined 的初始值const element refHTMLElement | null(null) // 在 onMounted 里赋值 onMounted(() { element.value document.getElementById(app) })4.2 reactive 的类型标注接口 可选属性时容易掉的坑reactive 的泛型用法同样有讲究。一个常见错误是定义带可选属性的接口时初始值没有补全导致 TS 报错import { reactive } from vue interface FormState { name: string age?: number address?: string } // 错误写法age 和 address 是可选属性且没给初始值 const state reactiveFormState({ name: 张三 }) // 这种其实不会报错因为 age 和 address 可选 // 但访问时要注意空值判断 if (state.age ! undefined) { console.log(state.age.toFixed(0)) }真正容易踩的坑是reactive 传给子组件或从 composable 返回时类型会丢失响应式标记。在 Vue 3.5 之前官方对reactive返回的UnwrapNestedRefs类型处理不够直观现在版本用ToRefs工具类型可以很好解决import { reactive, toRefs } from vue function useCounter() { const state reactive({ count: 0, step: 1 }) // 返回 toRefs 包装解构出来的还是 ref响应式不丢 return { ...toRefs(state) } } const { count, step } useCounter() count.value // 能更新因为 count 是 Refnumber这里要重点记一个结论从 reactive 对象直接解构出来的属性是普通类型不是 ref 也不是响应式对象修改它不会触发视图更新。想保持响应式必须用toRefs包一层再解构或者直接返回整个 reactive 对象让调用方通过.xxx访问。4.3 TypeScript 版本选择新教程默认 5.x我开始写 Vue 3 TS 项目的时候TypeScript 4.x 还是主流后来发现很多新特性比如satisfies关键字、更快的编译速度都会影响 Vue 项目的开发体验。当前教程和实战项目建议直接用 TypeScript 5.x。如果你在 Vite 里创建项目脚手架自带的版本通常就是最新的稳定版。一个小提醒有些旧项目升级 TypeScript 5.x 后vue-tsc版本太旧可能会报类型不兼容的错误。我一般建议vue-tsc保持和 Vue 官方版本同步升级别一直锁死在 1.8 这种老版本上。另外你在 tsconfig 里可能见过这样的警告比如 option baseurl has been deprecated and will stop running in TypeScript 7.0 或者 moduleResolutionnode10 has been deprecated 之类。这是因为新版 TypeScript 对老的模块解析策略做了弃用声明。处理方式很简单{ compilerOptions: { moduleResolution: bundler, verbatimModuleSyntax: false } }建议新建的 Vite Vue 项目直接用moduleResolution: bundler这是为打包工具设计的模块解析方式和 Vite 配合最顺。5. 高频踩坑记录面试题和线上 bug 都从这来5.1 解构丢失响应式每个 Vue 3 开发者都中过的招这是 Vue 3 面试最高频的题也是开发中最常见的 bug。原因我在前面已经铺垫过了reactive 对象的属性访问走的是 Proxy 的 get 陷阱响应式绑定在那个代理对象上不在原始值上。当你解构时拿到的是一个普通值它和代理对象之间没有任何联系。import { reactive } from vue const state reactive({ count: 0, message: hello }) // 这样解构count 和 message 都是普通值改它们页面不会变 let { count, message } state // 三行都无效count 和 message 已经是死值 count message world解决方案有两个。一是始终通过state.xxx访问不解构。二是用toRefs把 reactive 对象转换成 ref 引用再解构import { reactive, toRefs } from vue const state reactive({ count: 0, message: hello }) const { count, message } toRefs(state) count.value // 有效 message.value world // 有效ref 对象在解构时没有这个问题因为 ref 的响应式绑定在.value这个引用上解构出来的是RefT对象本身。这也是我全面转向 ref 的最主要原因——从根上避开一类 bug。5.2 整体替换 reactive 对象直接失效这个坑我在实战中踩过好多次。场景一般是表格筛选用户选了条件点查询前端把整个查询参数对象替换成新的。import { reactive } from vue const queryParams reactive({ page: 1, keyword: , status: all }) function search(newParams: any) { // 这样替换没有任何响应式效果queryParams 还是原来的代理对象 queryParams newParams // 正确做法逐个赋值或者用 Object.assign Object.assign(queryParams, newParams) }你可能会困惑为什么 reactive 不能整体替换因为queryParams这个变量名指向的是初始化时创建的 Proxy 实例重新赋值相当于改了变量指向原来的代理对象已经不在变量上了Proxy 对queryParams newParams这种操作是无能为力的——它只能拦截对象内部的变化拦不住变量赋值。解决方案有两个一是Object.assign合并属性二是干脆用 ref直接替换.value。const queryParams ref({ page: 1, keyword: , status: all }) queryParams.value { page: 2, keyword: Vue, status: done }在我负责的项目里我后面几乎是全程 ref 不可变替换的风格因为可预测性太强了任何状态更新都发生在.value赋值上调试时断点打一个地方就行不需要关心对象内部哪个属性被改了。5.3 数组和 Map/Set 的响应式问题先说结论Vue 3 的 reactive 和 ref对象类型底层用 Proxy数组是支持索引赋值和 length 修改的。所以arr[0] x是响应式的arr.length 0也是响应式的。但有一个隐蔽的坑直接替换整个数组仍然要走.value newArray或Object.assign的路线array 内部方法比如splice、push是没问题的。const list refnumber[]([1, 2, 3]) // 这是个隐蔽 bug直接用新数组整体替换如果是在 ref 里其实没问题 list.value [4, 5, 6] // 正常因为替换的是 value // 但如果用了 reactive const reactiveList reactivenumber[]([1, 2, 3]) // 想让页面更新到新数组不能用赋值用 splice 清空后 push reactiveList.splice(0, reactiveList.length, ...newArr)还有一个细节Map 和 Set 在 Vue 3 里也是支持响应式追踪的因为 Proxy 可以拦截get、set、has、deleteProperty等操作。实战中我用得不多但如果你做复杂数据处理知道它是响应式的能省很多心const map reactive(new Mapstring, any()) map.set(key, value) // 响应式 map.get(key) // 依赖追踪5.4 ref 在模板中的自动解包一个让人又爱又恨的设计模板里用 ref 不需要.value这是 Vue 3 的自动解包机制。我第一次写模板代码的时候满脑子都在想这到底是 ref 还是 reactive因为它俩在模板里长得一模一样。script setup langts import { ref, reactive } from vue const count ref(0) const state reactive({ count: 0 }) /script template !-- 两个都是 count但一个是 ref一个是 reactive -- p{{ count }}/p p{{ state.count }}/p /template自动解包有一条重要限制如果 ref 出现在响应式对象的顶层也会被自动解包但如果它嵌套在数组或其他深层属性里就不会自动解包必须.value。这个坑出过很多次const state reactive({ list: [ref(0), ref(1)], obj: { innerRef: ref(99) } }) // 模板里取值 state.list[0].value // 不会自动解包 state.obj.innerRef.value // 不会自动解包说实话我后来转变思路推荐大家主用 ref很大一个原因就是这个自动解包机制本身增加了一次心智负担。你写模板时永远要判断变量是 ref 还是 reactive才能确定.value加不加。团队协作时新人经常因为少写.value出现 bug 找不到原因。统一用 ref 后模板里虽然看起来都一样但 script 里访问必须.value反而规则清晰。5.5 深浅响应式shallowRef 和 shallowReactive 的取舍Vue 3 还提供了浅层响应式 APIshallowRef、shallowReactive。它们的区别在于是否递归代理所有嵌套属性。import { shallowRef } from vue const state shallowRef({ user: { name: 张三 } }) // 深层修改不会触发更新 state.value.user.name 李四 // 页面不更新 // 替换整个 value 才触发更新 state.value { user: { name: 王五 } }浅层响应式适合那些数据量大、每次都是整体替换的场景比如大表格数据源、图编辑器的节点集合逐层代理性能和内存开销都大而且你也用不到深层修改。我第一次把一个大树的 reactive 数据结构改成 shallowRef节点数量超过一万个时交互流畅度有肉眼可见的提升。6. 面试必问ref 和 reactive 那些让人迷惑的题6.1 为什么 ref 定义一个对象时也响应式这个问题面试官特别爱问。答案是ref 底层把对象值交给 reactive 处理。你可以理解为 ref 是一个更宽松的入口它对任何输入都负责地返回一个“能响应式变化”的容器。对基本类型它用 getter/setter 包装对对象类型它委托给 reactive 做 Proxy 代理。所以在核心原理上ref 并不是和 reactive 平级的替代品而是建立在 reactive 基础上、更上层的通用封装。6.2.value到底要不要加这是新手最容易迷茫的问题。记住一条铁律在script setup里访问 ref 时必须加.value在模板里通常不用加自动解包reactive 任何位置都不加。有个例外需要单独记ref解构之后依然要.value因为解构出来的本身就是 Ref 对象。模板里如果 v-for 遍历的是一个 ref 数组item 是 ref 也需要.value……这种边角情况在面试里不会细考但实战里遇见过就是 bug。6.3 为什么 Vue 官方推荐 ref 作为默认选择官方文档确实把 ref 放在了组合式 API 的推荐位。理由非常简单ref 对 TypeScript 支持最友好响应式丢失概率最低所有响应式数据都显式.value语义明确。虽然写起来多敲几个字符但长期维护的收益是巨大的。我在团队里推广过一个约定新代码统一用 refreactive 只允许用在“有明确深层嵌套结构的对象且不会整体替换、不需要解构”的场景比如表单数据。规则简单CR 的时候一眼就能看出来有没有越界。7. 组合起来才是真正的实战一个用户管理面板聊完理论上一段真实代码。假设做一个后台用户管理面板包含搜索、列表获取、用户详情编辑。这个案例能把 ref、reactive、toRefs、computed 全串起来。// useUserPanel.ts import { ref, reactive, computed } from vue interface User { id: number name: string role: admin | editor | viewer status: active | banned } // 搜索条件用 ref字段少、需要整体替换时方便 const keyword ref() const roleFilter refall | User[role](all) // 用户列表用 ref接口返回后整体替换 const userList refUser[]([]) const loading ref(false) // 详情弹窗用 reactive因为结构固定且要频繁修改字段 const detailDialog reactive({ visible: false, user: null as User | null, remark: }) // 筛选后的列表用 computed const filteredList computed(() { const kw keyword.value.trim().toLowerCase() return userList.value.filter((u) { const matchKeyword u.name.toLowerCase().includes(kw) const matchRole roleFilter.value all || u.role roleFilter.value return matchKeyword matchRole }) }) // 模拟接口 async function fetchUsers() { loading.value true try { userList.value await Promise.resolve([ { id: 1, name: Alice, role: admin, status: active }, { id: 2, name: Bob, role: editor, status: banned }, { id: 3, name: Carol, role: viewer, status: active } ]) } finally { loading.value false } } function openDetail(user: User) { detailDialog.user user detailDialog.remark detailDialog.visible true } function handleSearch() { // 有需要时也可以让详情弹窗状态整体重置但这里不需要 }这个例子里ref 负责容易整体替换的数据reactive 负责固定结构的组件状态。一条很实际的经验是凡是和接口交互、一次性拿到整批数据覆盖的用 ref凡是在组件内部高频修改、属性结构固定、需要深层响应的用 reactive。8. 避坑清单 我的最终建议8.1 常见问题速查表场景推荐写法不推荐写法及原因基本类型状态ref(0)reactive(0)根本不生效对象整体替换ref(obj)然后refObj.value newObjreactive无法替换整个代理对象深层嵌套数据reactive或ref(obj)都可以都没有问题看风格偏好从 reactive 解构属性toRefs(state)解构直接解构会丢失响应式数组整体替换ref([])再arr.value newArrreactive([])直接赋值等同于重新绑定变量表单等固定结构reactive顺手如果想重置考虑 ref 默认值函数大数据量全量替换shallowRef深响应开销大JSX/TSX 中访问状态ref显式.valuereactive语义不清晰8.2 我的默认选择全 ref 方案把话说到底我现在大多数项目里的状态管理已经全面转向 ref 方案。这么选不是因为 reactive 不好而是为了降低团队协作时的规则复杂度。只要记住“script 里访问状态要.value模板里自动解包”这一条就不会踩坑。reactive 则留给那些结构固定的局部状态比如弹窗数据、表单对象。如果你还在犹豫到底用哪个我的建议非常直接先用 ref 解决 90% 的需求。遇到一个明确固定结构的深层对象再考虑换 reactive并且永远记住不能解构、不能整体替换这两条铁律。踩过几次坑之后你会发现ref 的代码在重构时特别舒服因为每个状态都是独立的你可以随意调整组合顺序不用维护一个越来越大的 state 对象。而 reactive 大对象到后期会变成一个“状态垃圾场”大家都在往里面塞属性本来不相干的状态挤在一起出问题后排查起来非常痛苦。最后分享一个小技巧如果你确实需要 reactive 的书写体验又想要 ref 的灵活性可以用reactivetoRefs组合创建一个“结构固定的响应式状态族”后解构出 ref 对外暴露。这种模式在封装业务组件和 composable 时特别好用既保持了集合语义又让使用者拿到的是不会被意外替换的 ref 引用。
02
RELATED NEWS

相关资讯

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

03
WHY YAOTU

想打造同款高转化官网?

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

场景化定制

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

营销型架构

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

全周期服务

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

免费获取你的建站方案

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