前言
距离 Vue 3 正式发布已有数年,生态已趋于成熟。如果你已经掌握了 Vue 3 的基础用法——Composition API、ref、reactive、v-for、生命周期钩子等,那么是时候进入下一阶段了。
本文将从状态管理出发,结合工程化实践,带你搭建一套可落地的 Vue 3 企业级项目架构。全文以 Pinia 为主线,串联路由、TypeScript、API 封装、构建优化和单元测试,力求不只是讲 API,更是讲设计思路。
如果你还在使用 Options API + Vuex 3,这篇文章或许能给你一个迁移的理由。
一、Pinia vs Vuex:为什么选择 Pinia
Vuex 是 Vue 2 时代的官方状态管理方案,到了 Vue 3,官方推荐的新方案是 Pinia。两者对比如下:
| 维度 | Vuex 4 | Pinia |
|---|---|---|
| API 风格 | Options API + Mutation | Composition API 原生 |
| TypeScript 支持 | 需要大量类型体操 | 天然完整类型推断 |
| Mutation | 必需(同步 commit) | 移除,Action 直接修改 |
| 模块化 | 嵌套 Module,命名空间需手动 | 扁平 Store,天然隔离 |
| DevTools | 支持 | 支持(更友好) |
| 包体积 | ~8KB | ~1KB |
核心差异一句话:Pinia 删掉了 Vuex 中冗余的 Mutation,全面拥抱 Composition API,且类型推导几乎零成本。
以一个简单对比为例:
// Vuex 4
const store = createStore({
state: { count: 0 },
mutations: { increment(state) { state.count++ } },
actions: { async fetchCount({ commit }) { /* ... */ } }
})
// 组件中使用 computed + useStore + 映射辅助函数
// Pinia
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function increment() { count.value++ }
return { count, increment }
})
// 组件中直接 useCounterStore(),天然响应式
在团队协作中,Pinia 的 setup store 模式让逻辑组织更自然,尤其在大型项目中,每个 Store 都像一个独立的组合式函数,可读性和可维护性远超 Vuex。
二、Pinia 核心概念深入
2.1 Store 三种定义方式
Pinia 支持三种 Store 定义方式:
// 1. Options Store(类似 Vuex 习惯)
export const useUserStore = defineStore('user', {
state: () => ({ name: '', token: '' }),
getters: { isLoggedIn: (state) => !!state.token },
actions: { login(name: string) { this.name = name } }
})
// 2. Setup Store(推荐,拥抱 Composition API)
export const useUserStore = defineStore('user', () => {
const name = ref('')
const token = ref('')
const isLoggedIn = computed(() => !!token.value)
function login(userName: string) { name.value = userName }
return { name, token, isLoggedIn, login }
})
// 3. 配合 script setup 使用
// <script setup lang="ts">
// const store = useUserStore()
// </script>
推荐使用 Setup Store,它让你可以用 ref、computed、watch 等组合式 API 来组织状态逻辑,跨 Store 复用也更方便。
2.2 State 与 $patch
const store = useUserStore()
// 单个修改
store.name = 'Alice'
// 批量修改 - $patch 性能更优,只触发一次更新
store.$patch({
name: 'Bob',
token: 'abc123'
})
// 函数式 $patch(适合复杂逻辑)
store.$patch((state) => {
state.name = 'Charlie'
state.token = `token-${Date.now()}`
})
2.3 Getters 传参
Getters 本质是 computed,但也可以返回一个函数实现传参效果:
export const useProductStore = defineStore('product', () => {
const products = ref<Product[]>([])
// 普通 Getter
const cheapProducts = computed(() =>
products.value.filter(p => p.price < 100)
)
// 传参 Getter(返回函数,非缓存结果,但依赖数据响应式)
const getProductById = computed(() => {
return (id: number) => products.value.find(p => p.id === id)
})
return { products, cheapProducts, getProductById }
})
2.4 Actions 异步处理
Pinia 的 Action 可以是同步也可以是异步,没有任何特殊约定:
export const useOrderStore = defineStore('order', () => {
const orders = ref<Order[]>([])
const loading = ref(false)
async function fetchOrders(userId: string) {
loading.value = true
try {
const res = await api.getOrders(userId)
orders.value = res.data
} catch (err) {
// 统一错误处理
throw new Error(`获取订单失败: ${err}`)
} finally {
loading.value = false
}
}
return { orders, loading, fetchOrders }
})
三、模块化 Store 设计
3.1 按业务域拆分
在真实项目中,不要把所有状态塞进一个 Store。推荐按业务域拆分:
stores/
├── index.ts # 创建 pinia 实例
├── user.ts # 用户登录/权限
├── order.ts # 订单相关
├── product.ts # 商品/产品
└── app.ts # 全局 UI 状态(侧边栏、主题等)
3.2 跨 Store 调用
一个 Store 可直接引用另一个 Store,Pinia 天然支持:
// stores/order.ts
import { useUserStore } from './user'
import { useProductStore } from './product'
export const useOrderStore = defineStore('order', () => {
const orders = ref<Order[]>([])
async function placeOrder(productId: number) {
const userStore = useUserStore()
const productStore = useProductStore()
if (!userStore.isLoggedIn) {
throw new Error('请先登录')
}
const product = productStore.getProductById(productId)
if (!product || product.stock <= 0) {
throw new Error('商品库存不足')
}
const res = await api.createOrder({
userId: userStore.id,
productId,
})
orders.value.unshift(res.data)
}
return { orders, placeOrder }
})
这种设计消除了 Vuex 中 rootState 的 hack 式访问,类型推导完美。
3.3 Store 组合复用
可以用组合式函数提取公共逻辑:
// stores/utils/withPagination.ts
export function withPagination<T>() {
const list = ref<T[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
async function load(fetcher: (p: number) => Promise<{ list: T[]; total: number }>) {
const res = await fetcher(page.value)
list.value = res.list
total.value = res.total
}
return { list, page, pageSize, total, load }
}
// stores/product.ts
export const useProductStore = defineStore('product', () => {
const pagination = withPagination<Product>()
// 直接使用 pagination.list, pagination.load 等
return { ...pagination }
})
四、Vue Router 4 深入
4.1 路由守卫最佳实践
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { guest: true }, // 仅未登录可访问
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true }, // 需登录
},
{
path: '/admin',
name: 'Admin',
component: () => import('@/views/Admin.vue'),
meta: { requiresAuth: true, role: 'admin' },
},
],
})
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
// 自动恢复登录态(如从 localStorage 读取 token)
if (!userStore.initialized) {
await userStore.restoreSession()
}
if (to.meta.requiresAuth && !userStore.isLoggedIn) {
return next({ name: 'Login', query: { redirect: to.fullPath } })
}
if (to.meta.guest && userStore.isLoggedIn) {
return next({ name: 'Dashboard' })
}
if (to.meta.role && userStore.role !== to.meta.role) {
return next({ name: 'Forbidden' })
}
next()
})
4.2 路由懒加载与分包
const routes = [
{
path: '/orders',
component: () => import('@/views/OrderList.vue'),
// 配合 webpackChunkName / Vite 的 rollupOptions 实现命名分包
},
// 嵌套路由懒加载
{
path: '/user',
component: () => import('@/layouts/UserLayout.vue'),
children: [
{ path: 'profile', component: () => import('@/views/user/Profile.vue') },
{ path: 'settings', component: () => import('@/views/user/Settings.vue') },
],
},
]
4.3 动态路由(权限路由)
// 登录后根据角色动态添加路由
const asyncRoutes = [
{
path: '/admin/users',
name: 'AdminUsers',
component: () => import('@/views/admin/Users.vue'),
meta: { role: 'admin' },
},
]
export function setupDynamicRoutes(role: string) {
const router = useRouter()
const routes = asyncRoutes.filter(r => r.meta.role === role)
routes.forEach(route => {
// 避免重复添加
if (!router.hasRoute(route.name!)) {
router.addRoute(route)
}
})
}
五、TypeScript 在 Vue 3 中的最佳实践
5.1 组件 Props 类型安全
// ✅ 推荐:使用 defineProps 泛型
<script setup lang="ts">
interface UserCardProps {
user: {
id: number
name: string
avatar?: string
}
showEmail?: boolean
}
const props = defineProps<UserCardProps>()
// 定义 emits
const emit = defineEmits<{
(e: 'click', id: number): void
(e: 'delete', id: number): void
}>()
</script>
5.2 为 Store 定义接口
// types/index.ts
export interface User {
id: number
name: string
email: string
role: 'user' | 'admin'
}
export interface LoginPayload {
email: string
password: string
}
export interface ApiResponse<T> {
code: number
message: string
data: T
}
// stores/user.ts
export const useUserStore = defineStore('user', () => {
const currentUser = ref<User | null>(null)
async function login(payload: LoginPayload): Promise<void> {
const res = await api.post<ApiResponse<User>>('/auth/login', payload)
currentUser.value = res.data.data
}
return { currentUser, login }
})
5.3 组合式函数类型
// composables/useCounter.ts
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const double = computed(() => count.value * 2)
function increment() { count.value++ }
return { count, double, increment }
}
// 组件中使用时,count.value 自动推断为 number
六、Axios 封装与 API 层设计
6.1 基础封装
// utils/http.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
const http: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE,
timeout: 10_000,
})
// 请求拦截器:注入 Token
http.interceptors.request.use((config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
})
// 响应拦截器:统一错误处理
http.interceptors.response.use(
(res) => res,
(error: AxiosError<{ message: string }>) => {
if (error.response?.status === 401) {
const userStore = useUserStore()
userStore.logout()
window.location.href = '/login'
return Promise.reject(error)
}
ElMessage.error(error.response?.data?.message || '请求失败')
return Promise.reject(error)
}
)
export default http
6.2 模块化 API
// api/user.ts
import http from '@/utils/http'
import type { ApiResponse, User, LoginPayload } from '@/types'
export const userApi = {
login(data: LoginPayload) {
return http.post<ApiResponse<{ token: string; user: User }>>('/auth/login', data)
},
getProfile() {
return http.get<ApiResponse<User>>('/user/profile')
},
updateProfile(data: Partial<User>) {
return http.put<ApiResponse<User>>('/user/profile', data)
},
}
// api/order.ts
export const orderApi = {
getList(params: { page: number; pageSize: number }) {
return http.get<ApiResponse<Order[]>>('/orders', { params })
},
create(data: CreateOrderPayload) {
return http.post<ApiResponse<Order>>('/orders', data)
},
}
七、项目架构:从零搭建企业级 Vue 3 项目
7.1 推荐目录结构
src/
├── api/ # API 层(按业务模块拆分)
├── assets/ # 静态资源(图片、样式)
├── components/ # 公共组件
│ ├── common/ # 基础组件(Button、Table 等)
│ └── business/ # 业务组件(UserCard、OrderList 等)
├── composables/ # 组合式函数
├── layouts/ # 布局组件
├── router/ # 路由配置(含导航守卫)
├── stores/ # Pinia Store
├── types/ # TypeScript 类型定义
├── utils/ # 工具函数
├── views/ # 页面级组件
│ ├── login/
│ ├── dashboard/
│ └── admin/
├── App.vue
└── main.ts
7.2 入口文件规范
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import 'virtual:svg-icons-register'
import './styles/global.scss'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
八、构建优化:Vite 配置与代码分割
8.1 Vite 配置推荐
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
vue(),
// 自动导入 API(ref、computed 等无需手动 import)
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()],
}),
// 自动注册组件
Components({
resolvers: [ElementPlusResolver()],
}),
// 打包体积分析
visualizer({ open: false }),
],
build: {
rollupOptions: {
output: {
// 代码分割:第三方库分包
manualChunks: {
'vendor-vue': ['vue', 'vue-router', 'pinia'],
'vendor-ui': ['element-plus'],
'vendor-utils': ['axios', 'dayjs', 'lodash-es'],
},
},
},
// Tree-shaking
treeshake: {
preset: 'recommended',
},
// 移除 console/log
minify: 'esbuild',
sourcemap: false,
},
// 路径别名
resolve: {
alias: { '@': '/src' },
},
})
8.2 路由级代码分割
Vite 默认支持动态导入的代码分割,配合 manualChunks 可以实现精细的缓存策略。
// 构建产物示例
dist/
├── assets/
│ ├── index.abc123.js # 主入口
│ ├── Login.def456.js # 登录页
│ ├── Dashboard.ghi789.js # 仪表盘
│ ├── vendor-vue.jkl012.js # Vue 相关
│ └── vendor-ui.mno345.js # UI 库
九、单元测试:Vitest + Vue Test Utils
9.1 环境配置
pnpm add -D vitest @vue/test-utils jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
},
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
})
9.2 测试 Pinia Store
// stores/__tests__/user.spec.ts
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '../user'
import { describe, it, expect, beforeEach } from 'vitest'
describe('useUserStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('初始状态为未登录', () => {
const store = useUserStore()
expect(store.isLoggedIn).toBe(false)
expect(store.currentUser).toBeNull()
})
it('登录后更新状态', async () => {
const store = useUserStore()
await store.login({ email: 'test@example.com', password: '123456' })
expect(store.isLoggedIn).toBe(true)
expect(store.currentUser?.name).toBe('Test User')
})
})
9.3 测试组件
// components/__tests__/UserCard.spec.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import UserCard from '../UserCard.vue'
describe('UserCard', () => {
it('正确渲染用户信息', () => {
const wrapper = mount(UserCard, {
props: {
user: { id: 1, name: 'Alice', avatar: '' },
showEmail: false,
},
})
expect(wrapper.text()).toContain('Alice')
expect(wrapper.find('.email').exists()).toBe(false)
})
it('点击触发 emit', async () => {
const wrapper = mount(UserCard, {
props: { user: { id: 1, name: 'Alice' } },
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')?.[0]).toEqual([1])
})
})
9.4 测试异步 Action
// stores/__tests__/order.spec.ts
import { setActivePinia, createPinia } from 'pinia'
import { useOrderStore } from '../order'
import { vi, describe, it, expect, beforeEach } from 'vitest'
// Mock API
vi.mock('@/api/order', () => ({
orderApi: {
getList: vi.fn().mockResolvedValue({
data: { code: 0, data: [{ id: 1, amount: 100 }], total: 1 },
}),
},
}))
describe('useOrderStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('fetchOrders 填充订单列表', async () => {
const store = useOrderStore()
expect(store.orders).toHaveLength(0)
await store.fetchOrders('user-1')
expect(store.orders).toHaveLength(1)
expect(store.orders[0].amount).toBe(100)
})
})
十、总结与展望
本文涵盖了 Vue 3 进阶开发的核心拼图:
| 领域 | 关键要点 |
|---|---|
| 状态管理 | Pinia 替代 Vuex,Setup Store 模式,跨 Store 调用 |
| 路由 | 路由守卫、懒加载、动态权限路由 |
| TypeScript | 类型安全的 Props、Store、API |
| API 层 | Axios 拦截器封装、模块化 API |
| 构建优化 | Vite 分包、自动导入、Tree-shaking |
| 测试 | Vitest + Pinia Store 测试 + 组件测试 |
Vue 3 生态目前已经非常成熟,配合 Vite 的开发体验更是愉悦。建议你在下一个项目中:
- 拥抱 Composition API +
<script setup>,这是 Vue 3 的灵魂 - 用 Pinia 替代 Vuex,减少心智负担
- TypeScript 全程参与,小项目也值得
- 尽早引入测试,Vitest 的体验让写测试不再痛苦
Vue 3 的设计哲学是 "渐进式" 的,你不需要一次性掌握所有内容。从 Pinia 开始迁移,逐步加入 TypeScript,再优化构建和测试,每一步都有实实在在的收益。
如果你觉得这篇文章有帮助,欢迎点赞收藏,也欢迎在评论区交流你在 Vue 3 项目中的实践经验!
本文首发于 [Blog],转载请注明出处。
评论