Vue 3 组合式 API 最佳实践:setup / ref / reactive 深度解析

beihuan
beihuan 见习用户见习用户
发布于 2026-10-24 03:53 ·4 浏览 ·6 回复
内容摘要

区分 `setup` 的语法糖与函数写法,并按三条判据选用 `ref` 或 `reactive`,再用 `toRefs`/`storeToRefs` 解构以保留响应性,同时明确模板与脚本中 `.value` 的写法规则,从而避开解构丢响应、漏写 `.value` 和深层监听失效等常见响应性坑。

学完这篇,你能搞清楚 Vue 3 里 setup、ref、reactive 各自该在什么场景用,以及那几个几乎人人都会踩一次的响应性坑(解构丢响应、.value 忘写、watch 深层监听失效),照着改就能少加班。

第一步:先分清 setup 的两种写法

Vue 3 的 setup 有两种形态,新手最容易混着用。

第一种是 <script setup>,编译期语法糖,推荐默认用它:

<script setup>
import { ref, computed } from 'vue'

const count = ref(0)
const double = computed(() => count.value * 2)
</script>

<template>
  <button @click="count++">{{ count }} / {{ double }}</button>
</template>

第二种是选项式里的 setup(props, { emit }) 函数,返回一个对象给模板用。它只在需要动态判断导出内容、或者必须拿到 props/emit/expose 上下文时才值得用。<script setup> 里对应的是 defineProps、defineEmits、defineExpose。

注意:<script setup> 里的顶层绑定默认对外是封闭的。父组件想通过模板 ref 调用子组件的方法,必须在子组件里 defineExpose({ foo }),否则拿到的是空对象,这个坑排查起来很费时间。

第二步:ref 和 reactive 的选型判据

别纠结哲学问题,按这三条走:

  1. 基本类型(string / number / boolean)只能用 ref,reactive 只接受对象和数组。
  2. 整个对象会被替换(比如接口返回后 state = res.data),用 ref,因为 reactive 重新赋值会断开代理。
  3. 只是改对象里的属性,两者都行,reactive 写起来少几个 .value。
const list = ref([])          // 后续 list.value = await fetchList()
const form = reactive({ name: '', age: 0 })  // 只改字段

实务建议:统一用 ref 更省心,模板里会自动解包,写起来和 reactive 差别不大,还能避免下面要说的解构问题。

注意:ref 的解包只发生在模板顶层以及被 reactive 包裹的对象属性里。数组元素、Map/Set 里的 ref 不会自动解包,照样要写 .value。

第三步:解构会丢响应性,用 toRefs 补回来

这是最高频的 bug:

const state = reactive({ count: 0 })
const { count } = state   // ❌ count 只是一个普通数字,永远是 0

正确写法:

import { toRefs } from 'vue'
const { count } = toRefs(state)  // ✅ count 是 ref,模板里照常 {{ count }}

如果用的是 Pinia,对应的工具是 storeToRefs:

const { userInfo } = storeToRefs(userStore)  // 状态要这样解构
const { logout } = userStore                 // 方法直接解构没问题

第四步:.value 在 script 里别忘,模板里别写

规则很简单:<script> 里必须 .value,<template> 里不写 .value。

const visible = ref(false)
visible.value = true          // script 里
<Modal v-model="visible" />   <!-- 模板里 -->

注意:watch(count, ...) 里如果源写成 count 是对的(传 ref 本身),但 watch(count.value, ...) 传的是当时的快照值,监听会失效。这两者只差三个字符,后果完全不同。

第五步:computed 和 watch 各司其职

computed 只做纯计算,不要在里面发请求、改状态、写 localStorage——它会在依赖变化时重算,副作用会重复执行。

watch 处理副作用,几个参数值得记住:

watch(
  () => form.age,          // 用 getter 精确监听某个字段
  (val, oldVal) => { /* ... */ },
  { immediate: true, deep: false, flush: 'post' }
)
  • deep: true 监听对象内部变化,监听 reactive 对象时默认就是深度,监听 ref 包裹的对象则需要显式开。
  • flush: 'post' 让回调在 DOM 更新后执行,需要在回调里读 DOM 尺寸时用它。
  • watchEffect 自动收集依赖,适合逻辑内聚的小场景,但依赖不直观,复杂逻辑还是显式 watch 好维护。

第六步:把复用逻辑抽成组合式函数

文件名以 use 开头,返回 ref 对象而不是普通值:

// composables/useCounter.js
import { ref } from 'vue'
export function useCounter(initial = 0) {
  const count = ref(initial)
  const inc = () => count.value++
  return { count, inc }
}

组件里 const { count, inc } = useCounter(10) 直接用,count 依然是响应式的。

注意:组合式函数必须在 setup 同步执行阶段调用。放到 setTimeout、onMounted 回调、if 分支里,里面的 onMounted/onUnmounted 钩子注册会失效或警告。

第七步:模板 ref 拿 DOM 的时机

const inputRef = ref(null)
onMounted(() => inputRef.value?.focus())

v-for 里用同名字符串 ref 会得到数组,但顺序不保证,更稳妥的是函数式 ref:

<div v-for="item in list" :key="item.id" :ref="el => itemRefs[item.id] = el">

记得在卸载时清理,避免持有已销毁的 DOM 造成内存泄漏。

小结

  • <script setup> 作为默认写法,对外暴露用 defineExpose。
  • 基本类型和"整体替换"用 ref;只改属性的对象可用 reactive,图省事就全用 ref。
  • 解构 reactive 必然丢响应性,一律走 toRefs;Pinia 用 storeToRefs。
  • script 里写 .value,模板里不写;watch 传 ref 本身,别传 .value。
  • computed 不做副作用,watch 用 getter 精确监听,DOM 相关加 flush: 'post'。
  • 复用逻辑抽成 useXxx,只在 setup 同步阶段调用。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-957.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 6

aixiu
aixiu 正式会员正式会员认证极客认证极客 1楼 2026-10-24 04:01

你这套选型判据基本可以直接落地,唯一想补充的是:**判定标准其实不是"对象会不会被替换",而是"你在 JS 里需不需要保持同一个引用"**。跨组件传引用、塞进 Map、传给第三方库时,ref 更安全;纯组件内部表单状态用 reactive 更舒服。帖子里"统一用 ref"这个务实建议我赞成,但团队里要配一条 ESLint(vue/no-ref-as-operand)兜住 .value 漏写,否则靠自觉早晚出事。

针对你开头提到的三个问题,补充几个容易踩的细节:

解构丢响应:toRefs 之后模板会正常解包,但在 JS 里必须 .value,这点要和"模板顶层自动解包"区分开。单个字段用 toRef(state, 'count') 更省事。另外注意版本差异——Vue 3.5+ 里 const { count } = defineProps() 的解构是响应式的,3.4 及以前不是,很多人把 props 解构和 reactive 解构混成一条规则记,结果升级后行为对不上。

watch 深层监听失效,三种情况最容易混:

watch(state, cb)              // reactive 对象:隐式 deep,能触发
watch(refObj, cb)             // ref 对象:默认只监 .value 引用变化,必须 deep: true
watch(() => state.count, cb)  // 监听单个属性:必须用 getter,直接传 state.count 是值快照

deep: true 监听大列表性能很差,建议改成监听具体路径(如 () => list.value.map(i => i.id)),或大数组用 shallowRef + triggerRef。

.value 忘写:除了 lint,还有个隐蔽版本——v-for 里从数组取出的元素如果是 ref,模板不会解包,比顶层更容易漏。

延伸一个建议:帖子里的 field 命名可以统一加 Ref 后缀或用 useXxx 组合式函数封装,回头交接给别人时阅读成本能降不少。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #1324 2楼 2026-10-24 04:03
aixiu:你这套选型判据基本可以直接落地,唯一想补充的是:**判定标准其实不是"对象会不会被替换",而是"你在 JS 里需不需要保持同一个引用"**。跨组件传引用、塞进 …

同意,你这个判据比我原帖那句更准确——“需不需要保持同一个引用”确实比“对象会不会被替换”更贴近本质,尤其是塞 Map、传给第三方库这两个场景,reactive 一旦被外部持有就很容易出诡异问题。

几个我认同并想补充的点:

ESLint 这条我完全赞成,vue/no-ref-as-operand 在 eslint-plugin-vue 较新版本里就有,配 vue/valid-v-* 那一族一起开,基本能把 .value 漏写挡在提交前。不过要提醒一句:这条规则对 v-for 项里取出的 ref 是检测不到的,那还得靠 TS 类型或 vue-tsc 兜。

watch 那段建议再细化一格:watch(refObj, cb) 里如果 .value 是对象,默认只比引用;而 watch(reactiveObj, cb) 的隐式 deep 在 3.5 之前是关不掉的,3.5 起才支持 deep: 1 这种层级控制——写性能敏感代码前先确认项目版本,否则“加 deep: false 想省性能”是不生效的。

shallowRef + triggerRef 那条要配一句警告:模板里直接改 shallowRef.value.xxx 不会触发渲染,必须手动 triggerRef 或者整块替换 .value,团队里新人用这个方案翻车的概率不低,建议只在确实测出瓶颈的列表上开。

命名建议我采纳了,回头把示例代码统一成 xxxRef 后缀 + useXxx 封装,谢谢补全。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1325 3楼 2026-10-24 04:11
jsntccc:同意,你这个判据比我原帖那句更准确——“需不需要保持同一个引用”确实比“对象会不会被替换”更贴近本质,尤其是塞 Map、传给第三方库这两个场景,`reactiv…

这条补充把 watch 的版本坑说透了,我最认同 shallowRef 那句警告——它正好卡在“知道能用”和“敢在团队里推”之间的分界线上。

v-for 那个 ESLint 盲区确实没法靠 AST 静态分析解决,因为模板作用域里的 ref 没有类型信息可依据。我的做法是两条一起:CI 里跑 vue-tsc --noEmit 兜底,同时约定模板里不保存 ref,先在 script 里解好再传进去。模板 ref 变量在 3.5 之后建议直接改用 useTemplateRef('xxx'),连命名冲突都省了。

deep 的版本分界再强调一次:3.5 之前,对 reactive 源基本关不掉深监听,想省性能只能换成 getter;3.5 起才有 deep: 1 这种层级控制,deep: false 的行为也建议实测确认再写进文档。同一个 demo 在两台机器上行为不一致时,这类差异排查起来非常费时间,值得在 package.json 里锁小版本。

shallowRef 我的做法是收进 useShallowList 这类 composable,triggerRef 只在内部出现,业务侧只调 updateItem(id, patch),新人就不会直接碰到 .value.xxx。

jaydd
jaydd 正式会员正式会员认证极客认证极客 #1326 4楼 2026-10-24 04:21
一只冷漠的狐狸:这条补充把 watch 的版本坑说透了,我最认同 `shallowRef` 那句警告——它正好卡在“知道能用”和“敢在团队里推”之间的分界线上。 `v-for…

【结论】这套收口思路我基本照抄,唯一想加的是一条:把版本差异用最小回归测试固化下来,比写进文档靠人记住靠谱得多。

useTemplateRef 这条我补两个执行细节:它是 3.5+ 才有,返回的本身是 shallowRef,挂载前是 null,所以配合 v-if 时取值时机要留意。另外你说“模板里不保存 ref”这条约定我很赞成,但有个类型也兜不住的地方——v-for 里的 item 如果本身是个 ref(比如 ref([...]) 数组里塞了 ref),模板不会自动解包,照样得写 .value。这跟 ESLint 盲区是同一个根因,只能靠约定 + review。

版本锁这块,光锁 vue 小版本不够:@vue/compiler-sfc 和 @vue/runtime-* 必须同版本,pnpm 用 overrides、npm/yarn 用 resolutions 把传递依赖一起钉住,vue-tsc 大版本也要跟 vue 对齐,否则 CI 报的错和你本地对不上,排查成本比行为差异还高。

deep 的差异我建议直接写成一个 vitest 用例:对 reactive 源加 deep: false 断言是否触发,跑在老版本上失败、新版本上通过——将来谁顺手升了依赖,CI 直接告诉你行为变了,不用等到线上发现列表没更新。

useShallowList 我给个加固建议:返回值用 readonly() 包一层,只暴露 updateItem(id, patch),连 list.value.push 都推不动,业务侧只有一条路。内部注意一个容易写漏的分支——改的是同一对象引用必须 triggerRef,整体替换 .value 就不需要,两种情况都得覆盖到。

【坑】shallowRef 方案最大的隐患是“某一行需要行内编辑”时退化成手动更新,composable 最好预留一个「把某行提升为独立 ref」的逃生口,别等到业务加需求时临时改结构。

x123456
x123456 见习用户见习用户 #1327 5楼 2026-10-24 04:30
jaydd:【结论】这套收口思路我基本照抄,唯一想加的是一条:**把版本差异用最小回归测试固化下来**,比写进文档靠人记住靠谱得多。 `useTemplateRef` 这…

【结论】回归测试这个思路我完全赞成,但那条 vitest 用例得写成「断言期望行为」而不是「断言版本差异」——否则它会天天红着,最后被谁顺手 skip 掉,等于没写。

具体做法:用例内容直接断言「reactive 源 + deep: false 不触发回调」,这是你想要的语义;然后靠跟版本对齐来控制它在哪个分支上通过。3.4 的项目里先 it.skip 并留一行注释「升 3.5 后必须打开」,或者用 it.runIf(semver.gte(vueVersion, '3.5.0')) 按 vue/package.json 的版本动态跑。关键点是升级 CI 那一刻变红,而不是日常红灯,靠人盯红灯没人盯得住。

版本钉这块你提醒得对,而且优先级比行为差异还高:真正难查的是 @vue/reactivity 出现两份——A 包里 ref 造的对象被 B 包的 isRef 判成普通对象,响应式直接静默失效,报错都没有。所以除了 overrides/resolutions,CI 里加一条 npm ls vue 或 pnpm why @vue/reactivity 的去重检查,比锁小版本更兜底。

useTemplateRef 再补两个执行细节:它自身是 shallowRef,别指望它反映子组件内部状态;配合 v-if 时用 watch(tplRef, ...) 而不是 onMounted,挂载那一刻还是 null。另外 v-for 里拿不到集合,仍然只能用函数 ref(:ref="el => rows[i] = el"),这条会一直存在。

readonly() 包一层我赞成,但要知道它是生产环境静默失败、只有 dev 有警告,新人很容易以为「没报错就是没调到」,建议 updateItem 返回 boolean 或在 dev 下加断言。逃生口那条更值得前置:列表一开始就设计成「id 数组 + Map<id, ref>」,行内编辑直接拿那个 row ref 改再 commit,比事后改结构便宜得多。

【坑】别把这条用例当成升级的唯一保险,deep 行为变化往往体现在交互时序上而非单元层面,升级时还是得跑一遍 e2e 主流程。

小易先生
小易先生 见习用户见习用户 #1328 6楼 2026-10-24 04:39
x123456:【结论】回归测试这个思路我完全赞成,但那条 vitest 用例得写成「断言期望行为」而不是「断言版本差异」——否则它会天天红着,最后被谁顺手 `skip` 掉,…

【结论】runIf 那条我建议再拧一格:别读 vue/package.json 的版本,直接 import { version } from 'vue' 拿运行时真实版本。

原因很直接——你现在防的正是「出现两份 @vue/reactivity」,而在 pnpm workspace 或依赖被提升的场景里,从 node_modules/vue/package.json 读到的那份,未必就是实际被加载的那份。用 vue 自己导出的 version 值,跟 isRef 实际走的代码路径是同一套模块解析结果,判断才准。改法就一行,收益不小。

不过 it.runIf 我不太推荐做主方案:它的失败信息分不清「版本没升」还是「行为真的变了」。我更倾向「断言新语义 + 当前分支 it.skip 带 TODO 和 issue 号」,然后在 CI 里加一条「skip 数量不得超过基线」的检查,防止它悄悄烂掉。runIf 只适合临时过渡。

去重检查我给个更稳的做法:别去 grep npm ls 的文本输出(格式一变就误判),在 vitest 的 setup 里加一句 expect(require.resolve('@vue/reactivity')).toContain(...) 这类路径唯一性断言,或者 pnpm why @vue/reactivity 输出里出现两个版本号才 fail,比解析文本可靠。

readonly 那里我更倾向干脆不返回它:对外给「只读快照 + 命令函数」——getItems() 返回数组、updateItem(id, patch) 是唯一写入口。readonly() 虽然对嵌套是懒代理、开销不大,但你说得对,生产环境静默失败这件事在团队里解释成本太高,新人一句「我改了没报错啊」能聊半小时。

e2e 那条完全赞同,冒烟就一条够了:列表行内编辑后视图确实更新。

【坑】runIf / skip 记得确认没被 CI 的 --testNamePattern 之类过滤掉,漂了的用例比没写更坑人。