普通视图

发现新文章,点击刷新页面。
昨天以前首页

Pinia 状态管理实战 | 从 0 到 1 搭建 Vue3 项目状态层(附模块化 / 持久化)

作者 代码煮茶
2026年3月16日 17:02

Pinia 状态管理实战 | 从 0 到 1 搭建 Vue3 项目状态层(附模块化 / 持久化)

一、为什么是 Pinia?

还记得 Vuex 吗?那个陪伴我们多年的状态管理库,有着严格的 mutations、actions 分工,写起来像在写 Java——虽然严谨,但也繁琐。

// Vuex 时代的痛
mutations: {
  SET_USER(state, user) {
    state.user = user
  }
},
actions: {
  async fetchUser({ commit }) {
    const user = await api.getUser()
    commit('SET_USER', user) // 绕了一大圈
  }
}

而 Pinia 来了,它说:「简单点,写代码的方式简单点」

// Pinia 的快乐
export const useUserStore = defineStore('user', {
  state: () => ({ user: null }),
  actions: {
    async fetchUser() {
      this.user = await api.getUser() // 直接赋值,爽!
    }
  }
})

1.1 Pinia 的核心优势

特性 Vuex Pinia
mutations ✅ 必须写 ❌ 没了
TypeScript 支持 😖 痛苦 😎 原生支持
代码量 少 30%
学习曲线 陡峭 平缓
DevTools ✅ 更好

二、项目初始化:从 0 开始搭建状态层

承接上一节的 Vite 项目,我们来深度拆解状态管理。

2.1 安装 Pinia

npm install pinia
npm install pinia-plugin-persistedstate # 持久化插件(后面会讲)

2.2 在 main.ts 中注册

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

// 创建 Pinia 实例
const pinia = createPinia()

const app = createApp(App)

// 注册插件(顺序很重要:先 Pinia,后路由)
app.use(pinia)
app.use(router)

app.mount('#app')

三、Store 的两种写法:你pick哪一种?

Pinia 支持两种 Store 定义方式,就像 Vue 有 Options API 和 Composition API 一样。

3.1 Options Store(类似 Vuex 风格)

// stores/counter.ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // state:数据源
  state: () => ({
    count: 0,
    name: '计数器'
  }),
  
  // getters:计算属性
  getters: {
    doubleCount: (state) => state.count * 2,
    // 使用 this 访问其他 getter
    displayText(): string {
      return `${this.name}: ${this.count} (翻倍后: ${this.doubleCount})`
    }
  },
  
  // actions:方法(支持同步异步)
  actions: {
    increment(amount = 1) {
      this.count += amount
    },
    async fetchAndSetCount() {
      // 模拟异步请求
      const res = await fetch('/api/count')
      const data = await res.json()
      this.count = data.count
    }
  }
})

3.2 Setup Store(Composition API 风格)⭐推荐

// stores/counter.ts (Setup Store)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // state:用 ref/reactive
  const count = ref(0)
  const name = ref('计数器')
  
  // getters:用 computed
  const doubleCount = computed(() => count.value * 2)
  const displayText = computed(() => {
    return `${name.value}: ${count.value} (翻倍后: ${doubleCount.value})`
  })
  
  // actions:普通函数
  function increment(amount = 1) {
    count.value += amount
  }
  
  async function fetchAndSetCount() {
    const res = await fetch('/api/count')
    const data = await res.json()
    count.value = data.count
  }
  
  // 必须返回所有暴露的内容
  return {
    count,
    name,
    doubleCount,
    displayText,
    increment,
    fetchAndSetCount
  }
})

为什么推荐 Setup Store?

  • 更灵活,可以组合复用逻辑
  • TypeScript 类型推导更好
  • 符合 Vue3 Composition API 的心智模型

四、模块化设计:把大象装进冰箱分几步?

企业级项目最忌讳「一个大 Store 管所有」。正确的姿势是:按业务模块拆分

4.1 推荐的项目结构

src/stores/
├── index.ts              # 统一导出
├── modules/
│   ├── user.ts           # 用户模块
│   ├── cart.ts           # 购物车模块
│   ├── product.ts        # 商品模块
│   └── app.ts            # 应用配置(主题/语言等)
├── composables/          # 可复用的组合逻辑
│   ├── useAuth.ts
│   └── useCache.ts
└── plugins/              # Pinia 插件
    └── logger.ts

4.2 用户模块(完整示例)

// stores/modules/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo, LoginParams } from '@/types/user'
import { loginApi, getUserInfoApi } from '@/api/user'
import { ElMessage } from 'element-plus'

export const useUserStore = defineStore('user', () => {
  // --- State ---
  const token = ref<string | null>(localStorage.getItem('token'))
  const userInfo = ref<UserInfo | null>(null)
  const permissions = ref<string[]>([])
  
  // --- Getters ---
  const isLoggedIn = computed(() => !!token.value)
  const userName = computed(() => userInfo.value?.name || '游客')
  const userRole = computed(() => userInfo.value?.role || 'guest')
  const hasPermission = computed(() => (perm: string) => {
    return permissions.value.includes(perm) || userRole.value === 'admin'
  })
  
  // --- Actions ---
  // 登录
  async function login(params: LoginParams) {
    try {
      const res = await loginApi(params)
      token.value = res.token
      userInfo.value = res.userInfo
      permissions.value = res.permissions || []
      
      // 同步到 localStorage
      localStorage.setItem('token', res.token)
      
      ElMessage.success('登录成功')
      return true
    } catch (error) {
      ElMessage.error('登录失败:' + (error as Error).message)
      return false
    }
  }
  
  // 登出
  function logout() {
    token.value = null
    userInfo.value = null
    permissions.value = []
    localStorage.removeItem('token')
    ElMessage.success('已退出登录')
  }
  
  // 获取用户信息
  async function fetchUserInfo() {
    if (!token.value) return
    
    try {
      const res = await getUserInfoApi()
      userInfo.value = res.userInfo
      permissions.value = res.permissions
    } catch (error) {
      console.error('获取用户信息失败:', error)
      // token 无效,自动登出
      if ((error as any).response?.status === 401) {
        logout()
      }
    }
  }
  
  // 更新用户信息
  function updateUserInfo(data: Partial<UserInfo>) {
    if (userInfo.value) {
      userInfo.value = { ...userInfo.value, ...data }
    }
  }
  
  return {
    // state
    token,
    userInfo,
    permissions,
    // getters
    isLoggedIn,
    userName,
    userRole,
    hasPermission,
    // actions
    login,
    logout,
    fetchUserInfo,
    updateUserInfo
  }
})

4.3 应用配置模块(主题/语言)

// stores/modules/app.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

type Theme = 'light' | 'dark'
type Language = 'zh' | 'en'

export const useAppStore = defineStore('app', () => {
  // 从 localStorage 读取初始值
  const getInitialTheme = (): Theme => {
    const saved = localStorage.getItem('theme') as Theme
    return saved || 'light'
  }
  
  const getInitialLanguage = (): Language => {
    const saved = localStorage.getItem('language') as Language
    return saved || 'zh'
  }
  
  // State
  const theme = ref<Theme>(getInitialTheme())
  const language = ref<Language>(getInitialLanguage())
  const sidebarCollapsed = ref(false)
  
  // Getters
  const isDark = computed(() => theme.value === 'dark')
  const currentLanguage = computed(() => language.value)
  
  // Actions
  function setTheme(newTheme: Theme) {
    theme.value = newTheme
    localStorage.setItem('theme', newTheme)
    
    // 更新 HTML 的 data-theme 属性(用于 CSS 变量)
    document.documentElement.setAttribute('data-theme', newTheme)
  }
  
  function toggleTheme() {
    setTheme(theme.value === 'light' ? 'dark' : 'light')
  }
  
  function setLanguage(lang: Language) {
    language.value = lang
    localStorage.setItem('language', lang)
  }
  
  function toggleSidebar() {
    sidebarCollapsed.value = !sidebarCollapsed.value
  }
  
  return {
    theme,
    language,
    sidebarCollapsed,
    isDark,
    currentLanguage,
    setTheme,
    toggleTheme,
    setLanguage,
    toggleSidebar
  }
})

4.4 统一导出(方便使用)

// stores/index.ts
export { useUserStore } from './modules/user'
export { useAppStore } from './modules/app'
export { useCartStore } from './modules/cart'
export { useProductStore } from './modules/product'

// 如果需要,可以创建一个组合多个 store 的 hook
import { useUserStore } from './modules/user'
import { useAppStore } from './modules/app'

export const useStore = () => ({
  user: useUserStore(),
  app: useAppStore()
})

五、持久化:让状态「记住」自己

5.1 问题场景

用户登录后刷新页面,状态丢了——这是初学者最常见的困惑。

// 刷新后,token 没了,又要重新登录
// 用户体验:???

5.2 解决方案:pinia-plugin-persistedstate

npm install pinia-plugin-persistedstate
// src/main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // 注册插件

5.3 基本用法

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null,
    userInfo: null
  }),
  persist: true // 一键开启持久化
})

就这么简单!默认会:

  • 使用 localStorage
  • key 为 store名(这里是 'user')
  • 自动同步整个 state

5.4 高级配置:按需持久化

有时候我们不想存所有东西(比如敏感信息、临时数据):

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null,
    userInfo: null,
    tempSearchKeyword: '', // 这个不想持久化
    loginTime: null
  }),
  persist: {
    key: 'user-storage', // 自定义存储 key
    storage: localStorage, // 可选 sessionStorage
    paths: ['token', 'userInfo'], // 只持久化这两个字段
    beforeRestore: (context) => {
      console.log('即将恢复状态', context)
    },
    afterRestore: (context) => {
      console.log('状态恢复完成', context)
    }
  }
})

5.5 Setup Store 的持久化写法

// stores/modules/app.ts
export const useAppStore = defineStore('app', () => {
  const theme = ref('light')
  const language = ref('zh')
  
  // ... 其他逻辑
  
  return {
    theme,
    language
  }
}, {
  persist: {
    key: 'app-settings',
    paths: ['theme', 'language'] // 只持久化主题和语言
  }
})

5.6 多标签页同步

如果你想让多个标签页的状态保持同步,可以这样配置:

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null
  }),
  persist: {
    storage: localStorage,
    // 监听 storage 事件,实现多标签页同步
    beforeRestore: (context) => {
      window.addEventListener('storage', (e) => {
        if (e.key === 'user-storage') {
          // 重新恢复状态
          context.store.$hydrate()
        }
      })
    }
  }
})

六、Store 组合与复用(类似 Composables)

这是 Pinia 最强大的特性之一:Store 可以像组合式函数一样复用-5

6.1 场景:多个模块需要认证逻辑

假设你的应用有多个模块都需要用到用户认证状态,不想在每个 Store 里重复写一遍登录/登出逻辑。

// stores/composables/useAuth.ts
import { ref, computed } from 'vue'

export function useAuth() {
  const isLoggedIn = ref(false)
  const username = ref('')
  
  function login(name: string) {
    isLoggedIn.value = true
    username.value = name
  }
  
  function logout() {
    isLoggedIn.value = false
    username.value = ''
  }
  
  return {
    isLoggedIn,
    username,
    login,
    logout
  }
}

6.2 在 Store 中复用

// stores/modules/user.ts
import { defineStore } from 'pinia'
import { useAuth } from '../composables/useAuth'

export const useUserStore = defineStore('user', () => {
  // 复用认证逻辑
  const { isLoggedIn, username, login, logout } = useAuth()
  
  // 扩展用户专属状态
  const userId = ref<number | null>(null)
  const avatar = ref('')
  
  // 扩展登录方法
  const loginWithId = (name: string, id: number) => {
    login(name) // 调用复用的 login
    userId.value = id
  }
  
  return {
    isLoggedIn,
    username,
    userId,
    avatar,
    login: loginWithId,
    logout
  }
})

// stores/modules/admin.ts
import { defineStore } from 'pinia'
import { useAuth } from '../composables/useAuth'

export const useAdminStore = defineStore('admin', () => {
  // 同样复用认证逻辑
  const { isLoggedIn, username, login, logout } = useAuth()
  
  // 管理员特有的状态
  const adminLevel = ref(1)
  
  return {
    isLoggedIn,
    username,
    adminLevel,
    login,
    logout
  }
})

6.3 场景:数据缓存逻辑复用

多个模块都需要缓存数据(比如商品列表、订单列表),可以封装一个通用的缓存逻辑-5

// stores/composables/useCache.ts
import { ref } from 'vue'

export function useCache<T>(key: string, fetchFn: () => Promise<T>, expireTime = 5 * 60 * 1000) {
  const cachedData = ref<T | null>(null)
  const lastFetchTime = ref<number | null>(null)
  
  const getData = async () => {
    const now = Date.now()
    
    // 如果有缓存且未过期,直接返回缓存
    if (cachedData.value && lastFetchTime.value && (now - lastFetchTime.value) < expireTime) {
      console.log(`[缓存命中] ${key}`)
      return cachedData.value
    }
    
    // 否则重新获取
    console.log(`[缓存失效] ${key},重新获取`)
    const freshData = await fetchFn()
    cachedData.value = freshData
    lastFetchTime.value = now
    return freshData
  }
  
  const clearCache = () => {
    cachedData.value = null
    lastFetchTime.value = null
  }
  
  return {
    getData,
    clearCache,
    cachedData
  }
}
// stores/modules/product.ts
import { defineStore } from 'pinia'
import { useCache } from '../composables/useCache'
import { fetchProductList } from '@/api/product'

export const useProductStore = defineStore('product', () => {
  const { getData, clearCache, cachedData } = useCache(
    'products',
    fetchProductList,
    10 * 60 * 1000 // 10分钟缓存
  )
  
  const loadProducts = async () => {
    return await getData()
  }
  
  return {
    products: cachedData,
    loadProducts,
    clearCache
  }
})

七、在组件中使用:三种姿势

7.1 基础用法(最常用)

<!-- views/Profile.vue -->
<template>
  <div class="profile">
    <h2>个人中心</h2>
    
    <div v-if="userStore.isLoggedIn">
      <el-avatar :src="userStore.userInfo?.avatar" />
      <p>用户名:{{ userStore.userName }}</p>
      <p>角色:{{ userStore.userRole }}</p>
      
      <el-button @click="handleLogout">退出登录</el-button>
    </div>
    
    <div v-else>
      <p>请先登录</p>
      <el-button @click="goToLogin">去登录</el-button>
    </div>
    
    <!-- 测试权限指令 -->
    <button v-if="userStore.hasPermission('product:edit')">
      编辑商品
    </button>
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from '@/stores/modules/user'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'

const userStore = useUserStore()
const router = useRouter()

const handleLogout = () => {
  ElMessageBox.confirm('确认退出登录吗?', '提示', {
    type: 'info'
  }).then(() => {
    userStore.logout()
    router.push('/login')
  })
}

const goToLogin = () => {
  router.push('/login')
}
</script>

7.2 解构赋值(小心丢失响应性)

<script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia' // 重要!

const userStore = useUserStore()

// ❌ 错误:直接解构会丢失响应性
const { userName, isLoggedIn } = userStore

// ✅ 正确:使用 storeToRefs
const { userName, isLoggedIn, userInfo } = storeToRefs(userStore)

// actions 可以直接解构(不会丢失)
const { login, logout } = userStore
</script>

7.3 在路由守卫中使用

// src/router/index.ts
import { useUserStore } from '@/stores/modules/user'

router.beforeEach((to, from, next) => {
  // 需要手动获取 store 实例
  const userStore = useUserStore()
  
  if (to.meta.requiresAuth && !userStore.isLoggedIn) {
    next({ path: '/login', query: { redirect: to.fullPath } })
  } else {
    next()
  }
})

7.4 在 axios 拦截器中使用

// src/utils/request.ts
import { useUserStore } from '@/stores/modules/user'

request.interceptors.request.use((config) => {
  const userStore = useUserStore()
  
  if (userStore.token) {
    config.headers.Authorization = `Bearer ${userStore.token}`
  }
  
  return config
})

request.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      const userStore = useUserStore()
      userStore.logout() // 自动清除状态
      router.push('/login')
    }
    return Promise.reject(error)
  }
)

八、Pinia 插件开发:定制你的专属功能

8.1 日志插件:记录所有状态变化

// stores/plugins/logger.ts
import type { PiniaPluginContext } from 'pinia'

export function loggerPlugin({ store, options }: PiniaPluginContext) {
  // 订阅 state 变化
  store.$subscribe((mutation, state) => {
    console.group(`📝 [${store.$id}] 状态变化`)
    console.log('类型:', mutation.type)
    console.log('载荷:', mutation.payload)
    console.log('新状态:', state)
    console.groupEnd()
  })
  
  // 订阅 action 调用
  store.$onAction(({
    name,       // action 名称
    store,      // store 实例
    args,       // 参数
    after,      // 成功后回调
    onError     // 失败后回调
  }) => {
    console.log(`🚀 [${store.$id}] 调用 action: ${name}`, args)
    
    after(result => {
      console.log(`✅ [${store.$id}] action 成功: ${name}`, result)
    })
    
    onError(error => {
      console.error(`❌ [${store.$id}] action 失败: ${name}`, error)
    })
  })
}

8.2 注册插件

// src/main.ts
import { loggerPlugin } from './stores/plugins/logger'

const pinia = createPinia()
pinia.use(loggerPlugin) // 全局生效

8.3 自定义持久化插件

// stores/plugins/customPersist.ts
export function customPersist({ store }: PiniaPluginContext) {
  // 从 localStorage 恢复状态
  const savedState = localStorage.getItem(`pinia:${store.$id}`)
  if (savedState) {
    store.$patch(JSON.parse(savedState))
  }
  
  // 订阅变化并保存
  store.$subscribe((mutation, state) => {
    localStorage.setItem(`pinia:${store.$id}`, JSON.stringify(state))
  })
}

九、性能优化与最佳实践

9.1 避免在 getter 中返回新对象

// ❌ 错误:每次访问都返回新对象,破坏缓存
getters: {
  filteredList: (state) => {
    return state.list.filter(item => item.active) // 每次都是新数组
  }
}

// ✅ 正确:getter 本身会缓存计算结果
getters: {
  activeCount: (state) => state.list.filter(item => item.active).length
}

9.2 按需加载 Store

// 在组件中动态导入(适用于大型应用)
const useUserStore = () => import('@/stores/user').then(m => m.useUserStore)

// 或者在路由懒加载时使用
const UserModule = () => import('@/views/User.vue')

9.3 使用 shallowRef 优化大对象

import { shallowRef } from 'vue'

// 对于大型对象,不需要深度响应式
const bigData = shallowRef(null)

// 只有整体替换时才触发更新
bigData.value = await fetchLargeDataset()

9.4 重置 Store 状态

// 添加重置方法
export const useUserStore = defineStore('user', () => {
  const initialState = {
    token: null,
    userInfo: null,
    permissions: []
  }
  
  const token = ref(initialState.token)
  const userInfo = ref(initialState.userInfo)
  const permissions = ref(initialState.permissions)
  
  function $reset() {
    token.value = initialState.token
    userInfo.value = initialState.userInfo
    permissions.value = initialState.permissions
    localStorage.removeItem('token')
  }
  
  return {
    token,
    userInfo,
    permissions,
    $reset,
    // ... 其他 actions
  }
})

十、TypeScript 类型增强

10.1 为 store 添加类型

// stores/modules/user.ts
import type { UserInfo } from '@/types/user'

export interface UserState {
  token: string | null
  userInfo: UserInfo | null
  permissions: string[]
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    token: null,
    userInfo: null,
    permissions: []
  })
})

10.2 扩展 Pinia 类型(为所有 store 添加通用方法)

// types/pinia.d.ts
import 'pinia'

declare module 'pinia' {
  export interface PiniaCustomProperties {
    // 给所有 store 添加 $reset 方法
    $reset(): void
    
    // 添加自定义属性
    readonly $id: string
  }
  
  export interface PiniaCustomStateProperties<S> {
    // 给所有 state 添加 toJSON 方法
    toJSON(): S
  }
}

10.3 为插件添加类型

// stores/plugins/logger.ts
import type { PiniaPluginContext } from 'pinia'

export interface LoggerPluginOptions {
  enabled?: boolean
  filter?: (storeId: string) => boolean
}

export function loggerPlugin(options: LoggerPluginOptions = {}) {
  return (context: PiniaPluginContext) => {
    // 插件逻辑
  }
}

十一、实战演练:完整的购物车模块

让我们把学到的知识串起来,实现一个完整的购物车模块。

11.1 购物车 Store

// stores/modules/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { CartItem, Product } from '@/types'
import { ElMessage } from 'element-plus'

export const useCartStore = defineStore('cart', () => {
  // --- State ---
  const items = ref<CartItem[]>([])
  const loading = ref(false)
  const lastUpdated = ref<Date | null>(null)
  
  // --- Getters ---
  const totalCount = computed(() => {
    return items.value.reduce((sum, item) => sum + item.quantity, 0)
  })
  
  const totalPrice = computed(() => {
    return items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  })
  
  const isEmpty = computed(() => items.value.length === 0)
  
  const formattedTotal = computed(() => {
    return ${totalPrice.value.toFixed(2)}`
  })
  
  // --- Actions ---
  function addItem(product: Product, quantity = 1) {
    const existing = items.value.find(item => item.id === product.id)
    
    if (existing) {
      existing.quantity += quantity
    } else {
      items.value.push({
        id: product.id,
        name: product.name,
        price: product.price,
        image: product.image,
        quantity
      })
    }
    
    lastUpdated.value = new Date()
    ElMessage.success(`已添加 ${product.name} 到购物车`)
  }
  
  function removeItem(productId: number) {
    const index = items.value.findIndex(item => item.id === productId)
    if (index > -1) {
      const removed = items.value[index]
      items.value.splice(index, 1)
      ElMessage.success(`已移除 ${removed.name}`)
    }
  }
  
  function updateQuantity(productId: number, quantity: number) {
    const item = items.value.find(item => item.id === productId)
    if (item) {
      if (quantity <= 0) {
        removeItem(productId)
      } else {
        item.quantity = quantity
      }
    }
  }
  
  function clearCart() {
    items.value = []
    ElMessage.success('购物车已清空')
  }
  
  async function checkout() {
    if (isEmpty.value) {
      ElMessage.warning('购物车是空的')
      return false
    }
    
    loading.value = true
    try {
      // 模拟提交订单
      await new Promise(resolve => setTimeout(resolve, 1500))
      
      // 提交成功后清空购物车
      clearCart()
      ElMessage.success('下单成功!')
      return true
    } catch (error) {
      ElMessage.error('下单失败,请重试')
      return false
    } finally {
      loading.value = false
    }
  }
  
  return {
    // state
    items,
    loading,
    lastUpdated,
    // getters
    totalCount,
    totalPrice,
    isEmpty,
    formattedTotal,
    // actions
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    checkout
  }
}, {
  persist: {
    key: 'shopping-cart',
    paths: ['items'], // 只持久化商品列表
    storage: localStorage
  }
})

11.2 在组件中使用

<!-- components/CartIcon.vue -->
<template>
  <el-badge :value="cartStore.totalCount" :hidden="cartStore.isEmpty">
    <el-button :icon="ShoppingCart" @click="showCartDrawer = true">
      购物车
    </el-button>
  </el-badge>
  
  <el-drawer v-model="showCartDrawer" title="购物车" size="400px">
    <div v-loading="cartStore.loading" class="cart-content">
      <template v-if="!cartStore.isEmpty">
        <div v-for="item in cartStore.items" :key="item.id" class="cart-item">
          <img :src="item.image" :alt="item.name" class="item-image">
          <div class="item-info">
            <h4>{{ item.name }}</h4>
            <p class="item-price">¥{{ item.price }}</p>
          </div>
          <div class="item-actions">
            <el-input-number
              v-model="item.quantity"
              :min="1"
              :max="99"
              size="small"
              @change="handleQuantityChange(item.id, $event)"
            />
            <el-button
              type="danger"
              :icon="Delete"
              link
              @click="cartStore.removeItem(item.id)"
            />
          </div>
        </div>
        
        <div class="cart-footer">
          <div class="total">
            <span>总计:</span>
            <span class="total-price">{{ cartStore.formattedTotal }}</span>
          </div>
          <el-button
            type="primary"
            :loading="cartStore.loading"
            @click="handleCheckout"
          >
            结算
          </el-button>
        </div>
      </template>
      
      <el-empty v-else description="购物车空空如也" />
    </div>
  </el-drawer>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ShoppingCart, Delete } from '@element-plus/icons-vue'
import { useCartStore } from '@/stores/modules/cart'
import { ElMessageBox } from 'element-plus'

const cartStore = useCartStore()
const showCartDrawer = ref(false)

const handleQuantityChange = (productId: number, quantity: number) => {
  cartStore.updateQuantity(productId, quantity)
}

const handleCheckout = async () => {
  ElMessageBox.confirm('确认提交订单吗?', '提示', {
    type: 'info'
  }).then(async () => {
    const success = await cartStore.checkout()
    if (success) {
      showCartDrawer.value = false
    }
  })
}
</script>

<style scoped lang="scss">
.cart-content {
  padding: 20px;
  height: 100%;
  display: flex;
  flex-direction: column;
}

.cart-item {
  display: flex;
  align-items: center;
  padding: 12px 0;
  border-bottom: 1px solid #eee;
  
  .item-image {
    width: 60px;
    height: 60px;
    object-fit: cover;
    border-radius: 4px;
    margin-right: 12px;
  }
  
  .item-info {
    flex: 1;
    
    h4 {
      margin: 0 0 4px;
      font-size: 14px;
    }
    
    .item-price {
      margin: 0;
      color: #f56c6c;
      font-weight: bold;
    }
  }
  
  .item-actions {
    display: flex;
    align-items: center;
    gap: 8px;
  }
}

.cart-footer {
  margin-top: auto;
  padding-top: 20px;
  border-top: 2px solid #eee;
  
  .total {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;
    font-size: 16px;
    
    .total-price {
      color: #f56c6c;
      font-size: 20px;
      font-weight: bold;
    }
  }
}
</style>

十二、总结与进阶

12.1 Pinia 核心要点回顾

概念 作用 类比
State 存储数据 组件的 data
Getter 计算派生状态 组件的 computed
Action 修改状态的方法 组件的 methods
Plugin 扩展功能 全局混入
Store 上述内容的容器 一个模块

12.2 什么时候用 Pinia?

  • ✅ 多个组件共享同一份数据
  • ✅ 数据需要跨路由持久化
  • ✅ 有复杂的业务逻辑需要复用
  • ✅ 需要 DevTools 调试状态变化
  • ❌ 简单的父子组件通信(用 props/emit 就够了)

12.3 下一步学习方向

  1. Pinia + Vue Query:服务端状态管理
  2. Pinia + WebSocket:实时数据同步
  3. Pinia 源码阅读:理解响应式原理
  4. 自定义插件开发:根据项目需求定制

12.4 写在最后

从 Vuex 到 Pinia,不仅仅是 API 的简化,更是对「状态管理应该简单」这一理念的回归。就像 Evan You 说的:

"Pinia 成功地在保持清晰的设计分离的同时,提供了简单、小巧且易于上手的 API。"

掌握 Pinia,不是为了炫技,而是为了让代码更清晰、维护更简单。现在,去重构你项目里的状态管理吧!🚀

Vite 工程化实战 | 从 0 配置一个企业级前端项目(按需引入 / 环境变量 / 打包优化)

作者 代码煮茶
2026年3月14日 11:38

零、为什么我们要“折腾”环境?

还记得你第一次用 create-vue 脚手架时的感受吗?一行命令,项目就跑起来了,那叫一个爽!

但是!当你真正进入公司项目,你会发现:

// 理想中的项目
npm run dev // 启动,完事!

// 现实中的项目
npm run dev // 报错!Node版本不对
npm run build // 报错!内存溢出
npm run lint // 报错!代码格式不对
npm run test // 报错!环境变量没配

这时候你才明白:脚手架给你的是“毛坯房”,企业级项目需要的是“精装修”

今天,我们就从一个空文件夹开始,一步步搭建一个企业级 Vue3 + Vite 项目。这不是简单的“搭环境”,而是“搭项目”!

一、项目初始化:从零开始的艺术

1.1 创建项目(这次不用脚手架)

# 创建项目目录
mkdir vite-enterprise-demo
cd vite-enterprise-demo

# 初始化 package.json
npm init -y

# 安装核心依赖
npm install vue@latest
npm install -D vite @vitejs/plugin-vue typescript vue-tsc

# 创建项目结构
mkdir -p src/{assets,components,views,router,store,utils,styles,types}
touch index.html vite.config.ts tsconfig.json src/main.ts src/App.vue

现在的项目结构应该是这样:

vite-enterprise-demo/
├── src/
│   ├── assets/        # 静态资源
│   ├── components/     # 组件
│   ├── views/         # 页面
│   ├── router/        # 路由
│   ├── store/         # 状态管理
│   ├── utils/         # 工具函数
│   ├── styles/        # 全局样式
│   ├── types/         # TypeScript类型
│   ├── main.ts        # 入口文件
│   └── App.vue        # 根组件
├── index.html         # 入口HTML
├── vite.config.ts     # Vite配置
├── tsconfig.json      # TypeScript配置
└── package.json       # 项目配置

1.2 配置入口文件

<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <link rel="icon" href="/favicon.ico">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vite企业级项目实战</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'

// 创建应用实例
const app = createApp(App)

// 挂载应用
app.mount('#app')
<!-- src/App.vue -->
<template>
  <div class="app">
    <h1>🚀 Vite企业级项目实战</h1>
    <p>从0开始,搭建一个生产可用的项目</p>
  </div>
</template>

<script setup lang="ts">
// 这里写逻辑
</script>

<style scoped>
.app {
  text-align: center;
  padding: 2rem;
  color: #2c3e50;
}
</style>

1.3 配置 Vite

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  
  // 路径别名
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
      '@components': resolve(__dirname, 'src/components'),
      '@views': resolve(__dirname, 'src/views'),
      '@utils': resolve(__dirname, 'src/utils'),
      '@styles': resolve(__dirname, 'src/styles')
    }
  },
  
  // 开发服务器配置
  server: {
    port: 3000,
    open: true, // 自动打开浏览器
    cors: true, // 允许跨域
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^/api/, '')
      }
    }
  },
  
  // 构建配置
  build: {
    target: 'es2015',
    outDir: 'dist',
    assetsDir: 'assets',
    assetsInlineLimit: 4096, // 小于4kb的图片转base64
    sourcemap: false, // 不生成sourcemap
    reportCompressedSize: false, // 关闭压缩大小报告
    chunkSizeWarningLimit: 500 // 块大小警告限制
  }
})
// package.json 添加脚本
{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    "type-check": "vue-tsc --noEmit"
  }
}

试试运行:

npm run dev

看到页面了吗?恭喜!你已经从0开始搭建了一个Vite项目!

二、TypeScript 配置:告别 any 恐惧症

2.1 配置 tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "node",
    "strict": true,
    "jsx": "preserve",
    "sourceMap": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "lib": ["ESNext", "DOM"],
    "types": ["vite/client"],
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@views/*": ["src/views/*"],
      "@utils/*": ["src/utils/*"],
      "@styles/*": ["src/styles/*"]
    },
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
  "exclude": ["node_modules", "dist"]
}

2.2 添加类型声明

// src/types/shims-vue.d.ts
declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

// src/types/env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_APP_TITLE: string
  readonly VITE_API_BASE_URL: string
  readonly VITE_ENABLE_MOCK: string
  // 更多环境变量...
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

三、环境变量配置:一套代码,多套环境

3.1 环境变量文件

# .env                # 所有环境共用
# .env.local          # 本地覆盖(不提交)
# .env.development    # 开发环境
# .env.production     # 生产环境
# .env.test           # 测试环境
# .env.development
VITE_APP_TITLE=开发环境
VITE_API_BASE_URL=http://localhost:8080/api
VITE_ENABLE_MOCK=true
VITE_LOG_LEVEL=debug

# .env.production
VITE_APP_TITLE=生产环境
VITE_API_BASE_URL=https://api.example.com
VITE_ENABLE_MOCK=false
VITE_LOG_LEVEL=error

3.2 使用环境变量

// src/utils/config.ts
export const config = {
  appTitle: import.meta.env.VITE_APP_TITLE,
  apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
  enableMock: import.meta.env.VITE_ENABLE_MOCK === 'true',
  logLevel: import.meta.env.VITE_LOG_LEVEL,
  
  // 判断环境
  isDev: import.meta.env.DEV,
  isProd: import.meta.env.PROD,
  mode: import.meta.env.MODE
}

console.log('当前环境:', config.mode)
console.log('API地址:', config.apiBaseUrl)

四、路由配置:让页面"动"起来

4.1 安装路由

npm install vue-router@4

4.2 配置路由

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

// 路由配置
const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@views/Home.vue'),
    meta: {
      title: '首页',
      requiresAuth: false
    }
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('@views/About.vue'),
    meta: {
      title: '关于',
      requiresAuth: false
    }
  },
  {
    path: '/user',
    name: 'User',
    component: () => import('@views/User.vue'),
    meta: {
      title: '个人中心',
      requiresAuth: true
    }
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@views/404.vue')
  }
]

// 创建路由实例
const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

// 全局前置守卫
router.beforeEach((to, from, next) => {
  // 设置页面标题
  document.title = to.meta.title ? `${to.meta.title} - Vite企业级项目` : 'Vite企业级项目'
  
  // 检查是否需要登录
  if (to.meta.requiresAuth) {
    const token = localStorage.getItem('token')
    if (token) {
      next()
    } else {
      next({ path: '/login', query: { redirect: to.fullPath } })
    }
  } else {
    next()
  }
})

export default router

4.3 创建页面组件

<!-- src/views/Home.vue -->
<template>
  <div class="home">
    <h2>🏠 首页</h2>
    <p>欢迎来到首页!</p>
    <button @click="goToAbout">去关于页面</button>
  </div>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router'

const router = useRouter()
const goToAbout = () => {
  router.push('/about')
}
</script>

4.4 在 main.ts 中注册路由

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(router) // 注册路由

app.mount('#app')

五、状态管理:Pinia 来了

5.1 为什么不用 Vuex?

// Vuex 的写法
mutations: {
  SET_USER(state, user) {
    state.user = user
  }
},
actions: {
  async fetchUser({ commit }) {
    const user = await api.getUser()
    commit('SET_USER', user)
  }
}

// Pinia 的写法
export const useUserStore = defineStore('user', {
  state: () => ({ user: null }),
  actions: {
    async fetchUser() {
      this.user = await api.getUser()
    }
  }
})

Pinia 更简洁、更 TypeScript 友好、更模块化!

5.2 安装 Pinia

npm install pinia
npm install pinia-plugin-persistedstate # 持久化插件

5.3 创建 store

// src/store/index.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

export default pinia
// src/store/modules/user.ts
import { defineStore } from 'pinia'

interface UserState {
  token: string | null
  userInfo: {
    id?: number
    name?: string
    avatar?: string
    role?: string
  } | null
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    token: localStorage.getItem('token'),
    userInfo: null
  }),
  
  getters: {
    isLoggedIn: (state) => !!state.token,
    userName: (state) => state.userInfo?.name || '游客',
    userRole: (state) => state.userInfo?.role || 'guest'
  },
  
  actions: {
    // 登录
    async login(credentials: { username: string; password: string }) {
      try {
        // 模拟登录请求
        const response = await fetch('/api/login', {
          method: 'POST',
          body: JSON.stringify(credentials)
        })
        const data = await response.json()
        
        this.token = data.token
        this.userInfo = data.userInfo
        
        localStorage.setItem('token', data.token)
        
        return true
      } catch (error) {
        console.error('登录失败:', error)
        return false
      }
    },
    
    // 登出
    logout() {
      this.token = null
      this.userInfo = null
      localStorage.removeItem('token')
    },
    
    // 获取用户信息
    async fetchUserInfo() {
      if (!this.token) return
        
      try {
        const response = await fetch('/api/user/info', {
          headers: {
            Authorization: `Bearer ${this.token}`
          }
        })
        this.userInfo = await response.json()
      } catch (error) {
        console.error('获取用户信息失败:', error)
      }
    }
  },
  
  persist: {
    key: 'user-store',
    storage: localStorage,
    paths: ['token'] // 只持久化 token
  }
})
// src/store/modules/app.ts
import { defineStore } from 'pinia'

interface AppState {
  sidebarCollapsed: boolean
  theme: 'light' | 'dark'
  language: 'zh' | 'en'
}

export const useAppStore = defineStore('app', {
  state: (): AppState => ({
    sidebarCollapsed: false,
    theme: 'light',
    language: 'zh'
  }),
  
  getters: {
    isSidebarCollapsed: (state) => state.sidebarCollapsed,
    currentTheme: (state) => state.theme,
    currentLanguage: (state) => state.language
  },
  
  actions: {
    toggleSidebar() {
      this.sidebarCollapsed = !this.sidebarCollapsed
    },
    
    setTheme(theme: 'light' | 'dark') {
      this.theme = theme
      document.documentElement.setAttribute('data-theme', theme)
    },
    
    setLanguage(language: 'zh' | 'en') {
      this.language = language
    }
  },
  
  persist: true // 持久化整个 store
})

5.4 在组件中使用

<!-- src/views/User.vue -->
<template>
  <div class="user">
    <h2>👤 个人中心</h2>
    
    <div v-if="userStore.isLoggedIn">
      <p>欢迎回来,{{ userStore.userName }}!</p>
      <p>角色:{{ userStore.userRole }}</p>
      <button @click="handleLogout">退出登录</button>
    </div>
    
    <div v-else>
      <p>请先登录</p>
      <button @click="handleLogin">模拟登录</button>
    </div>
    
    <hr>
    
    <h3>应用设置</h3>
    <p>侧边栏状态: {{ appStore.isSidebarCollapsed ? '折叠' : '展开' }}</p>
    <button @click="appStore.toggleSidebar">切换侧边栏</button>
    
    <p>当前主题: {{ appStore.currentTheme }}</p>
    <button @click="appStore.setTheme('dark')">深色模式</button>
    <button @click="appStore.setTheme('light')">浅色模式</button>
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from '@/store/modules/user'
import { useAppStore } from '@/store/modules/app'

const userStore = useUserStore()
const appStore = useAppStore()

const handleLogin = async () => {
  const success = await userStore.login({
    username: 'admin',
    password: '123456'
  })
  
  if (success) {
    alert('登录成功!')
  }
}

const handleLogout = () => {
  userStore.logout()
  alert('已退出登录')
}
</script>

六、UI 组件库集成:按需引入的艺术

6.1 安装 Element Plus

npm install element-plus
npm install -D unplugin-vue-components unplugin-auto-import

6.2 配置自动按需引入

// 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'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/types/auto-imports.d.ts',
      eslintrc: {
        enabled: true, // 生成 .eslintrc-auto-import.json
        filepath: './.eslintrc-auto-import.json'
      }
    }),
    Components({
      resolvers: [ElementPlusResolver()],
      dts: 'src/types/components.d.ts',
      dirs: ['src/components'] // 自动注册自己的组件
    })
  ]
})

6.3 自定义主题

// src/styles/element.scss
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  $colors: (
    'primary': (
      'base': #409eff,
    ),
    'success': (
      'base': #67c23a,
    ),
    'warning': (
      'base': #e6a23c,
    ),
    'danger': (
      'base': #f56c6c,
    ),
    'info': (
      'base': #909399,
    ),
  )
);

// 如果需要,可以导入所有样式
// @use "element-plus/theme-chalk/src/index.scss" as *;
// vite.config.ts 添加 CSS 配置
export default defineConfig({
  // ... 其他配置
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@use "@/styles/element.scss" as *;`
      }
    }
  }
})

6.4 在组件中使用

<template>
  <div>
    <el-button type="primary" @click="handleClick">
      主要按钮
    </el-button>
    
    <el-table :data="tableData" style="width: 100%">
      <el-table-column prop="date" label="日期" width="180" />
      <el-table-column prop="name" label="姓名" width="180" />
      <el-table-column prop="address" label="地址" />
    </el-table>
    
    <el-pagination
      v-model:current-page="currentPage"
      :page-size="pageSize"
      :total="total"
      layout="prev, pager, next"
    />
  </div>
</template>

<script setup lang="ts">
// 不用手动导入,自动按需引入!
const handleClick = () => {
  ElMessage.success('点击成功!')
}

const tableData = [
  { date: '2024-01-01', name: '张三', address: '北京市' }
]

const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(100)
</script>

七、HTTP 请求封装:让 API 调用更优雅

7.1 封装 axios

npm install axios
// src/utils/request.ts
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { useUserStore } from '@/store/modules/user'
import { ElMessage, ElMessageBox } from 'element-plus'

// 定义响应数据类型
export interface ApiResponse<T = any> {
  code: number
  data: T
  message: string
}

class Request {
  private instance: AxiosInstance
  
  constructor(config: AxiosRequestConfig) {
    this.instance = axios.create(config)
    
    // 请求拦截器
    this.instance.interceptors.request.use(
      (config) => {
        // 添加 token
        const userStore = useUserStore()
        if (userStore.token) {
          config.headers.Authorization = `Bearer ${userStore.token}`
        }
        
        // 开发环境打印请求信息
        if (import.meta.env.DEV) {
          console.log('🚀 请求:', config.method?.toUpperCase(), config.url)
          console.log('参数:', config.params || config.data)
        }
        
        return config
      },
      (error) => {
        return Promise.reject(error)
      }
    )
    
    // 响应拦截器
    this.instance.interceptors.response.use(
      (response: AxiosResponse<ApiResponse>) => {
        const { code, data, message } = response.data
        
        // 根据后端约定的 code 处理业务错误
        if (code !== 200) {
          ElMessage.error(message || '请求失败')
          return Promise.reject(new Error(message))
        }
        
        return data as any
      },
      (error) => {
        // 处理 HTTP 错误
        if (error.response) {
          switch (error.response.status) {
            case 401:
              handleUnauthorized()
              break
            case 403:
              ElMessage.error('没有权限访问')
              break
            case 404:
              ElMessage.error('请求的资源不存在')
              break
            case 500:
              ElMessage.error('服务器错误')
              break
            default:
              ElMessage.error(`请求失败: ${error.response.status}`)
          }
        } else if (error.request) {
          ElMessage.error('网络连接失败,请检查网络')
        } else {
          ElMessage.error('请求配置错误')
        }
        
        return Promise.reject(error)
      }
    )
  }
  
  // 统一请求方法
  public request<T = any>(config: AxiosRequestConfig): Promise<T> {
    return this.instance.request(config)
  }
  
  // GET 请求
  public get<T = any>(url: string, params?: any): Promise<T> {
    return this.instance.get(url, { params })
  }
  
  // POST 请求
  public post<T = any>(url: string, data?: any): Promise<T> {
    return this.instance.post(url, data)
  }
  
  // PUT 请求
  public put<T = any>(url: string, data?: any): Promise<T> {
    return this.instance.put(url, data)
  }
  
  // DELETE 请求
  public delete<T = any>(url: string, params?: any): Promise<T> {
    return this.instance.delete(url, { params })
  }
  
  // 上传文件
  public upload<T = any>(url: string, file: File, onProgress?: (progress: number) => void): Promise<T> {
    const formData = new FormData()
    formData.append('file', file)
    
    return this.instance.post(url, formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      },
      onUploadProgress: (progressEvent) => {
        if (onProgress && progressEvent.total) {
          const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
          onProgress(percentCompleted)
        }
      }
    })
  }
  
  // 下载文件
  public download(url: string, filename?: string): Promise<void> {
    return this.instance.get(url, {
      responseType: 'blob'
    }).then(response => {
      const blob = new Blob([response as any])
      const downloadUrl = window.URL.createObjectURL(blob)
      const link = document.createElement('a')
      link.href = downloadUrl
      link.download = filename || 'download'
      document.body.appendChild(link)
      link.click()
      document.body.removeChild(link)
      window.URL.revokeObjectURL(downloadUrl)
    })
  }
}

// 处理未授权
function handleUnauthorized() {
  ElMessageBox.confirm('登录已过期,请重新登录', '提示', {
    confirmButtonText: '去登录',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    const userStore = useUserStore()
    userStore.logout()
    window.location.href = '/login'
  })
}

// 创建实例
const request = new Request({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json;charset=UTF-8'
  }
})

export default request

7.2 封装 API 模块

// src/api/user.ts
import request from '@/utils/request'

export interface LoginParams {
  username: string
  password: string
}

export interface UserInfo {
  id: number
  name: string
  avatar: string
  role: string
  permissions: string[]
}

export const userApi = {
  // 登录
  login(data: LoginParams) {
    return request.post<{ token: string; userInfo: UserInfo }>('/user/login', data)
  },
  
  // 获取用户信息
  getUserInfo() {
    return request.get<UserInfo>('/user/info')
  },
  
  // 获取用户列表
  getUserList(params: { page: number; limit: number }) {
    return request.get<{ list: UserInfo[]; total: number }>('/user/list', params)
  },
  
  // 更新用户信息
  updateUserInfo(id: number, data: Partial<UserInfo>) {
    return request.put(`/user/${id}`, data)
  },
  
  // 删除用户
  deleteUser(id: number) {
    return request.delete(`/user/${id}`)
  }
}

八、代码规范:让团队代码像一个人写的

8.1 安装 ESLint + Prettier

npm install -D eslint prettier eslint-plugin-vue @vue/eslint-config-typescript
npm install -D @typescript-eslint/eslint-plugin @typescript-eslint/parser
npm install -D eslint-config-prettier eslint-plugin-prettier

8.2 配置 ESLint

// .eslintrc.js
module.exports = {
  root: true,
  env: {
    browser: true,
    es2021: true,
    node: true
  },
  extends: [
    'plugin:vue/vue3-recommended',
    '@vue/typescript/recommended',
    'plugin:prettier/recommended'
  ],
  parserOptions: {
    ecmaVersion: 2021,
    parser: '@typescript-eslint/parser',
    sourceType: 'module'
  },
  plugins: ['@typescript-eslint'],
  rules: {
    // 自定义规则
    'vue/multi-word-component-names': 'off',
    '@typescript-eslint/no-explicit-any': 'warn',
    '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
  },
  globals: {
    defineProps: 'readonly',
    defineEmits: 'readonly',
    defineExpose: 'readonly',
    withDefaults: 'readonly'
  }
}

8.3 配置 Prettier

// .prettierrc
{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "none",
  "printWidth": 100,
  "vueIndentScriptAndStyle": true,
  "endOfLine": "auto"
}

8.4 配置 Husky + lint-staged

npm install -D husky lint-staged

# 初始化 husky
npx husky install

# 添加 pre-commit 钩子
npx husky add .husky/pre-commit "npx lint-staged"
// package.json 添加 lint-staged 配置
{
  "lint-staged": {
    "*.{js,ts,vue}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{css,scss,html,json,md}": [
      "prettier --write"
    ]
  }
}

九、打包优化:让项目飞起来

9.1 配置打包分析

npm install -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    // ... 其他插件
    visualizer({
      filename: 'dist/stats.html',
      open: true,
      gzipSize: true,
      brotliSize: true
    })
  ]
})

9.2 代码分割优化

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // 手动分包
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 将 vue、vue-router、pinia 打包在一起
            if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
              return 'vendor-vue'
            }
            
            // 将 element-plus 单独打包
            if (id.includes('element-plus')) {
              return 'vendor-element'
            }
            
            // 其他依赖打包在一起
            return 'vendor-other'
          }
        },
        
        // 自定义 chunk 文件名
        chunkFileNames: 'assets/js/[name]-[hash].js',
        entryFileNames: 'assets/js/[name]-[hash].js',
        assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
      }
    },
    
    // 启用/禁用 CSS 代码分割
    cssCodeSplit: true,
    
    // 设置资源大小限制
    assetsInlineLimit: 4096
  }
})

9.3 图片压缩

npm install -D vite-plugin-imagemin
// vite.config.ts
import viteImagemin from 'vite-plugin-imagemin'

export default defineConfig({
  plugins: [
    // ... 其他插件
    viteImagemin({
      gifsicle: {
        optimizationLevel: 7,
        interlaced: false
      },
      optipng: {
        optimizationLevel: 7
      },
      mozjpeg: {
        quality: 80
      },
      pngquant: {
        quality: [0.8, 0.9],
        speed: 4
      },
      svgo: {
        plugins: [
          {
            name: 'removeViewBox'
          },
          {
            name: 'removeEmptyAttrs',
            active: false
          }
        ]
      }
    })
  ]
})

9.4 打包进度条

npm install -D vite-plugin-progress
// vite.config.ts
import progress from 'vite-plugin-progress'

export default defineConfig({
  plugins: [
    // ... 其他插件
    progress()
  ]
})

9.5 压缩打包结果

npm install -D vite-plugin-compression
// vite.config.ts
import viteCompression from 'vite-plugin-compression'

export default defineConfig({
  plugins: [
    // ... 其他插件
    viteCompression({
      verbose: true,
      disable: false,
      threshold: 10240, // 大于10kb才压缩
      algorithm: 'gzip',
      ext: '.gz'
    })
  ]
})

十、完整的 vite.config.ts

// vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
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'
import viteImagemin from 'vite-plugin-imagemin'
import progress from 'vite-plugin-progress'
import viteCompression from 'vite-plugin-compression'

// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
  // 加载环境变量
  const env = loadEnv(mode, process.cwd())
  
  return {
    plugins: [
      vue(),
      
      // 自动导入
      AutoImport({
        resolvers: [ElementPlusResolver()],
        imports: ['vue', 'vue-router', 'pinia'],
        dts: 'src/types/auto-imports.d.ts',
        eslintrc: {
          enabled: true,
          filepath: './.eslintrc-auto-import.json'
        }
      }),
      
      // 自动注册组件
      Components({
        resolvers: [ElementPlusResolver()],
        dts: 'src/types/components.d.ts',
        dirs: ['src/components']
      }),
      
      // 图片压缩
      viteImagemin({
        optipng: { optimizationLevel: 7 },
        mozjpeg: { quality: 80 },
        pngquant: { quality: [0.8, 0.9], speed: 4 },
        svgo: { plugins: [{ name: 'removeViewBox' }] }
      }),
      
      // 打包进度条
      progress(),
      
      // gzip压缩
      viteCompression({
        threshold: 10240,
        algorithm: 'gzip',
        ext: '.gz'
      }),
      
      // 打包分析(只在分析时开启)
      ...(mode === 'analyze' ? [visualizer({
        filename: 'dist/stats.html',
        open: true,
        gzipSize: true,
        brotliSize: true
      })] : [])
    ],
    
    // 路径别名
    resolve: {
      alias: {
        '@': resolve(__dirname, 'src'),
        '@components': resolve(__dirname, 'src/components'),
        '@views': resolve(__dirname, 'src/views'),
        '@utils': resolve(__dirname, 'src/utils'),
        '@styles': resolve(__dirname, 'src/styles')
      }
    },
    
    // CSS 配置
    css: {
      preprocessorOptions: {
        scss: {
          additionalData: `@use "@/styles/element.scss" as *;`
        }
      }
    },
    
    // 开发服务器配置
    server: {
      port: 3000,
      open: true,
      cors: true,
      proxy: {
        '/api': {
          target: env.VITE_API_BASE_URL,
          changeOrigin: true,
          rewrite: (path) => path.replace(/^/api/, '')
        }
      }
    },
    
    // 构建配置
    build: {
      target: 'es2015',
      outDir: 'dist',
      assetsDir: 'assets',
      assetsInlineLimit: 4096,
      sourcemap: env.VITE_BUILD_SOURCEMAP === 'true',
      reportCompressedSize: false,
      chunkSizeWarningLimit: 500,
      
      rollupOptions: {
        output: {
          manualChunks(id) {
            if (id.includes('node_modules')) {
              if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
                return 'vendor-vue'
              }
              if (id.includes('element-plus')) {
                return 'vendor-element'
              }
              return 'vendor-other'
            }
          },
          chunkFileNames: 'assets/js/[name]-[hash].js',
          entryFileNames: 'assets/js/[name]-[hash].js',
          assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
        }
      }
    }
  }
})

十一、package.json 完整配置

{
  "name": "vite-enterprise-demo",
  "version": "1.0.0",
  "description": "Vite企业级项目实战",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "build:analyze": "vue-tsc && vite build --mode analyze",
    "preview": "vite preview",
    "type-check": "vue-tsc --noEmit",
    "lint": "eslint . --ext .vue,.js,.ts --fix",
    "format": "prettier --write 'src/**/*.{vue,js,ts,css,scss}'",
    "prepare": "husky install"
  },
  "dependencies": {
    "axios": "^1.6.0",
    "element-plus": "^2.4.0",
    "pinia": "^2.1.0",
    "pinia-plugin-persistedstate": "^3.2.0",
    "vue": "^3.3.0",
    "vue-router": "^4.2.0"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "@typescript-eslint/eslint-plugin": "^6.0.0",
    "@typescript-eslint/parser": "^6.0.0",
    "@vitejs/plugin-vue": "^4.0.0",
    "@vue/eslint-config-typescript": "^12.0.0",
    "eslint": "^8.0.0",
    "eslint-config-prettier": "^9.0.0",
    "eslint-plugin-prettier": "^5.0.0",
    "eslint-plugin-vue": "^9.0.0",
    "husky": "^8.0.0",
    "lint-staged": "^15.0.0",
    "prettier": "^3.0.0",
    "rollup-plugin-visualizer": "^5.9.0",
    "sass": "^1.69.0",
    "typescript": "^5.0.0",
    "unplugin-auto-import": "^0.16.0",
    "unplugin-vue-components": "^0.25.0",
    "vite": "^4.0.0",
    "vite-plugin-compression": "^0.5.0",
    "vite-plugin-imagemin": "^0.6.0",
    "vite-plugin-progress": "^0.0.7",
    "vue-tsc": "^1.8.0"
  }
}

十二、总结:从“搭环境”到“搭项目”

回顾一下,我们做了什么:

  1. 项目初始化:从空文件夹开始,手动搭建了项目结构
  2. TypeScript配置:告别 any,拥抱类型安全
  3. 环境变量:一套代码,多套环境
  4. 路由配置:让页面动起来
  5. Pinia状态管理:比 Vuex 更香的选择
  6. Element Plus 按需引入:告别全量引入的臃肿
  7. Axios封装:统一的请求处理
  8. 代码规范:ESLint + Prettier + Husky
  9. 打包优化:代码分割、图片压缩、gzip

现在,你拥有的是一个真正的企业级项目模板,而不仅仅是一个“能跑的项目”。

为什么这很重要?

// 面试官问:你们的项目是怎么配置的?
// 初级回答:用的 create-vue 脚手架
// 中级回答:配置了路由、状态管理、按需引入
// 高级回答:从零搭建了完整的工程化体系,包括代码规范、打包优化、环境管理等

当你能够从零搭建一个企业级项目,你就掌握了前端工程化的核心能力。这不仅是一份工作,更是一种将想法转化为产品的能力。

下一步做什么?

  1. 添加单元测试:Vitest + Vue Test Utils
  2. 配置 CI/CD:GitHub Actions 自动部署
  3. 添加 Mock 服务:开发环境模拟数据
  4. 性能监控:集成 Sentry 等错误监控
  5. 文档生成:使用 VitePress 生成组件文档

记住:好的工程化不是一蹴而就的,而是在实践中不断完善的。现在,带着这个模板去创建你的下一个项目吧!🚀

Vue3 组件封装实战 | 从 0 封装一个可复用的表格组件(附插槽 / Props 设计)

作者 代码煮茶
2026年3月13日 15:13

一、为什么要封装组件?

在企业级项目中,表格是最常见的 UI 形态之一。几乎每个后台管理系统都有大量的表格页面:用户列表、订单管理、商品管理...如果每个页面都重复写表格逻辑,不仅代码冗余,维护成本也极高。

封装表格组件的价值:

  • 提升开发效率:一次封装,多处使用
  • 统一交互体验:分页、排序、筛选行为一致
  • 降低维护成本:修改逻辑只需改一处
  • 代码复用:避免重复造轮子

二、组件设计思路

2.1 需求分析

一个成熟的表格组件应该具备哪些能力?

// 核心功能需求
1. 数据展示:支持列表数据渲染
2. 列配置:自定义列标题、字段、宽度、对齐方式
3. 分页:支持分页器,可配置每页条数
4. 排序:支持单列排序、多列排序
5. 筛选:支持表头筛选
6. 操作列:编辑、删除等操作按钮
7. 自定义内容:插槽支持个性化渲染
8. 加载状态:显示加载中效果
9. 空状态:无数据时显示占位
10. 选择功能:支持行选择(单选/多选)
11. 展开行:支持展开查看更多信息
12. 固定列:左侧/右侧固定列

2.2 组件设计原则

// 1. 单一职责原则
// 表格组件只负责表格渲染,不关心数据获取

// 2. 可配置原则
// 通过 props 提供灵活的配置选项

// 3. 可扩展原则
// 通过插槽支持自定义内容

// 4. 类型安全
// 使用 TypeScript 定义 Props 和事件

三、基础版本实现

3.1 项目初始化

# 创建项目
npm create vite@latest vue3-table-demo -- --template vue-ts

# 安装依赖
npm install element-plus @element-plus/icons-vue

# 启动项目
cd vue3-table-demo
npm run dev

3.2 基础表格组件

<!-- components/BaseTable.vue -->
<template>
  <div class="base-table">
    <!-- 表格主体 -->
    <el-table
      v-loading="loading"
      :data="data"
      :border="border"
      :stripe="stripe"
      :size="size"
      :empty-text="emptyText"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @row-click="handleRowClick"
    >
      <!-- 选择列 -->
      <el-table-column
        v-if="showSelection"
        type="selection"
        width="55"
        fixed="left"
      />
      
      <!-- 序号列 -->
      <el-table-column
        v-if="showIndex"
        type="index"
        width="55"
        label="序号"
        fixed="left"
      />
      
      <!-- 动态渲染列 -->
      <template v-for="column in columns" :key="column.prop">
        <!-- 有自定义插槽的列 -->
        <el-table-column
          v-if="column.slot"
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
        >
          <template #default="{ row, $index }">
            <slot 
              :name="column.slot" 
              :row="row" 
              :index="$index"
              :prop="column.prop"
            >
              {{ row[column.prop] }}
            </slot>
          </template>
        </el-table-column>
        
        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :formatter="column.formatter"
          :show-overflow-tooltip="column.showTooltip"
        />
      </template>
      
      <!-- 操作列(预留插槽) -->
      <el-table-column
        v-if="$slots.action"
        label="操作"
        :width="actionWidth"
        :fixed="actionFixed"
        align="center"
      >
        <template #default="{ row, $index }">
          <slot name="action" :row="row" :index="$index" />
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 分页器 -->
    <div v-if="showPagination" class="table-pagination">
      <el-pagination
        v-model:current-page="currentPage"
        v-model:page-size="pageSize"
        :page-sizes="pageSizes"
        :total="total"
        :layout="paginationLayout"
        background
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { PropType } from 'vue'

// TypeScript 接口定义
export interface TableColumn {
  prop: string                // 字段名
  label: string               // 列标题
  width?: number | string     // 宽度
  align?: 'left' | 'center' | 'right'  // 对齐方式
  fixed?: boolean | 'left' | 'right'   // 固定列
  sortable?: boolean          // 是否可排序
  slot?: string               // 插槽名称
  formatter?: (row: any, column: any, cellValue: any, index: number) => any  // 格式化函数
  showTooltip?: boolean       // 超出是否显示tooltip
}

// Props 定义
const props = defineProps({
  // 表格数据
  data: {
    type: Array as PropType<any[]>,
    required: true,
    default: () => []
  },
  
  // 列配置
  columns: {
    type: Array as PropType<TableColumn[]>,
    required: true,
    default: () => []
  },
  
  // 总条数(用于分页)
  total: {
    type: Number,
    default: 0
  },
  
  // 是否显示分页
  showPagination: {
    type: Boolean,
    default: true
  },
  
  // 当前页码
  page: {
    type: Number,
    default: 1
  },
  
  // 每页条数
  limit: {
    type: Number,
    default: 20
  },
  
  // 每页条数选项
  pageSizes: {
    type: Array as PropType<number[]>,
    default: () => [10, 20, 50, 100]
  },
  
  // 分页布局
  paginationLayout: {
    type: String,
    default: 'total, sizes, prev, pager, next, jumper'
  },
  
  // 是否显示选择列
  showSelection: {
    type: Boolean,
    default: false
  },
  
  // 是否显示序号列
  showIndex: {
    type: Boolean,
    default: false
  },
  
  // 是否显示边框
  border: {
    type: Boolean,
    default: true
  },
  
  // 是否显示斑马纹
  stripe: {
    type: Boolean,
    default: true
  },
  
  // 表格尺寸
  size: {
    type: String as PropType<'large' | 'default' | 'small'>,
    default: 'default'
  },
  
  // 加载状态
  loading: {
    type: Boolean,
    default: false
  },
  
  // 空数据提示
  emptyText: {
    type: String,
    default: '暂无数据'
  },
  
  // 操作列宽度
  actionWidth: {
    type: [Number, String],
    default: 150
  },
  
  // 操作列是否固定
  actionFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'right'
  }
})

// 事件定义
const emit = defineEmits([
  'update:page',
  'update:limit',
  'selection-change',
  'sort-change',
  'row-click',
  'page-change'
])

// 内部状态
const currentPage = ref(props.page)
const pageSize = ref(props.limit)

// 监听外部变化
watch(() => props.page, (val) => {
  currentPage.value = val
})

watch(() => props.limit, (val) => {
  pageSize.value = val
})

// 分页变化处理
const handleSizeChange = (size: number) => {
  pageSize.value = size
  emit('update:limit', size)
  emit('page-change', { page: currentPage.value, limit: size })
}

const handleCurrentChange = (page: number) => {
  currentPage.value = page
  emit('update:page', page)
  emit('page-change', { page, limit: pageSize.value })
}

// 选择变化处理
const handleSelectionChange = (selection: any[]) => {
  emit('selection-change', selection)
}

// 排序变化处理
const handleSortChange = ({ prop, order, column }: any) => {
  emit('sort-change', { prop, order, column })
}

// 行点击处理
const handleRowClick = (row: any, column: any, event: Event) => {
  emit('row-click', { row, column, event })
}

// 暴露方法给父组件
defineExpose({
  // 清除选择
  clearSelection: () => {
    // 通过 ref 调用 el-table 的方法
  },
  
  // 切换某行的选择状态
  toggleRowSelection: (row: any, selected?: boolean) => {
    // 实现...
  }
})
</script>

<style scoped lang="scss">
.base-table {
  width: 100%;
  
  .table-pagination {
    margin-top: 20px;
    display: flex;
    justify-content: flex-end;
  }
}
</style>

四、增强版封装(企业级)

4.1 高级表格组件

<!-- components/ProTable.vue -->
<template>
  <div class="pro-table">
    <!-- 工具栏 -->
    <div v-if="showToolbar" class="table-toolbar">
      <div class="toolbar-left">
        <slot name="toolbar-left">
          <span class="table-title">{{ title }}</span>
        </slot>
      </div>
      
      <div class="toolbar-right">
        <slot name="toolbar-right">
          <!-- 刷新按钮 -->
          <el-button 
            v-if="showRefresh" 
            :icon="Refresh" 
            circle 
            @click="handleRefresh"
          />
          
          <!-- 密度切换 -->
          <el-dropdown v-if="showDensity" @command="handleDensityChange">
            <el-button :icon="Grid" circle />
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item command="large">宽松</el-dropdown-item>
                <el-dropdown-item command="default">默认</el-dropdown-item>
                <el-dropdown-item command="small">紧凑</el-dropdown-item>
              </el-dropdown-menu>
            </template>
          </el-dropdown>
          
          <!-- 列设置 -->
          <el-popover
            v-if="showColumnSetting"
            placement="bottom-end"
            :width="200"
            trigger="click"
          >
            <template #reference>
              <el-button :icon="Setting" circle />
            </template>
            
            <div class="column-setting">
              <div class="setting-header">
                <span>列展示</span>
                <el-checkbox 
                  v-model="checkAll" 
                  :indeterminate="isIndeterminate"
                  @change="handleCheckAllChange"
                >
                  全选
                </el-checkbox>
              </div>
              <el-divider />
              <el-checkbox-group v-model="checkedColumns" @change="handleCheckedChange">
                <div v-for="col in allColumns" :key="col.prop" class="setting-item">
                  <el-checkbox :label="col.prop">
                    {{ col.label }}
                  </el-checkbox>
                  <el-icon class="drag-icon"><Rank /></el-icon>
                </div>
              </el-checkbox-group>
            </div>
          </el-popover>
        </slot>
      </div>
    </div>
    
    <!-- 表格主体 -->
    <el-table
      ref="tableRef"
      v-loading="loading"
      :data="filteredData"
      :border="border"
      :stripe="stripe"
      :size="tableSize"
      :empty-text="emptyText"
      :row-key="rowKey"
      :expand-row-keys="expandRowKeys"
      :default-sort="defaultSort"
      :span-method="spanMethod"
      :row-class-name="rowClassName"
      :cell-class-name="cellClassName"
      :header-row-class-name="headerRowClassName"
      :header-cell-class-name="headerCellClassName"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @row-click="handleRowClick"
      @row-dblclick="handleRowDblClick"
      @expand-change="handleExpandChange"
    >
      <!-- 展开行 -->
      <el-table-column
        v-if="showExpand"
        type="expand"
        width="50"
      >
        <template #default="{ row }">
          <slot name="expand" :row="row" />
        </template>
      </el-table-column>
      
      <!-- 选择列 -->
      <el-table-column
        v-if="showSelection"
        type="selection"
        :width="selectionWidth"
        :fixed="selectionFixed"
        :selectable="selectable"
        :reserve-selection="reserveSelection"
      />
      
      <!-- 序号列 -->
      <el-table-column
        v-if="showIndex"
        type="index"
        :width="indexWidth"
        :label="indexLabel"
        :fixed="indexFixed"
        :index="indexMethod"
      />
      
      <!-- 动态渲染列(支持拖拽排序) -->
      <template v-for="column in visibleColumns" :key="column.prop">
        <!-- 有自定义插槽的列 -->
        <el-table-column
          v-if="column.slot"
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :min-width="column.minWidth"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :sort-method="column.sortMethod"
          :sort-by="column.sortBy"
          :sort-orders="column.sortOrders"
          :resizable="column.resizable !== false"
          :show-overflow-tooltip="column.showTooltip"
        >
          <template #default="{ row, $index }">
            <slot 
              :name="column.slot" 
              :row="row" 
              :index="$index"
              :prop="column.prop"
              :column="column"
            >
              {{ formatCellValue(row, column) }}
            </slot>
          </template>
          
          <template #header="{ column: col, $index }">
            <slot 
              :name="`header-${column.prop}`" 
              :column="col" 
              :index="$index"
              :prop="column.prop"
            >
              {{ column.label }}
            </slot>
          </template>
        </el-table-column>
        
        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :min-width="column.minWidth"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :sort-method="column.sortMethod"
          :sort-by="column.sortBy"
          :sort-orders="column.sortOrders"
          :resizable="column.resizable !== false"
          :formatter="column.formatter"
          :show-overflow-tooltip="column.showTooltip"
        >
          <template #default="{ row, column: col, $index }">
            {{ formatCellValue(row, column) }}
          </template>
        </el-table-column>
      </template>
      
      <!-- 操作列 -->
      <el-table-column
        v-if="hasAction"
        :label="actionLabel"
        :width="actionWidth"
        :min-width="actionMinWidth"
        :fixed="actionFixed"
        :align="actionAlign"
      >
        <template #default="{ row, $index }">
          <slot 
            name="action" 
            :row="row" 
            :index="$index"
          />
        </template>
      </el-table-column>
      
      <!-- 自定义列插槽 -->
      <slot name="append" />
    </el-table>
    
    <!-- 底部区域 -->
    <div class="table-footer">
      <!-- 左侧统计信息 -->
      <div v-if="showSummary" class="footer-left">
        <slot name="summary">
          <span>共 {{ total }} 条记录</span>
          <span v-if="showSelection && selectedRows.length">
            已选择 {{ selectedRows.length }} 条
          </span>
        </slot>
      </div>
      
      <!-- 右侧分页器 -->
      <div v-if="showPagination" class="footer-right">
        <el-pagination
          v-model:current-page="currentPage"
          v-model:page-size="pageSize"
          :page-sizes="pageSizes"
          :total="total"
          :layout="paginationLayout"
          :background="paginationBackground"
          :disabled="paginationDisabled"
          :hide-on-single-page="hideOnSinglePage"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { Refresh, Grid, Setting, Rank } from '@element-plus/icons-vue'
import type { PropType } from 'vue'
import type { TableColumn } from './BaseTable'
import Sortable from 'sortablejs'

// Props 定义(继承 BaseTable 的 props 并扩展)
const props = defineProps({
  // ... 继承 BaseTable 的所有 props
  
  // 表格标题
  title: {
    type: String,
    default: ''
  },
  
  // 是否显示工具栏
  showToolbar: {
    type: Boolean,
    default: true
  },
  
  // 是否显示刷新按钮
  showRefresh: {
    type: Boolean,
    default: true
  },
  
  // 是否显示密度切换
  showDensity: {
    type: Boolean,
    default: true
  },
  
  // 是否显示列设置
  showColumnSetting: {
    type: Boolean,
    default: true
  },
  
  // 行唯一标识
  rowKey: {
    type: String,
    default: 'id'
  },
  
  // 是否显示展开行
  showExpand: {
    type: Boolean,
    default: false
  },
  
  // 展开行的 keys
  expandRowKeys: {
    type: Array as PropType<string[]>,
    default: () => []
  },
  
  // 默认排序
  defaultSort: {
    type: Object as PropType<{ prop: string; order: 'ascending' | 'descending' }>,
    default: null
  },
  
  // 合并单元格的方法
  spanMethod: {
    type: Function as PropType<({
      row,
      column,
      rowIndex,
      columnIndex
    }: {
      row: any
      column: any
      rowIndex: number
      columnIndex: number
    }) => number[] | { rowspan: number; colspan: number }>,
    default: null
  },
  
  // 是否显示汇总信息
  showSummary: {
    type: Boolean,
    default: true
  },
  
  // 选择列宽度
  selectionWidth: {
    type: [Number, String],
    default: 55
  },
  
  // 选择列是否固定
  selectionFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'left'
  },
  
  // 行是否可选
  selectable: {
    type: Function as PropType<(row: any, index: number) => boolean>,
    default: null
  },
  
  // 是否保留选择(数据更新后)
  reserveSelection: {
    type: Boolean,
    default: false
  },
  
  // 序号列宽度
  indexWidth: {
    type: [Number, String],
    default: 60
  },
  
  // 序号列标签
  indexLabel: {
    type: String,
    default: '序号'
  },
  
  // 序号列是否固定
  indexFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'left'
  },
  
  // 序号生成方法
  indexMethod: {
    type: Function as PropType<(index: number) => number>,
    default: (index: number) => index + 1
  },
  
  // 操作列标签
  actionLabel: {
    type: String,
    default: '操作'
  },
  
  // 操作列最小宽度
  actionMinWidth: {
    type: [Number, String],
    default: 120
  },
  
  // 操作列对齐方式
  actionAlign: {
    type: String as PropType<'left' | 'center' | 'right'>,
    default: 'center'
  },
  
  // 分页器背景
  paginationBackground: {
    type: Boolean,
    default: true
  },
  
  // 分页器禁用
  paginationDisabled: {
    type: Boolean,
    default: false
  },
  
  // 只有一页时是否隐藏分页器
  hideOnSinglePage: {
    type: Boolean,
    default: false
  },
  
  // 行类名
  rowClassName: {
    type: [String, Function] as PropType<string | (({ row, rowIndex }: { row: any; rowIndex: number }) => string)>,
    default: ''
  },
  
  // 单元格类名
  cellClassName: {
    type: [String, Function] as PropType<string | (({ row, column, rowIndex, columnIndex }: any) => string)>,
    default: ''
  }
})

// 事件定义
const emit = defineEmits([
  // ... 继承 BaseTable 的事件
  'refresh',
  'density-change',
  'column-change',
  'row-dblclick',
  'expand-change'
])

// 表格引用
const tableRef = ref()

// 内部状态
const tableSize = ref<'large' | 'default' | 'small'>(props.size as any)
const selectedRows = ref<any[]>([])
const checkedColumns = ref<string[]>([])
const allColumns = ref<TableColumn[]>([])

// 计算属性:是否有操作列
const hasAction = computed(() => !!props.$slots.action)

// 计算属性:可见列
const visibleColumns = computed(() => {
  if (!checkedColumns.value.length) return allColumns.value
  return allColumns.value.filter(col => checkedColumns.value.includes(col.prop))
})

// 计算属性:过滤后的数据(可用于前端搜索)
const filteredData = computed(() => {
  // 实现前端筛选逻辑
  return props.data
})

// 初始化列配置
onMounted(() => {
  allColumns.value = props.columns.filter(col => !col.hidden)
  checkedColumns.value = allColumns.value.map(col => col.prop)
  initDrag()
})

// 初始化拖拽排序
const initDrag = () => {
  nextTick(() => {
    const settingEl = document.querySelector('.column-setting .el-checkbox-group')
    if (!settingEl) return
    
    new Sortable(settingEl as HTMLElement, {
      animation: 150,
      handle: '.drag-icon',
      onEnd: (evt) => {
        const { oldIndex, newIndex } = evt
        if (oldIndex === newIndex) return
        
        // 重新排序列
        const newColumns = [...allColumns.value]
        const [movedColumn] = newColumns.splice(oldIndex!, 1)
        newColumns.splice(newIndex!, 0, movedColumn)
        allColumns.value = newColumns
        
        emit('column-change', newColumns)
      }
    })
  })
}

// 格式化单元格值
const formatCellValue = (row: any, column: TableColumn) => {
  if (column.formatter) {
    return column.formatter(row, column, row[column.prop], 0)
  }
  return row[column.prop]
}

// 列设置相关
const checkAll = computed({
  get: () => checkedColumns.value.length === allColumns.value.length,
  set: (val) => {
    checkedColumns.value = val ? allColumns.value.map(col => col.prop) : []
  }
})

const isIndeterminate = computed(() => {
  return checkedColumns.value.length > 0 && 
         checkedColumns.value.length < allColumns.value.length
})

const handleCheckAllChange = (val: boolean) => {
  checkedColumns.value = val ? allColumns.value.map(col => col.prop) : []
  emit('column-change', visibleColumns.value)
}

const handleCheckedChange = (value: string[]) => {
  emit('column-change', visibleColumns.value)
}

// 密度切换
const handleDensityChange = (size: string) => {
  tableSize.value = size as any
  emit('density-change', size)
}

// 刷新
const handleRefresh = () => {
  emit('refresh')
}

// 双击行
const handleRowDblClick = (row: any, column: any) => {
  emit('row-dblclick', { row, column })
}

// 展开行变化
const handleExpandChange = (row: any, expandedRows: any[]) => {
  emit('expand-change', { row, expandedRows })
}

// 暴露方法
defineExpose({
  // 清除选择
  clearSelection: () => {
    tableRef.value?.clearSelection()
    selectedRows.value = []
  },
  
  // 切换行选择
  toggleRowSelection: (row: any, selected?: boolean) => {
    tableRef.value?.toggleRowSelection(row, selected)
  },
  
  // 切换所有行选择
  toggleAllSelection: () => {
    tableRef.value?.toggleAllSelection()
  },
  
  // 设置某行展开状态
  toggleRowExpansion: (row: any, expanded?: boolean) => {
    tableRef.value?.toggleRowExpansion(row, expanded)
  },
  
  // 设置当前行
  setCurrentRow: (row: any) => {
    tableRef.value?.setCurrentRow(row)
  },
  
  // 清除排序
  clearSort: () => {
    tableRef.value?.clearSort()
  },
  
  // 清除筛选
  clearFilter: (columnKeys?: string[]) => {
    tableRef.value?.clearFilter(columnKeys)
  },
  
  // 重新布局
  doLayout: () => {
    tableRef.value?.doLayout()
  },
  
  // 滚动到某行
  scrollToRow: (row: any, offset?: number) => {
    // 实现滚动逻辑
  }
})
</script>

<style scoped lang="scss">
.pro-table {
  background-color: #fff;
  border-radius: 4px;
  padding: 16px;
  
  .table-toolbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;
    
    .toolbar-left {
      .table-title {
        font-size: 16px;
        font-weight: 600;
        color: #303133;
      }
    }
    
    .toolbar-right {
      display: flex;
      gap: 8px;
    }
  }
  
  .table-footer {
    margin-top: 16px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    
    .footer-left {
      color: #909399;
      font-size: 14px;
      
      span {
        margin-right: 16px;
      }
    }
  }
  
  .column-setting {
    padding: 8px;
    
    .setting-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 8px;
    }
    
    .setting-item {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 4px 0;
      
      &:hover {
        background-color: #f5f7fa;
      }
      
      .drag-icon {
        cursor: move;
        color: #909399;
      }
    }
  }
}
</style>

五、使用示例

5.1 基础用法

<!-- views/UserList.vue -->
<template>
  <div class="user-list">
    <pro-table
      ref="tableRef"
      :data="userList"
      :columns="columns"
      :total="total"
      :loading="loading"
      :show-selection="true"
      :show-index="true"
      :page="page"
      :limit="limit"
      @page-change="handlePageChange"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @refresh="handleRefresh"
    >
      <!-- 自定义状态列 -->
      <template #status="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'info'">
          {{ row.status === 1 ? '启用' : '禁用' }}
        </el-tag>
      </template>
      
      <!-- 自定义操作列 -->
      <template #action="{ row }">
        <el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
        <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
      </template>
    </pro-table>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ProTable from '@/components/ProTable.vue'
import type { TableColumn } from '@/components/BaseTable'
import { getUserList } from '@/api/user'

// 表格列配置
const columns: TableColumn[] = [
  {
    prop: 'name',
    label: '姓名',
    width: 120,
    sortable: true
  },
  {
    prop: 'age',
    label: '年龄',
    width: 80,
    align: 'center'
  },
  {
    prop: 'email',
    label: '邮箱',
    minWidth: 200,
    showTooltip: true
  },
  {
    prop: 'phone',
    label: '手机号',
    width: 150
  },
  {
    prop: 'status',
    label: '状态',
    width: 80,
    slot: 'status'  // 使用自定义插槽
  },
  {
    prop: 'createTime',
    label: '创建时间',
    width: 180,
    sortable: true,
    formatter: (row: any, column: any, value: string) => {
      return new Date(value).toLocaleString()
    }
  }
]

// 表格数据
const userList = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const limit = ref(20)

// 获取数据
const fetchData = async () => {
  loading.value = true
  try {
    const res = await getUserList({
      page: page.value,
      limit: limit.value
    })
    userList.value = res.list
    total.value = res.total
  } finally {
    loading.value = false
  }
}

// 分页变化
const handlePageChange = ({ page: newPage, limit: newLimit }: any) => {
  page.value = newPage
  limit.value = newLimit
  fetchData()
}

// 选择变化
const handleSelectionChange = (selection: any[]) => {
  console.log('选中:', selection)
}

// 排序变化
const handleSortChange = ({ prop, order }: any) => {
  console.log('排序:', prop, order)
  // 可以在这里处理排序逻辑
}

// 刷新
const handleRefresh = () => {
  fetchData()
}

// 编辑
const handleEdit = (row: any) => {
  console.log('编辑:', row)
}

// 删除
const handleDelete = (row: any) => {
  ElMessageBox.confirm('确认删除该用户吗?', '提示', {
    type: 'warning'
  }).then(() => {
    // 调用删除接口
    ElMessage.success('删除成功')
    fetchData()
  })
}

onMounted(() => {
  fetchData()
})
</script>

5.2 高级用法:动态列 + 展开行

<!-- views/OrderList.vue -->
<template>
  <pro-table
    :data="orderList"
    :columns="dynamicColumns"
    :total="total"
    :show-expand="true"
    :show-summary="true"
    :span-method="objectSpanMethod"
  >
    <!-- 展开行内容 -->
    <template #expand="{ row }">
      <div class="order-detail">
        <h4>订单详情</h4>
        <el-descriptions :column="3" border>
          <el-descriptions-item label="商品名称">{{ row.productName }}</el-descriptions-item>
          <el-descriptions-item label="单价">¥{{ row.price }}</el-descriptions-item>
          <el-descriptions-item label="数量">{{ row.quantity }}</el-descriptions-item>
          <el-descriptions-item label="总价">¥{{ row.totalPrice }}</el-descriptions-item>
          <el-descriptions-item label="下单时间">{{ row.orderTime }}</el-descriptions-item>
          <el-descriptions-item label="支付方式">{{ row.payMethod }}</el-descriptions-item>
        </el-descriptions>
      </div>
    </template>
    
    <!-- 自定义操作列 -->
    <template #action="{ row }">
      <el-button type="primary" link @click="viewOrder(row)">查看</el-button>
      <el-button type="success" link @click="processOrder(row)">处理</el-button>
    </template>
  </pro-table>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'

// 动态列配置(可以根据权限动态生成)
const columnsConfig = ref([
  { prop: 'orderNo', label: '订单号', width: 180, fixed: 'left' },
  { prop: 'customer', label: '客户', width: 120 },
  { prop: 'amount', label: '金额', width: 120, align: 'right' },
  { prop: 'status', label: '状态', width: 100 },
  { prop: 'payStatus', label: '支付状态', width: 100 },
  { prop: 'deliveryStatus', label: '发货状态', width: 100 },
  { prop: 'createTime', label: '创建时间', width: 180 },
  { prop: 'updateTime', label: '更新时间', width: 180 }
])

// 根据用户权限过滤列
const dynamicColumns = computed(() => {
  const userPermissions = ['orderNo', 'customer', 'amount', 'status']
  return columnsConfig.value.filter(col => userPermissions.includes(col.prop))
})

// 合并单元格
const objectSpanMethod = ({ row, column, rowIndex, columnIndex }: any) => {
  if (columnIndex === 0) {
    if (rowIndex % 2 === 0) {
      return {
        rowspan: 2,
        colspan: 1
      }
    } else {
      return {
        rowspan: 0,
        colspan: 0
      }
    }
  }
}
</script>

六、单元测试

// __tests__/ProTable.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ProTable from '@/components/ProTable.vue'

describe('ProTable.vue', () => {
  const mockColumns = [
    { prop: 'name', label: '姓名' },
    { prop: 'age', label: '年龄' }
  ]
  
  const mockData = [
    { name: '张三', age: 25 },
    { name: '李四', age: 30 }
  ]
  
  it('renders table correctly', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: mockColumns,
        total: 2
      }
    })
    
    expect(wrapper.find('.pro-table').exists()).toBe(true)
    expect(wrapper.findAll('.el-table__row').length).toBe(2)
  })
  
  it('emits page-change event when pagination changes', async () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: mockColumns,
        total: 100,
        showPagination: true
      }
    })
    
    // 模拟分页变化
    await wrapper.find('.el-pagination .btn-next').trigger('click')
    
    expect(wrapper.emitted('page-change')).toBeTruthy()
    expect(wrapper.emitted('page-change')?.[0]).toEqual([{ page: 2, limit: 20 }])
  })
  
  it('shows loading state', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: [],
        columns: mockColumns,
        loading: true
      }
    })
    
    expect(wrapper.find('.el-loading-mask').exists()).toBe(true)
  })
  
  it('renders custom slot content', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: [
          { prop: 'name', label: '姓名', slot: 'customName' }
        ]
      },
      slots: {
        customName: '<span class="custom-name">{{ row.name }}</span>'
      }
    })
    
    expect(wrapper.find('.custom-name').exists()).toBe(true)
  })
})

七、性能优化

7.1 虚拟滚动(大数据量)

<!-- 对于大量数据,可以使用虚拟滚动 -->
<template>
  <el-table
    v-loading="loading"
    :data="visibleData"
    :height="tableHeight"
    style="width: 100%"
    @scroll="handleScroll"
  >
    <!-- 列配置 -->
  </el-table>
</template>

<script setup>
import { ref, computed } from 'vue'

const props = defineProps({
  data: {
    type: Array,
    default: () => []
  },
  rowHeight: {
    type: Number,
    default: 48
  },
  bufferSize: {
    type: Number,
    default: 10
  }
})

const scrollTop = ref(0)
const tableHeight = ref(600)

// 计算可见范围
const visibleCount = computed(() => Math.ceil(tableHeight.value / props.rowHeight))

const startIndex = computed(() => {
  return Math.max(0, Math.floor(scrollTop.value / props.rowHeight) - props.bufferSize)
})

const endIndex = computed(() => {
  return Math.min(
    props.data.length,
    startIndex.value + visibleCount.value + props.bufferSize * 2
  )
})

const visibleData = computed(() => {
  return props.data.slice(startIndex.value, endIndex.value)
})

const handleScroll = (e) => {
  scrollTop.value = e.target.scrollTop
}
</script>

7.2 大数据量优化策略

// 1. 使用虚拟滚动
// 2. 按需渲染
// 3. 使用函数式组件
// 4. 避免不必要的响应式
// 5. 使用 computed 缓存计算结果
// 6. 列表项使用唯一的 key
// 7. 使用 v-once 处理静态内容

八、总结与最佳实践

8.1 组件设计要点

  1. Props 设计原则

    • 提供合理的默认值
    • 使用 TypeScript 类型定义
    • 保持 API 简洁但够用
  2. 插槽设计原则

    • 提供足够的自定义能力
    • 作用域插槽传递必要数据
    • 预留扩展位置
  3. 事件设计原则

    • 遵循 v-model 规范
    • 提供完整的事件体系
    • 事件命名清晰规范

8.2 使用建议

// 1. 合理配置列宽度
const columns = [
  { prop: 'name', label: '姓名', width: 120 }, // 固定宽度
  { prop: 'address', label: '地址', minWidth: 200 }, // 最小宽度
  { prop: 'description', label: '描述', width: 'auto' } // 自适应
]

// 2. 使用唯一 rowKey
<pro-table :data="list" row-key="id" />

// 3. 合理使用插槽
<template #status="{ row }">
  <Badge :status="row.status" />
</template>

// 4. 处理加载状态
<pro-table :loading="loading" :data="list" />

// 5. 处理空状态
<pro-table :data="[]" empty-text="暂无数据" />

8.3 扩展思考

  1. 如何支持表格导出?

    • 添加导出按钮和导出方法
    • 支持导出当前页或全部数据
    • 支持导出格式配置(CSV/Excel)
  2. 如何支持表格打印?

    • 添加打印样式
    • 隐藏操作列和按钮
    • 调整列宽适配打印
  3. 如何支持表格列拖动调整宽度?

    • 使用 resizable 属性
    • 保存用户调整后的宽度到 localStorage
  4. 如何支持表格状态持久化?

    • 保存列显示状态
    • 保存排序状态
    • 保存筛选状态

通过合理封装表格组件,可以极大提升开发效率,保证项目代码质量,这也是企业级前端开发的核心能力之一。

❌
❌