前言

距离 Vue 3 正式发布已有数年,生态已趋于成熟。如果你已经掌握了 Vue 3 的基础用法——Composition API、refreactivev-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,它让你可以用 refcomputedwatch 等组合式 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 的开发体验更是愉悦。建议你在下一个项目中:

  1. 拥抱 Composition API + <script setup>,这是 Vue 3 的灵魂
  2. 用 Pinia 替代 Vuex,减少心智负担
  3. TypeScript 全程参与,小项目也值得
  4. 尽早引入测试,Vitest 的体验让写测试不再痛苦

Vue 3 的设计哲学是 "渐进式" 的,你不需要一次性掌握所有内容。从 Pinia 开始迁移,逐步加入 TypeScript,再优化构建和测试,每一步都有实实在在的收益。

如果你觉得这篇文章有帮助,欢迎点赞收藏,也欢迎在评论区交流你在 Vue 3 项目中的实践经验!


本文首发于 [Blog],转载请注明出处。