刚开始把项目从 Vue 2 往 Vue 3.0 迁移时,我犯过一个特别典型的错误:把原本 Options API 那套写法原封不动塞进 setup 里。结果组件膨胀得比以前还快,一个 setup 里塞了两百行逻辑,同事看我的眼神都不太对劲了。后来我把 Vue 3.0 全家桶完整过了一遍,才意识到问题根本不在语法,而在于整套思维模型已经变了——从“用选项组织代码”变成了“用逻辑组织代码”。
这篇指南,就是把我整理后的组合式 API、响应式 API、Vite、Pinia、Router 和 TypeScript 实践一次性梳理清楚。不打算复述官方文档,只讲我在真实项目里验证过的用法、踩过的坑和取舍判断。适合两类人:准备从 Vue 2 切入 Vue 3 新项目的前端,以及已经写了半年多 Vue 3、却总觉得整体架构不顺手、想重新梳理一遍的开发者。
1. 组合式API不是写法变化,而是组件逻辑的编排方式升级
1.1 setup 到底替代了什么:生命周期视角的重新理解
很多刚从 Vue 2 过来的人都会问一句:setup 是不是就是 created 换个名字?真不是。setup 在组件实例创建之前执行,所以它里面没有 this,这是最容易让人不适的一点。原来在 created 里写this.fetchData()的地方,现在要写成fetchData(),这个差别看起来小,但它逼你把逻辑从“依赖 this 上的魔法”转向“依赖显式传入的参数和返回值”。
生命周期也跟着变了:beforeCreate和created直接由 setup 取代,mounted对应onMounted,beforeDestroy改成onBeforeUnmount,destroyed改成onUnmounted。这里我有一张常用的对照表,贴给团队新人非常省事:
| Vue 2 选项 | Vue 3 组合式写法 | 触发时机 |
|---|---|---|
| beforeCreate / created | setup | 组件实例创建前,props 已解析 |
| mounted | onMounted | DOM 挂载完成后 |
| beforeUpdate | onBeforeUpdate | 数据变化后、DOM 更新前 |
| updated | onUpdated | DOM 更新完成后 |
| beforeDestroy | onBeforeUnmount | 卸载前 |
| destroyed | onUnmounted | 卸载后 |
组合式 API 真正的价值,是把原来散落在 data、computed、methods、watch 里的同一段业务逻辑聚合到一起。Vue 2 的 Options API 强制按压碎分类组织代码,当一个组件里有搜索、筛选、分页、权限判断一堆功能时,你要在几个区块之间来回跳。组合式 API 允许你以功能为单位写代码,搜索相关的状态和方法放一起,分页相关的放一起,这才是它存在的意义。
1.2 用 composable 替代 mixin 的逻辑复用
Vue 2 时代做逻辑复用,大部分人第一反应是 mixin。mixin 的问题有三:命名冲突、来源不明、难以测试。一个组件同时混入两个定义了data里同名变量的 mixin,运行时不报错,但行为已经不可预测;你看到一个fetchList方法,根本不知道它来自哪个 mixin;测试的时候还得把 mixin 的依赖链一起关注起来。
组合式 API 的答案是 composable,本质就是一个普通函数。我举一个最简单的计数器复用:
export function useCounter(initialValue = 0) { const count = ref(initialValue) const increment = () => count.value++ const decrement = () => count.value-- return { count, increment, decrement } }组件里这样用:
<script setup lang="ts"> import { useCounter } from '@/composables/useCounter' const { count, increment } = useCounter(10) </script> <template> <button @click="increment">{{ count }}</button> </template>这个例子看着简单,但它揭示了 composable 的核心:一个独立的逻辑单元,输入是参数,输出是响应式状态和行为方法。没有隐式共享,没有命名空间污染,测试时也可以直接当做普通函数调用。
1.3 拆分 composable 的边界经验
用了一段时间组合式 API 后,我给自己定了三条拆分原则。
第一,一个 composable 只解决一件事。useUserInfo只管用户信息的状态和刷新逻辑,useTableList只管列表加载、分页参数和筛选条件,不要在useUserInfo里顺手做权限判断。第二,如果 composable 内部管理多个强相关状态,优先返回 ref 而不是 reactive。ref 解构后照样响应式,使用者拿到count和increment直接用,没有任何心智负担。第三,凡是创建了监听器、定时器、DOM 事件的 composable,必须在内部提供卸载清理逻辑,或者在文档注释里明确提醒调用方调用onUnmounted清理。
还有一个小细节很多人会忽略:setup 只在组件初始化时执行一次。如果你在 composable 里做异步请求,比如:
const { list, load } = useTableList() onMounted(() => load()) // 而不是在 composable 内部直接 load()这样做的好处是请求时机由组件把控,composable 只负责提供能力和状态,便于复用和测试。
2. ref、reactive与toRefs:响应式API选型的判断依据与踩坑记录
2.1 ref 和 reactive 的分工比想象中更微妙
Vue 3 的响应式底层是 Proxy,ref 和 reactive 在运行时都会被包装成 Proxy 对象。它们的设计意图不同:reactive 面向对象,把整个对象的深层属性都代理掉;ref 则是给基础类型一个容器,内部包一个{ value: xxx }的结构,通过.value读写。
但在模板里,ref 会自动解包,写{{ count }}就行,不用写{{ count.value }}。这个“自动解包”是好意,也造成了混乱——很多新人一会儿写.value一会儿不写,全靠试。我自己在写代码时的处理原则是:在<script setup>里访问就统一带.value,在模板里一律不带,用习惯消除歧义。
关于选型,我见过两种主流方案:
- 全 ref 流:所有状态都用
ref声明,包括对象。优点是类型统一、解构安全、心智负担小。 - reactive 流:对象用
reactive,基础类型用ref。优点是语义清晰,写起来更像 Vue 2 的 data。
| 对比项 | ref | reactive |
|---|---|---|
| 适用数据类型 | 任意,包括对象 | 仅对象 / 数组 |
| 访问方式 | 脚本中.value,模板自动解包 | 直接.属性 |
| 解构响应性 | 解构后仍然响应式 | 解构后丢失,需配合 toRefs |
| TypeScript 推导 | 类型明确 | 对复杂类型推导有时偏“宽” |
我现在的团队统一用“全 ref 为主,个别场景 reactive”的方案,因为代码审查时只需要关注.value是否写错即可,规则简单。
2.2 解构丢失响应性的三大高频场景
这个坑我至少教过三个新人。第一个场景最经典:
const state = reactive({ count: 0, name: 'vue' }) const { count } = state // 丢失响应性想要保住响应性,要用toRefs:
const { count, name } = toRefs(state)第二个场景出现在 props 上。Vue 3 的 props 本身是响应式的,但你直接解构:
const props = defineProps<{ visible: boolean }>() const { visible } = props // 丢失响应性正确做法是用toRef单独取某个 prop,或者干脆别解构,在模板里直接用props.visible。如果你特别需要解构,可以toRef(props, 'visible')。
第三个场景是 Pinia store 的解构,后文会专门讲storeToRefs。这三个场景本质都是同一个问题:响应式状态必须通过代理访问才能触发依赖收集,解构成普通变量后就绕过了 Proxy。
2.3 watch 和 watchEffect 的触发时机怎么控制
watch和watchEffect的选择也常让人纠结。简单说:watch需要显式指定数据源,只有这个源变化时才回调;watchEffect会自动收集回调里引用的所有响应式依赖,任何依赖变化都会执行。watchEffect写起来爽,但它更像一个“副作用注册器”,依赖一变就跑,如果你的回调里有网络请求,很容易造成频繁触发。
我通常这样分工:需要监听特定状态变化,比如“地址切换后重新拉列表”,用watch明确指定() => address.value;需要把一些逻辑被动地跟很多状态联动,比如“把当前搜索条件同步到 URL query”,用watchEffect。
还有一个容易忽略的flush选项:
watch(source, async (newVal) => { const dom = await nextTick() dom.scrollTop = 0 }) // 或者更明确地 watch(source, (newVal) => { // 此时 DOM 已经更新 }, { flush: 'post' })默认情况下 watch 回调在组件更新前触发,如果你想在回调里访问更新后的 DOM,就要设置flush: 'post',或者直接在回调里await nextTick()。这个细节在写滚动定位、编辑器同步这类场景时会卡你很久。
3. Vite构建链路:冷启动、热更新、环境变量和生产部署的取舍
3.1 Vite为什么能快:开发态与生产态的两套逻辑
如果你从 webpack 时代过来,第一次打开 Vite 项目的感受肯定是:这也太快了。根本原因是 Vite 开发服务器直接基于浏览器原生 ES Module,冷启动时只做依赖预构建,不打包整个应用。浏览器请求哪个模块,Vite 才编译哪个模块。而 webpack 启动时要先遍历整个依赖图,把一切打包成 bundle,项目越大启动越慢。
但要注意,Vite 生产构建走的是 Rollup,和开发时的加载逻辑不同。这种双轨制在绝大多数场景下不会给你惹麻烦,真正需要注意的是依赖的格式兼容。开发时 Vite 会把 CommonJS 依赖预构建成 ESM,如果某个第三方库同时导出了 ESM 和 CJS,配置优先级以你package.json里的exports字段为准。
还有一个很实际的注意点:Vite 5 和之前版本对 Node 版本要求不同,老项目升级前先确认环境。部署时如果服务器上 Node 版本太低,构建阶段就会直接报错,这种问题常常要排查半天才发现是版本不匹配。
3.2 import.meta.env 与模式匹配:多环境配置的正确姿势
Vite 的环境变量有一套约定,很容易被忽略:只有以VITE_开头的变量才会暴露给客户端代码。服务端无关的变量不会进import.meta.env,这是避免把密钥意外打包进前端的重要防线。
实际项目中我一般准备三个文件:
.env.development # 开发环境默认 .env.production # 生产环境默认 .env.test # 测试环境,运行 --mode test 时生效.env.development里写:
VITE_API_BASE=/api VITE_APP_TITLE=本地环境代码里这样用:
const apiBase = import.meta.env.VITE_API_BASE if (import.meta.env.DEV) { // 开发环境逻辑 } if (import.meta.env.PROD) { // 生产环境逻辑 }有一个小坑我必须提醒:修改.env文件后必须重启 dev server,不是热更新能搞定的。因为环境变量在编译期就被内联替换到代码里了。另外,import.meta.env是一个扁平对象,你拿不到不存在的变量时是undefined,别用import.meta.env.VITE_SOME ?? ''这种默认值写法,最好显式判断。
3.3 常见的 Vite 配置坑:alias、base 与 HMR失效
路径别名@是标配,但只配 Vite 不够,tsconfig 也要同步:
// vite.config.ts resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }// tsconfig.json { "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }如果两者不一致,编辑器可能不报错,但vue-tsc类型检查或生产构建时会突然挂掉。
base配置则和部署路径强相关。如果前端资源部署在域名子路径下,比如https://example.com/admin/,就必须设置base: '/admin/'。忘记配 base 的表现是页面能访问 HTML,但 JS、CSS、图片全部 404,这个错误在实际项目里出现过好多次。
HMR 失效的原因多数不在 Vite 配置,而在项目结构。我遇到最多的几种:组件文件用了大小写不一致的路径引用、循环依赖导致模块图更新不完整、以及手动修改了node_modules里的代码。如果你在node_modules里临时改了东西做验证,记得之后删掉,否则 HMR 可能会一直不更新或者报奇怪的错。
4. Pinia 接棒 Vuex:状态管理从模板化到组合化的关键变化
4.1 没有 mutations 之后代码发生了什么变化
Vuex 时代的要求是:改 state 必须同步 commit mutation,异步逻辑放 action 里再 commit。这套约束对大型团队有审计价值,但小团队更容易感受到的是无意义的样板代码。Pinia 直接去掉了 mutations,在 action 里可以同步改 state,状态变更逻辑归拢到 action 一个通道,同时也保留了 devtools 的时间旅行调试能力。
但这不意味着你可以毫无约束地改状态。我的团队约定是:组件里只允许通过 store 暴露的 action 修改状态,读状态可以走 storeToRefs 解构,写状态一律调用 action。这条约定不靠框架强制,靠 code review 执行,效果比 Vuex 的 mutations 更好——因为代码量少了,审查者会更有耐心去关注实际逻辑。
Pinia 的第二个大变化是模块化更轻量。Vuex 要写modules: { user: userModule },Pinia 直接一个defineStore定义一个 store,按需导入,天然模块化。配合 tree-shaking,store 没被用到就不会打包进产物。
4.2 setup Store 写法和选项式 Store 应该选哪个
Pinia 提供了两种写法:选项式和 setup 式。选项式长这样:
export const useUserStore = defineStore('user', { state: () => ({ name: '', token: '' }), getters: { isLogin: (state) => !!state.token }, actions: { login(token: string, name: string) { this.token = token this.name = name } } })setup 式长这样:
export const useUserStore = defineStore('user', () => { const name = ref('') const token = ref('') const isLogin = computed(() => !!token.value) function login(newToken: string, newName: string) { token.value = newToken name.value = newName } return { name, token, isLogin, login } })两种我都用过。选项式对新手更友好,结构固定,getters 还能自动缓存;setup 式更贴近组合式 API 的思维,特别是当你需要在 store 里复用已有 composable 时,setup 式可以直接调用外部函数,非常自然。比如:
export const useOrderStore = defineStore('order', () => { const { list, load } = useTableList() // 复用 composable return { list, load } })这个能力是选项式做不到的。我的建议是新项目统一用 setup 式,给团队带来的思维方式迁移成本最低。
4.3 跨 store 调用、异步 action 与持久化
setup 式 store 里调用另一个 store 很直接:
export const useCartStore = defineStore('cart', () => { const items = ref([]) async function checkout() { const userStore = useUserStore() if (!userStore.isLogin) { // 引导登录 } // 提交订单逻辑 } return { items, checkout } })跨 store 调用通常发生在业务联动场景,比如去结算时必须拿到用户信息,直接用useUserStore()即可,不需要额外的事件总线或复杂参数传递。异步 action 里直接用async/await,组件里await store.checkout()就能拿到 Promise 的结果,错误处理也一样,用 try/catch 包住调用处即可。
持久化问题几乎是每个中后台项目都要面对的。我自己的做法是写一个极简插件:store 里监听关键状态,变化时同步到 localStorage,初始化时读取。第三方持久化插件虽然方便,但偶尔会遇到版本不兼容,而且它默认的序列化策略对嵌套比较深的响应式对象不友好。简单场景自己写一个不过几十行,出问题也好排查。
5. Vue Router 4 的权限路由:守卫返回值、动态 addRoute 和刷新恢复
5.1 路由组织与组件加载的现代写法
Vue Router 4 的入门配置不复杂,但实际项目里我建议用import.meta.glob批量加载视图组件,避免写几十个 import 语句:
// router/index.ts const viewModules = import.meta.glob('../views/**/*.vue') function loadView(path: string) { return viewModules[`../views/${path}.vue`] }这样做的好处是:新增页面时不需要再改路由文件顶部的导入区域,路由配置里直接写组件路径字符串即可。配合后台配置的动态菜单,这个模式几乎是最好的选择。
404 兜底路由的写法也和 Vue 2 不一样:
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/error/NotFound.vue') }/:pathMatch(.*)*表示匹配所有未匹配路径,这个路由一定要放在最后。
5.2 守卫返回值的坑:false、true 和路由对象分别意味着什么
Vue Router 4 的全局前置守卫beforeEach的返回值决定导航行为,很多人会忽略这里隐含的规则:
| 返回值 | 行为 |
|---|---|
| true / undefined | 放行 |
| false | 取消当前导航,停在原路由 |
| 路由对象 / 字符串路径 | 重定向到该路由 |
最常见的坑是死循环。比如:
router.beforeEach((to) => { if (to.path === '/login') return true if (!isLogin) return { path: '/login' } })这段逻辑本身没问题,但如果你在{ path: '/login' }后没有加“已登录就跳首页”的判断,而是统一返回登录页,就可能在某些场景下循环重定向。定位死循环的土办法是在守卫开头加一行console.log(to.path),看路径序列就能看出循环点。
还有一个值得注意的细节:守卫里如果触发了异步操作,比如去后端拉用户权限,必须保证守卫回调在异步完成后再返回最终结果。别在异步回调外面同步 return,否则守卫立即放行,权限还没加载完。
5.3 动态路由与刷新恢复:最常见的权限方案落地
中后台系统几乎都要求按角色显示菜单和页面。常规做法是登录后根据用户权限列表,动态调用router.addRoute():
// 假设从后端拿到权限路由配置 const permissionRoutes = await fetchPermissionRoutes() for (const item of permissionRoutes) { router.addRoute({ path: item.path, name: item.name, component: loadView(item.component) }) }但这套方案有一个必踩的坑:刷新页面后,点击登录后的动态路由会全部丢失,因为router.addRoute是在 JS 运行时添加的,刷新后内存重新初始化,动态路由自然就没了。
解决思路是把“权限路由配置数据”持久化。刷新后,在app启动的守卫里判断:如果本地已经存过权限配置,且当前路由还没注册,就重新addRoute,然后用router.replace(to.fullPath)重新进入目标路由。这里的关键是加一个“路由是否已注册”的判断,否则每次导航都会重复注册同名路由,控制台会报警告。
我实际落地的伪代码大概这样:
const ROUTER_HAS_DYNAMIC = Symbol('router_has_dynamic') router.beforeEach(async (to) => { const hasStore = localStorage.getItem('permissionRoutes') if (hasStore && !router.hasRoute(ROUTER_HAS_DYNAMIC)) { const routes = JSON.parse(hasStore) for (const item of routes) { router.addRoute(item) } router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404' }) return to.fullPath // 重新进入目标路径 } return true })这段代码不够完整,但思路是核心:持久化权限路由、判断是否已注入、刷新后重新注入并重放一次导航。
6. TypeScript 和 script setup 的组合实践:类型真的是省出来的
6.1 用类型声明代替运行时校验,defineProps 和 defineEmits 应该这么写
<script setup>里定义 props 有几种写法,我强烈推荐纯类型声明:
<script setup lang="ts"> interface Props { visible: boolean title?: string } const props = withDefaults(defineProps<Props>(), { title: '默认标题' }) </script>withDefaults用来设置默认值,类型检查依然生效。这里注意一个坑:如果你要导入外部类型,必须用import type,否则打包时可能把类型当作运行时依赖带进去。
emits 的类型声明相对繁琐,但写习惯后就发现它可以避免误传事件名:
<script setup lang="ts"> const emit = defineEmits<{ (e: 'update:visible', value: boolean): void (e: 'delete', id: number): void }>() emit('update:visible', false) </script>6.2 组件 ref、defineExpose 与实例类型推导
父组件要调用子组件暴露的方法,是很多人的盲区。Vue 2 时代this.$refs.child.someMethod()随便调,Vue 3 默认情况下子组件的内部状态和方法不会暴露给父组件,必须用defineExpose显式声明:
<!-- 子组件 --> <script setup lang="ts"> function scrollToTop() { // ... } defineExpose({ scrollToTop }) </script>父组件里获取类型安全的 ref:
import ChildComp from '@/components/ChildComp.vue' const childRef = ref<InstanceType<typeof ChildComp> | null>(null) function handleClick() { childRef.value?.scrollToTop() }注意InstanceType拿到的是子组件暴露出来的实例类型,而不是子组件内部所有的属性和方法。如果你没写defineExpose,父组件这个 ref 拿到的可能是一个没有任何业务方法的空壳。很多项目 TS 报错查不到原因,最后发现就是少了这一行。
还有一个从 Vue 2 带过来的习惯要改掉:不要在onMounted里立刻调用子组件方法。子组件挂载虽然完成了,但内部可能还在做异步初始化,直接调用容易拿到 undefined。更稳妥的方式是子组件内部把“初始化完成”作为一个可监听状态暴露出来,父组件 watch 它再执行后续动作。
6.3 模板里的类型推断与常见编译器报错
vue-tsc比普通 TS 检查严格很多,常见的报错集中在两类。第一类是事件参数类型,模板里@change="handleChange",如果 handleChange 声明为(value: string) => void,但@change传的是Event对象,类型就不匹配。解决办法是:
function handleChange(event: Event) { const value = (event.target as HTMLInputElement).value }第二类是关于v-model自定义的。Vue 3.4 之后的defineModel虽然好用,但考虑兼容性,我目前仍然偏好标准写法:
<!-- 子组件 --> <script setup lang="ts"> const props = defineProps<{ visible: boolean }>() const emit = defineEmits<{ (e: 'update:visible', value: boolean): void }>() function close() { emit('update:visible', false) } </script>父组件直接用v-model:visible="showDialog"。这套写法从 Vue 3.0 到最新版都完全兼容,不会踩到版本差异的坑。
关于 TypeScript 在 Vue 3 里的体会,我就一句话:宁可前期多花半小时把类型写对,也不要用any先糊过去。因为 Vue 的模板类型推导链是靠组件 props、emits、ref 的类型声明串联起来的,任意一个any都会切断这条链,后续排查成本会成倍上升。
最后再分享一点我个人的体会。组合式 API 确实更灵活,但它对写代码的人的自律要求明显更高。Options API 自带强制分层,哪怕写得再乱,至少数据、方法、计算属性各有归处;组合式 API 如果不注意拆分,setup 很容易变成一个没有结构的长函数。我现在面对规模很小的页面组件时,依然会考虑用 Options API 或简单的 setup 直接写,不是因为它“适合新手”,而是它自带约束。Vue 3.0 真正带来的,是“你可以选择怎么组织代码”的自由,而不是“你必须用某一种写法”的命令。这种自由度,才是全家桶体系中最容易被低估的一部分。