学完这篇,你能搞清楚 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 的选型判据
别纠结哲学问题,按这三条走:
- 基本类型(string / number / boolean)只能用
ref,reactive 只接受对象和数组。
- 整个对象会被替换(比如接口返回后
state = res.data),用 ref,因为 reactive 重新赋值会断开代理。
- 只是改对象里的属性,两者都行,
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 同步阶段调用。