Vue 3 的组合式 API(Composition API)配合 <script setup> 已经是现在写 Vue 的默认姿势。但初学者最常卡住的不是语法,而是第一道选择题:这个数据到底用 ref 还是 reactive?
先给结论,再讲为什么:默认用 ref,只有明确需要”一个整体对象”时才用 reactive。比如表单对象、分页查询参数这种天然就是一坨的东西。
ref 与 reactive 的本质区别
ref 可以包装任何类型的值,模板里自动解包,JS 里要 .value:
<script setup>
import { ref } from 'vue'
const count = ref(0)
const name = ref('joon')
function increment() {
count.value++ // JS 里必须 .value
}
</script>
<template>
<!-- 模板里直接用,不用 .value -->
<p>{{ count }} - {{ name }}</p>
<button @click="increment">+1</button>
</template>
reactive 只能包装对象(包括数组),返回的是原对象的 Proxy:
<script setup>
import { reactive } from 'vue'
const form = reactive({
username: '',
password: '',
remember: true,
})
</script>
<template>
<input v-model="form.username" />
<input v-model="form.password" type="password" />
</template>
注意 reactive 有两个硬伤,新手必踩:
- 解构会丢失响应性:
const { username } = form拿到的就是普通字符串了。非要解构,用toRefs(form)。 - 不能整体替换:
form = { username: 'a' }直接报错(const)或断开响应。只能逐个字段赋值,或者Object.assign(form, newData)。
// ❌ 错误:解构丢失响应性
const { username } = form
username = 'new' // 界面不会更新
// ✅ 正确:保持 reactive 整体使用
form.username = 'new'
// ✅ 批量更新用 Object.assign
Object.assign(form, { username: 'a', password: 'b' })
所以回到开头那道选择题:单个值、可能被替换的值 → ref;一组固定字段、整体传递的对象 → reactive。拿不准就用 ref,ref 包对象一样能用(ref({}) 内部也是 reactive),只是多写 .value。
computed:派生状态别用 watch 硬算
只要”一个值是由其他值算出来的”,就用 computed,别用 watch 手动同步:
<script setup>
import { ref, computed } from 'vue'
const items = ref([
{ name: '键盘', price: 299, checked: true },
{ name: '鼠标', price: 149, checked: false },
])
// ✅ computed:声明式,自动缓存
const total = computed(() =>
items.value.filter(i => i.checked).reduce((s, i) => s + i.price, 0)
)
// ❌ 别这样:watch 手动同步又啰嗦又容易漏
</script>
<template>
<p>合计:¥{{ total }}</p>
</template>
computed 带缓存,只有依赖变了才重算。需要”可写”的派生值时给它 getter/setter,比如全选框:
const allChecked = computed({
get: () => items.value.every(i => i.checked),
set: (v) => items.value.forEach(i => (i.checked = v)),
})
watch vs watchEffect:别混着用
watch(明确的数据源, 回调):精确控制,“count 变了我就去请求接口”watchEffect(回调):自动追踪回调里用到的所有响应式数据,适合”这些数据任一变了就重跑”的场景
import { ref, watch, watchEffect } from 'vue'
const keyword = ref('')
const page = ref(1)
const list = ref([])
// 明确监听 keyword,防抖搜接口
watch(keyword, (newVal) => {
searchApi(newVal)
})
// 自动追踪:keyword 或 page 任一变化都重新拉列表
watchEffect(async () => {
list.value = await fetchList({ q: keyword.value, page: page.value })
})
两个细节:watch 默认是懒执行的(数据源变了才跑),想要立即跑一次加 { immediate: true };监听 reactive 对象默认是深层监听,监听 ref 包的对象需要 { deep: true }。
生命周期:写在 setup 里,没有 this
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
const width = ref(window.innerWidth)
function onResize() {
width.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', onResize)
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
})
</script>
常用的就这几个:onMounted(发请求、绑事件)、onUnmounted(清定时器、解绑)、onUpdated 很少用。注意组合式 API 里没有 this,created 对应的逻辑直接写在 setup 顶层就行。
和 Options API 的对照
| Options API | Composition API |
|---|---|
data() | ref / reactive |
computed: {} | computed() |
watch: {} | watch() / watchEffect() |
methods: {} | 普通函数 |
mounted() | onMounted() |
this.xxx | 直接用变量名 |
组合式 API 真正的价值不是语法糖,而是逻辑复用:把”搜索框的防抖逻辑""分页逻辑”抽成 useSearch()、usePagination() 这种 composable 函数,跨组件直接 import。Options API 时代靠 mixins 做这事,命名冲突和来源不明是出了名的坑。
实战案例
来自个人全栈项目的一个后台管理页面:用户列表,带搜索框、分页、批量删除。
一开始我把搜索条件写成了一个 reactive 对象 { keyword: '', status: '', page: 1 },翻页时想重置条件,直接 query = { keyword: '', status: '', page: 1 } —— 界面纹丝不动。原因就是上面说的 reactive 不能整体替换。改成 Object.assign(query, { keyword: '', status: '', page: 1 }) 就好了。
第二个坑是搜索防抖。我一开始用 watchEffect 监听 keyword,每敲一个字母就请求一次接口。后改成 watch(keyword, debounce(fn, 300)),明确只监听 keyword 且防抖,请求量直接降了一个数量级。
现在的习惯:表单类整体对象用 reactive,其他一律 ref;派生值无脑 computed;副作用明确数据源用 watch,图省事才用 watchEffect。这套规则跑了小半年,没再踩过响应性丢失的坑。
