前言
Vue 3 作为 Vue.js 的重大版本更新,带来了 Composition API、更好的 TypeScript 支持、性能优化等诸多改进。然而,在从 Vue 2 迁移或直接上手 Vue 3 的过程中,开发者们常常会遇到一些"坑"。本文旨在汇总这些常见问题,并提供详细的解决方案和最佳实践,帮助你更顺畅地使用 Vue 3。
1. 响应式系统 (Reactivity) 的"坑"
Vue 3 使用 Proxy 重构了响应式系统,虽然更强大,但也带来了新的理解和使用门槛。
1.1 解构响应式对象导致失去响应性
这是最常见的错误之一。直接解构 reactive 或 ref 的 .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
watch 和 watchEffect 功能强大,但使用不当会导致性能问题或无限循环。
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 路由组件内获取路由参数和查询
使用 useRoute 和 useRouter 组合式函数。
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