刚开始用 Vue3 时,最容易踩的几个坑
这几个坑是我最近把一个中后台项目从 Vue2 迁到 Vue3 时,一个个撞出来又填回去的,趁记得清楚整理成一份,免得下个项目再踩一遍。
Vue3 本身已经很成熟,但从 Vue2 或 Options API 迁过来,还是很容易写出"能跑但不太对"的代码。这些坑大多不是语法不会,而是对组合式 API 和响应式规则理解不够。写法更灵活,也意味着组件逻辑该怎么拆得开发者自己管。我最大的不适应其实不是 .value,是逻辑组织方式变了:Options API 会把 data、methods、watch 分栏摆好;Composition API 更自由,可自由也意味着你能把请求、表单、弹窗、权限全塞进一个 setup。写得顺手不代表逻辑分得清楚。
基本类型别直接用 reactive
reactive 适合对象、数组、Map、Set 这类引用类型。数字、字符串、布尔值应该用 ref。
1import { reactive, ref } from "vue"; 2 3const user = reactive({ 4 name: "张三", 5 age: 18, 6}); 7 8const count = ref(0); 9const keyword = ref(""); 10const loading = ref(false);
别这么写:
1const count = reactive(0);
基本类型没法被 reactive 正常包装。一个简单判断:单个值用 ref,结构化对象用 reactive。对象也能用 ref,取决于你是想整体替换对象,还是更常改内部字段。接口返回的详情对象要整体替换,我更倾向 ref<User | null>(null);表单这种长期改内部字段的,用 reactive 更顺。不是谁更高级,是看更新方式。
ref 在脚本里要写 .value
模板里用 ref 会自动解包:
1<template> 2 <button>{{ count }}</button> 3</template>
但 <script setup> 里读写要用 .value:
1const count = ref(0); 2 3function add() { 4 count.value += 1; 5}
刚上手时特别容易漏 .value。发现状态没更新,先看是在模板里还是脚本里用的。为什么模板不用写?因为模板编译时 Vue 会对顶层 ref 自动解包,脚本没这层处理。这个能在 SFC Playground 里直接验证,不用靠记:
1<script setup> 2import { ref, isRef } from "vue"; 3const count = ref(0); 4console.log(isRef(count)); // true:count 是 ref 对象 5console.log(count); // 一个 RefImpl 对象,不是 0 6console.log(count.value); // 0:脚本里必须 .value 才拿到真实值 7</script> 8 9<template> 10 <!-- 模板里直接写 count,渲染出 0,不需要 .value --> 11 <button @click="count++">{{ count }}</button> 12</template>
脚本里 console.log(count) 打的是 ref 包装对象而不是 0,isRef(count) 为 true;模板里 {{ count }} 渲染成 0,@click="count++" 也能正常自增——这就是自动解包只在模板这一侧发生的直接证据。
解构 reactive 会断响应式
这是最常见的坑。
1const state = reactive({ 2 name: "张三", 3 age: 18, 4}); 5 6const { name } = state;
解构出的 name 只是普通值,不再跟着 state.name 变。这个结论能用 isReactive/isRef 直接验证,省得靠猜:
1import { reactive, isReactive, isRef } from "vue"; 2 3const state = reactive({ name: "张三", age: 18 }); 4const { name } = state; 5 6console.log(isReactive(state)); // true:state 本身是响应式代理 7console.log(isRef(name)); // false:解构出来的是普通字符串 "张三" 8console.log(typeof name); // "string"
isRef(name) 是 false,说明它根本不是 ref,自然不会随 state.name 变。道理放回纯 JS 更透——reactive 底层就是个 Proxy,解构等于一次普通属性读取,读到的是那一刻的原始值,跟代理再没关系:
1const raw = { name: "张三" }; 2const proxy = new Proxy(raw, { 3 get(target, key) { 4 console.log("get", key); 5 return Reflect.get(target, key); 6 }, 7 set(target, key, value) { 8 console.log("set", key, "=", value); 9 return Reflect.set(target, key, value); 10 }, 11}); 12 13const { name } = proxy; // 打印 get name —— 解构只触发一次读取 14proxy.name = "李四"; // 打印 set name = 李四 —— 代理仍能拦到对 proxy 的赋值 15console.log(name); // "张三" —— 解构出的局部变量纹丝不动
响应式挂在 Proxy 的 get/set 上,普通变量身上什么都没有,这就是"解构会断"的本质。要解构就用 toRefs:
1import { reactive, toRefs } from "vue"; 2 3const state = reactive({ 4 name: "张三", 5 age: 18, 6}); 7 8const { name, age } = toRefs(state);
toRefs 把对象属性转成 ref,解构后仍保留响应式。这里顺带记一个相近的坑:props 上想解构又不想断响应式,别直接用 toRefs(props) 之外硬解,toRef(props, 'foo') 能单独取某个字段且保持响应式;而 Vue 3.3 起 <script setup> 里可以直接对 defineProps 的返回值解构、编译器会保留响应式(响应式解构),不过这还是很新的实验性能力,我暂时只在自己的分支上试,团队正式代码还是走 toRefs。
props 别直接改
子组件拿到的 props 当只读数据。要改,通常两条路:emit 通知父组件改,或在子组件里复制一份本地状态。
1const props = defineProps({ 2 modelValue: String, 3}); 4 5const emit = defineEmits(["update:modelValue"]); 6 7function updateValue(value) { 8 emit("update:modelValue", value); 9}
直接改 props 会让数据流乱掉,也容易报警告。确实需要本地编辑副本就这么处理:
1const localValue = ref(props.modelValue); 2 3watch( 4 () => props.modelValue, 5 (value) => { 6 localValue.value = value; 7 } 8);
但要清楚:本地副本意味着父子状态可能短暂不一致,得明确什么时候同步、什么时候提交。这个在弹窗表单里特别常见——父组件传一条记录进来,子组件复制成本地表单,用户点取消不该污染父数据,点确定再 emit 提交。这几个时机想清楚,props 只读就不显得麻烦。
上面这套 defineProps + defineEmits 手写 v-model 的样板,Vue 3.3(上个月刚发布)给了个实验性的 defineModel 想收掉,一个宏就能把 prop 和 update 事件一起声明:
1// 实验性,需在配置里显式打开 2const model = defineModel(); 3// 读 model.value 即 props.modelValue,写 model.value 自动 emit update
看着很省事,但它现在还标着实验、默认没开,语义细节(多个 model、修饰符)也还在动。我只在个人项目试了试,正式项目暂时还是老老实实手写那对宏,等它稳下来再说。
watch 别滥用
watch 很有用,但别把所有逻辑都塞进去。一个值能由其它值算出来,优先 computed:
1const fullName = computed(() => `${firstName.value} ${lastName.value}`);
watch 更适合副作用,比如请求接口、写缓存、同步外部状态。搜索关键词变了请求列表:
1watch(keyword, async (value, oldValue, onCleanup) => { 2 const controller = new AbortController(); 3 4 onCleanup(() => { 5 controller.abort(); 6 }); 7 8 try { 9 const response = await fetch(`/api/search?q=${value}`, { 10 signal: controller.signal, 11 }); 12 result.value = await response.json(); 13 } catch (error) { 14 if (error.name !== 'AbortError') { 15 console.error('搜索请求失败', error); 16 } 17 } 18});
onCleanup 取消旧请求,避免上一次比下一次晚返回、旧数据覆盖新数据。如果请求逻辑在多个页面重复,别每个 watch 里都手写取消和错误处理,抽成 composable 或请求库策略,统一管 loading、error、abort,否则同一个搜索框在不同页面会长出不同的竞态 bug。
还有个执行时机的坑:watch 默认在组件更新前、异步执行回调。如果你要在回调里读被这次变化影响后的 DOM,得加 { flush: 'post' };早年 Vue2 的 watch 是同步语义,迁过来时这个差异容易让人误判"为什么拿到的还是旧 DOM"。
watch 对象时,说清监听目标
另一个常见问题是直接深度监听整个对象:
1watch( 2 form, 3 () => { 4 saveDraft(form); 5 }, 6 { deep: true } 7);
方便,但容易触发过于频繁,表单字段一多尤其明显。只关心某几个字段,就把依赖明确写出来:
1watch( 2 () => [form.title, form.category], 3 () => { 4 saveDraft({ 5 title: form.title, 6 category: form.category, 7 }); 8 } 9);
监听目标越明确,后续维护越容易判断为什么会触发。
computed 别带副作用
computed 用来算派生值,别在里面改状态或发请求。
1const total = computed(() => { 2 reportLog(); 3 return price.value * count.value; 4});
这就是反例。computed 可能因为依赖变化和缓存策略被多次求值,副作用放进去行为会难预测。发请求、埋点、写缓存放事件处理或 watch 里。
组合式函数别什么都往里塞
Vue3 里可以把逻辑封成组合式函数:
1function useLoading() { 2 const loading = ref(false); 3 4 function setLoading(value) { 5 loading.value = value; 6 } 7 8 return { 9 loading, 10 setLoading, 11 }; 12}
但别把所有业务都塞进一个巨大的 usePage。一个组合式函数同时管请求、表单、权限、弹窗、路由、埋点,只是把组件复杂度换了个文件放。好的组合式函数职责明确,只处理请求、表单、分页、弹窗或权限中的一类。
我不太喜欢万能的 useTablePage。一开始它帮你快速做列表,后来加权限、批量、导出、缓存、路由同步,参数越滚越多。组合式函数最好小而专注、可组合,别变成另一个配置平台。
生命周期里记得清理副作用
组合式 API 让事件监听和定时器更容易散落,清理就更重要:
1import { onMounted, onUnmounted } from "vue"; 2 3onMounted(() => { 4 window.addEventListener("resize", handleResize); 5}); 6 7onUnmounted(() => { 8 window.removeEventListener("resize", handleResize); 9});
定时器、订阅、WebSocket、全局事件都该在卸载时清理,否则页面切走后旧逻辑还在跑。还有个迁移期常见问题:在模块顶层直接调用 Pinia store 或依赖组件上下文的 composable。很多 composable 只能在 setup 或 app 创建后调用,放模块顶层可能在初始化顺序上出问题——Pinia 现在是官方默认状态管理方案,useStore() 得等 app.use(pinia) 之后才可用。能延后到函数内部就别过早执行。
自动解包很方便,但不是到处都自动
模板里 count 直接用,脚本里要 count.value;reactive 对象里的 ref 会被解包,但放进数组、Map 里表现又不一样。遇到不确定的响应式问题,先把数据结构简化,别把 ref、reactive、toRefs 混成一团。
我刚迁的时候,总想把 Options API 平移过来:data 换 ref、methods 挪成函数、watch 照旧堆。跑是能跑,组件很快又乱成一团。后来调过来了:基本类型用 ref,复杂对象看更新方式选 reactive 或对象 ref;脚本里老老实实写 .value;解构前先想响应式会不会断;props 保持单向流;能用 computed 表达的派生值别塞进 watch;组合式函数别什么都往里塞。这几条改完,这个中后台项目从 Vue2 迁过来之后终于没再乱成一团,组件行数也肉眼可见地降了下来。