刚开始用 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 包装对象而不是 0isRef(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.valuereactive 对象里的 ref 会被解包,但放进数组、Map 里表现又不一样。遇到不确定的响应式问题,先把数据结构简化,别把 ref、reactive、toRefs 混成一团。

我刚迁的时候,总想把 Options API 平移过来:datarefmethods 挪成函数、watch 照旧堆。跑是能跑,组件很快又乱成一团。后来调过来了:基本类型用 ref,复杂对象看更新方式选 reactive 或对象 ref;脚本里老老实实写 .value;解构前先想响应式会不会断;props 保持单向流;能用 computed 表达的派生值别塞进 watch;组合式函数别什么都往里塞。这几条改完,这个中后台项目从 Vue2 迁过来之后终于没再乱成一团,组件行数也肉眼可见地降了下来。