Vue 3 避坑指南:从入门到精通的实战经验总结

前言

Vue 3 作为 Vue.js 的重大版本更新,带来了 Composition API、更好的 TypeScript 支持、性能优化等诸多改进。然而,在从 Vue 2 迁移或直接上手 Vue 3 的过程中,开发者们常常会遇到一些"坑"。本文旨在汇总这些常见问题,并提供详细的解决方案和最佳实践,帮助你更顺畅地使用 Vue 3。

1. 响应式系统 (Reactivity) 的"坑"

Vue 3 使用 Proxy 重构了响应式系统,虽然更强大,但也带来了新的理解和使用门槛。

1.1 解构响应式对象导致失去响应性

这是最常见的错误之一。直接解构 reactiveref.value 会破坏响应式连接。

javascript 复制代码
// ❌ 错误示例
import { reactive } from 'vue';
const state = reactive({ count: 0 });
const { count } = state; // 解构后,count 不再是响应式的
count++; // 视图不会更新

// ✅ 正确做法 1:使用 toRefs
import { reactive, toRefs } from 'vue';
const state = reactive({ count: 0, name: 'Vue' });
const { count, name } = toRefs(state); // 现在 count 和 name 都是响应式 ref
count.value++; // 视图会更新

// ✅ 正确做法 2:直接访问原对象属性
state.count++; // 视图会更新

1.2 直接替换 reactive 对象的引用

reactive 绑定的是对象的引用,直接赋值一个新对象会丢失响应性。

javascript 复制代码
// ❌ 错误示例
let state = reactive({ data: null });
state = { data: fetchData() }; // 错误!state 的响应式连接断了

// ✅ 正确做法:修改对象的属性
state.data = fetchData(); // 正确,保持了响应性

// 或者使用 ref
import { ref } from 'vue';
const state = ref({ data: null });
state.value = { data: fetchData() }; // 正确,ref 的 .value 可以被整体替换

1.3 在异步操作中访问未定义的响应式属性

如果在 setup 或 onMounted 等钩子中异步获取数据并赋值给响应式对象,需要确保属性已初始化。

javascript 复制代码
// ❌ 可能导致问题
const state = reactive({});
setTimeout(() => {
  state.user = { name: 'Alice' }; // 如果模板在赋值前访问 state.user.name 会报错
}, 1000);

// ✅ 更安全的做法:预先定义属性结构
const state = reactive({
  user: null, // 或 user: {}
  list: []
});
// 或者使用可选链操作符 ?. 和空值合并 ?? 来防御
<div>{{ state.user?.name ?? 'Loading...' }}</div>

2. Composition API 使用陷阱

2.1 在生命周期钩子外使用 onMounted/onUpdated 等

生命周期钩子(如 onMounted, onUpdated)必须在 setup() 函数或 <script setup> 的同步执行过程中调用。

javascript 复制代码
// ❌ 错误示例
import { onMounted } from 'vue';
const fetchData = () => {
  onMounted(() => { // 错误!不能在异步函数或事件回调中调用
    console.log('mounted');
  });
};

// ✅ 正确示例
import { onMounted } from 'vue';
onMounted(() => {
  // 正确的调用位置
  console.log('组件已挂载');
  fetchData();
});

2.2 忘记 .value (Ref 相关)

在 JavaScript 中操作 ref 时需要使用 .value,但在模板中会自动解包。

javascript 复制代码
// ❌ 错误示例
import { ref } from 'vue';
const count = ref(0);
const double = count * 2; // 错误!count 是一个 ref 对象,不是数字

// ✅ 正确示例
const double = count.value * 2; // 在 JS 中正确

// 在模板中则不需要 .value
<template>
  <div>{{ count }}</div> <!-- 自动解包,正确 -->
  <div>{{ count * 2 }}</div> <!-- 模板中自动解包,正确 -->
</template>

2.3 滥用 watch 和 watchEffect

watchwatchEffect 功能强大,但使用不当会导致性能问题或无限循环。

javascript 复制代码
// ❌ 可能导致无限循环
const state = reactive({ count: 0 });
watchEffect(() => {
  state.count = state.count + 1; // 在 effect 中修改依赖,会触发重新执行,可能死循环
});

// ✅ 正确使用 watch:明确指定侦听源和回调
watch(
  () => state.count,
  (newVal, oldVal) => {
    console.log(`count 从 ${oldVal} 变为 ${newVal}`);
    // 不要在这里直接修改 state.count
  },
  { immediate: true } // 可选:立即执行一次
);

// ✅ 使用 watchEffect 处理副作用,注意清理
const stop = watchEffect((onCleanup) => {
  const timer = setInterval(() => {
    console.log('tick');
  }, 1000);
  onCleanup(() => clearInterval(timer)); // 重要:清理副作用
});
// 需要时调用 stop() 停止侦听

3. <script setup> 语法糖的注意事项

<script setup> 是编译时语法糖,让 Composition API 更简洁,但也有特殊规则。

3.1 定义组件名和 inheritAttrs

<script setup> 中,默认没有显式的组件名,且 inheritAttrs 默认为 true。

html 复制代码
<!-- MyComponent.vue -->
<script setup>
// 如果需要自定义组件名(用于调试或递归组件)
defineOptions({
  name: 'MyComponent',
  inheritAttrs: false // 禁止根元素继承未在 props 中声明的 attribute
});

// 如果需要访问透传的 attributes (attrs)
import { useAttrs } from 'vue';
const attrs = useAttrs();
console.log(attrs.class); // 获取透传的 class
</script>

3.2 使用 defineExpose 暴露组件内部方法或属性

默认情况下,<script setup> 内部的变量和方法是私有的。父组件需要通过模板 ref 访问时,需要使用 defineExpose

html 复制代码
<!-- Child.vue -->
<script setup>
import { ref } from 'vue';
const count = ref(0);
const increment = () => { count.value++; };

// 暴露给父组件
defineExpose({
  count,
  increment
});
</script>

<!-- Parent.vue -->
<template>
  <Child ref="childRef" />
</template>
<script setup>
import { ref, onMounted } from 'vue';
import Child from './Child.vue';
const childRef = ref(null);
onMounted(() => {
  console.log(childRef.value.count); // 0
  childRef.value.increment(); // 调用子组件方法
  console.log(childRef.value.count); // 1
});
</script>

4. 路由 (Vue Router 4) 的常见问题

4.1 路由守卫中使用 Composition API

在 Vue Router 4 的路由守卫中,不能直接使用 setup 中的响应式变量或生命周期钩子。

javascript 复制代码
// router/index.js 或路由守卫文件中
import { createRouter, createWebHistory } from 'vue-router';
import { useUserStore } from '@/stores/user'; // 假设使用 Pinia

const router = createRouter({
  history: createWebHistory(),
  routes: [...]
});

// 全局前置守卫
router.beforeEach((to, from) => {
  // ❌ 错误:不能在这里调用 composable
  // const userStore = useUserStore();

  // ✅ 正确:通过其他方式获取状态,例如从 localStorage 或直接导入 store 实例
  const userStore = useUserStore(); // 注意:这要求 store 已在应用层初始化
  if (to.meta.requiresAuth && !userStore.isLoggedIn) {
    return '/login';
  }
});

4.2 路由组件内获取路由参数和查询

使用 useRouteuseRouter 组合式函数。

html 复制代码
<script setup>
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();

// 获取参数
const userId = route.params.id; // 响应式
// 获取查询字符串
const searchQuery = route.query.q;

// 编程式导航
const goToUser = (id) => {
  router.push(`/user/${id}`);
  // 或使用命名路由
  // router.push({ name: 'user', params: { id } });
};
</script>

5. 状态管理 (Pinia) 的最佳实践与坑点

Pinia 是 Vue 3 官方推荐的状态管理库,替代 Vuex。

5.1 在 setup 外使用 store

在组件 setup 之外(例如路由守卫、工具函数中)使用 store,需要确保 Pinia 实例已安装到应用中。

javascript 复制代码
// stores/user.js
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', {
  state: () => ({ name: '' }),
  actions: {
    setName(name) { this.name = name; }
  }
});

// 在非组件文件中使用(例如 axios 拦截器)
import { useUserStore } from '@/stores/user';
// ❌ 直接调用会报错,因为此时没有活跃的 Pinia 实例
// const store = useUserStore();

// ✅ 正确:在应用初始化后,通过导入的 store 文件直接调用
// 方法1:在能访问到 app 的地方传入 pinia 实例(不常见)
// 方法2:在 store 动作内部处理非响应式逻辑,或确保调用时组件已挂载

更安全的模式:在组件或组合式函数内调用 store 动作,将必要的业务逻辑封装在 store 的 actions 中。

5.2 Store 的响应式解构

reactive 类似,直接解构 store 的状态也会失去响应性。使用 storeToRefs

html 复制代码
<script setup>
import { useUserStore } from '@/stores/user';
import { storeToRefs } from 'pinia';
const userStore = useUserStore();

// ❌ 错误:失去响应性
const { name, age } = userStore;

// ✅ 正确:保持响应性
const { name, age } = storeToRefs(userStore);
// 现在 name 和 age 是 ref,模板中可以直接使用 {{ name }}
// 修改时需要使用 .value (在 JS 中) 或调用 store 的 action
</script>

6. 性能优化相关陷阱

6.1 不必要的组件重新渲染

Vue 3 的响应式系统很高效,但传递不必要的响应式对象或函数仍会导致子组件不必要的更新。

html 复制代码
<!-- Parent.vue -->
<template>
  <!-- ❌ 每次父组件渲染都会生成新的函数,导致 Child 不必要的更新 -->
  <Child :on-click="() => handleClick(item.id)" />

  <!-- ✅ 使用计算属性或 useMemo 缓存函数 -->
  <Child :on-click="getClickHandler(item.id)" />
</template>
<script setup>
import { computed } from 'vue';
const getClickHandler = (id) => {
  // 使用 computed 或 useMemo 来缓存函数引用
  return computed(() => () => handleClick(id));
};
// 或者使用 Vue 3.3+ 的 `defineModel` 或更精细的 props 设计
</script>

6.2 大型列表使用 v-for 未加 key

这虽然是 Vue 2 就有的问题,但在 Vue 3 中依然重要。同时,避免在 v-for 中使用复杂表达式。

html 复制代码
<!-- ❌ 错误:没有 key,且过滤操作在模板中重复执行 -->
<li v-for="item in list.filter(i => i.active)">
  {{ item.name }}
</li>

<!-- ✅ 正确:使用 key,且将计算逻辑移到计算属性中 -->
<template>
  <li v-for="item in activeList" :key="item.id">
    {{ item.name }}
  </li>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps(['list']);
const activeList = computed(() => props.list.filter(i => i.active));
</script>

7. TypeScript 集成注意事项

7.1 为组件 Props 和 Emits 定义类型

使用 TypeScript 时,明确定义 props 和 emits 的类型可以获得更好的类型推断和 IDE 支持。

html 复制代码
<script setup lang="ts">
// 定义 Props 类型
interface Props {
  title: string;
  count?: number; // 可选属性
  items: string[];
}
const props = withDefaults(defineProps<Props>(), {
  count: 0, // 默认值
  items: () => [] // 默认值为数组或对象时,使用工厂函数
});

// 定义 Emits 类型
interface Emits {
  (e: 'update:title', value: string): void;
  (e: 'submit', payload: { id: number }): void;
}
const emit = defineEmits<Emits>();

const updateTitle = (newTitle: string) => {
  emit('update:title', newTitle);
};
</script>

7.2 模板 Ref 的类型定义

为模板 ref 指定类型,以访问组件实例或 DOM 元素的属性。

html 复制代码
<template>
  <input ref="inputRef" type="text" />
  <MyComponent ref="childRef" />
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import MyComponent from './MyComponent.vue';
import type { MyComponentExposed } from './MyComponent.vue'; // 假设子组件通过 defineExpose 暴露了类型

// DOM 元素 ref
const inputRef = ref<HTMLInputElement | null>(null);
// 组件实例 ref
const childRef = ref<InstanceType<typeof MyComponent> | null>(null);
// 或者使用子组件暴露的类型
// const childRef = ref<MyComponentExposed | null>(null);

onMounted(() => {
  if (inputRef.value) {
    inputRef.value.focus(); // 类型安全,有代码提示
  }
  if (childRef.value) {
    console.log(childRef.value.count); // 类型安全
  }
});
</script>

8. 构建与部署相关

8.1 公共路径 (publicPath) 配置

如果应用部署在子路径下(如 https://example.com/my-app/),需要在构建配置中设置正确的 publicPath

javascript 复制代码
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defin
相关推荐
Tom@敲代码1 小时前
js学习笔记-01
javascript·笔记·学习
风中的小甜瓜1 小时前
ASP.NET Page 那点事
java·前端·asp.net
木西1 小时前
告别机械重复:使用 Node.js + Playwright 构建 24 小时全自动测试网领水脚本
前端·javascript·node.js
不好听6132 小时前
JavaScript Promise 从入门到精通(一):为什么需要 Promise 以及它是什么
javascript
西柚小萌新2 小时前
【论文写作】--LaTeX详细入门教程2
前端·html
qdkfz2 小时前
JavaScript 如何解析 Outlook .msg 文件 —— @kenjiuno/msgreader
前端·javascript
李明卫杭州2 小时前
Vue3 Suspense 组件详解:从入门到避坑指南
vue.js
LuckyDog阿祥2 小时前
卸载Microsoft Edge:EdgeRemover完整操作指南
前端·edge
zhangxingchao2 小时前
AI应用开发九:从 Hermes Agent 学长期记忆
前端·人工智能·后端