Vue 3 + Vite 工程化实战:从零搭建企业级项目

Vue 3 + Vite 工程化实战:从零搭建企业级项目

admin
2026-07-05 / 0 评论 / 4 阅读
Vite 已成为 Vue 项目的标配构建工具。本文从零搭建一个包含目录规范、请求封装、权限路由、Mock 数据、环境配置和 CI/CD 的企业级 Vue 3 项目。

一、项目初始化

创建项目

# 创建 Vue 3 项目
npm create vue@latest my-app

# 交互选择:
# ✓ TypeScript
# ✓ JSX Support
# ✓ Router
# ✓ Pinia
# ✓ Vitest
# ✓ ESLint
# ✓ Prettier

cd my-app
npm install

安装核心依赖

# UI 组件库(按需选择)
npm install element-plus
npm install -D unplugin-vue-components unplugin-auto-import

# HTTP 请求
npm install axios

# 实用工具
npm install dayjs lodash-es @vueuse/core

# 图表(按需)
npm install echarts

二、目录结构规范

my-app/
├── public/                    # 静态资源(不参与构建)
├── src/
│   ├── api/                   # API 接口定义
│   │   ├── modules/
│   │   │   ├── user.js
│   │   │   ├── order.js
│   │   │   └── product.js
│   │   └── index.js           # 统一导出
│   ├── assets/                # 静态资源(参与构建)
│   │   ├── images/
│   │   └── styles/
│   ├── components/            # 全局公共组件
│   │   ├── AppTable/
│   │   │   └── index.vue
│   │   └── AppDialog/
│   ├── composables/           # 组合式函数
│   │   ├── useAuth.js
│   │   ├── usePagination.js
│   │   └── useRequest.js
│   ├── config/                # 项目配置
│   │   └── index.js
│   ├── constants/             # 常量定义
│   │   └── index.js
│   ├── directives/            # 自定义指令
│   │   ├── permission.js
│   │   └── lazy.js
│   ├── layouts/               # 布局组件
│   │   ├── DefaultLayout.vue
│   │   └── BlankLayout.vue
│   ├── plugins/               # 插件配置
│   │   └── elementPlus.js
│   ├── router/                # 路由
│   │   ├── index.js
│   │   ├── guards.js
│   │   └── routes/
│   │       ├── modules/       # 路由模块
│   │       └── index.js
│   ├── stores/                # Pinia Store
│   │   ├── user.js
│   │   ├── permission.js
│   │   └── app.js
│   ├── styles/                # 全局样式
│   │   ├── variables.scss
│   │   ├── mixins.scss
│   │   └── index.scss
│   ├── utils/                 # 工具函数
│   │   ├── request.js         # axios 封装
│   │   ├── storage.js
│   │   └── format.js
│   ├── views/                 # 页面组件
│   │   ├── login/
│   │   ├── dashboard/
│   │   └── system/
│   ├── App.vue
│   └── main.js
├── .env                       # 所有环境通用
├── .env.development           # 开发环境
├── .env.production            # 生产环境
├── .env.staging               # 预发布环境
├── vite.config.js
└── package.json

三、Vite 配置详解

// vite.config.js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } 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(({ mode }) => {
  const env = loadEnv(mode, process.cwd())

  return {
    plugins: [
      vue(),
      // 自动导入 Vue API
      AutoImport({
        imports: ['vue', 'vue-router', 'pinia'],
        resolvers: [ElementPlusResolver()],
        dts: 'src/auto-imports.d.js'
      }),
      // 组件自动注册
      Components({
        resolvers: [ElementPlusResolver()],
        dts: 'src/components.d.js'
      })
    ],

    resolve: {
      alias: {
        '@': fileURLToPath(new URL('./src', import.meta.url))
      }
    },

    css: {
      preprocessorOptions: {
        scss: {
          additionalData: `@use "@/styles/variables.scss" as *;`
        }
      }
    },

    server: {
      port: 3000,
      open: true,
      proxy: {
        '/api': {
          target: env.VITE_API_BASE_URL,
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/api/, '')
        }
      }
    },

    build: {
      // 输出目录
      outDir: 'dist',
      // 静态资源分包
      rollupOptions: {
        output: {
          manualChunks: {
            'vendor-vue': ['vue', 'vue-router', 'pinia'],
            'vendor-ui': ['element-plus'],
            'vendor-utils': ['lodash-es', 'dayjs']
          },
          // 分包体积警告阈值
          chunkFileNames: 'assets/js/[name]-[hash].js',
          entryFileNames: 'assets/js/[name]-[hash].js',
          assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
        }
      },
      // chunk 大小警告
      chunkSizeWarningLimit: 1000,
      // 生产环境移除 console
      minify: 'terser',
      terserOptions: {
        compress: {
          drop_console: env.VITE_DROP_CONSOLE === 'true',
          drop_debugger: true
        }
      }
    }
  }
})

四、环境变量配置

# .env — 通用配置
VITE_APP_TITLE=企业管理系统

# .env.development
VITE_API_BASE_URL=http://localhost:8080
VITE_DROP_CONSOLE=false

# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_DROP_CONSOLE=true
// src/config/index.js
export const config = {
  title: import.meta.env.VITE_APP_TITLE,
  apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
  tokenKey: 'access_token',
  tokenExpireKey: 'token_expire'
}

五、Axios 封装

// src/utils/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import router from '@/router'

const service = axios.create({
  baseURL: '/api',
  timeout: 15000
})

// 请求拦截器
service.interceptors.request.use(
  (config) => {
    const userStore = useUserStore()

    // 携带 token
    if (userStore.token) {
      config.headers.Authorization = `Bearer ${userStore.token}`
    }

    // GET 请求防缓存
    if (config.method === 'get') {
      config.params = { ...config.params, _t: Date.now() }
    }

    return config
  },
  (error) => Promise.reject(error)
)

// 响应拦截器
let isRefreshing = false
let pendingRequests = []

service.interceptors.response.use(
  (response) => {
    const { code, data, message } = response.data

    // 业务状态码判断
    switch (code) {
      case 200:
        return data
      case 401:
        handleUnauthorized()
        return Promise.reject(new Error(message))
      default:
        ElMessage.error(message || '请求失败')
        return Promise.reject(new Error(message))
    }
  },
  async (error) => {
    const { response } = error

    if (response) {
      switch (response.status) {
        case 401:
          handleUnauthorized()
          break
        case 403:
          ElMessage.error('没有权限访问')
          break
        case 404:
          ElMessage.error('请求的资源不存在')
          break
        case 500:
          ElMessage.error('服务器错误')
          break
        default:
          ElMessage.error(response.data?.message || '请求失败')
      }
    } else if (error.code === 'ECONNABORTED') {
      ElMessage.error('请求超时')
    } else {
      ElMessage.error('网络异常')
    }

    return Promise.reject(error)
  }
)

// Token 过期处理
async function handleUnauthorized() {
  const userStore = useUserStore()

  if (!isRefreshing) {
    isRefreshing = true
    try {
      await userStore.refreshToken()
      // 重放失败请求
      pendingRequests.forEach(cb => cb())
      pendingRequests = []
    } catch {
      userStore.logout()
      router.push('/login')
    } finally {
      isRefreshing = false
    }
  }

  return new Promise((resolve) => {
    pendingRequests.push(() => resolve(service(error.config)))
  })
}

export default service
// src/api/modules/user.js
import request from '@/utils/request'

export const userApi = {
  login: (data) => request.post('/auth/login', data),
  logout: () => request.post('/auth/logout'),
  getProfile: () => request.get('/user/profile'),
  updateProfile: (data) => request.put('/user/profile', data),
  changePassword: (data) => request.put('/user/password', data),

  // 分页列表
  getUsers: (params) => request.get('/users', { params }),
  createUser: (data) => request.post('/users', data),
  updateUser: (id, data) => request.put(`/users/${id}`, data),
  deleteUser: (id) => request.delete(`/users/${id}`)
}

六、路由与权限

// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { setupGuards } from './guards'

// 基础路由 — 无需权限
export const constantRoutes = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/login/index.vue'),
    meta: { title: '登录' }
  },
  {
    path: '/',
    component: () => import('@/layouts/DefaultLayout.vue'),
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/dashboard/index.vue'),
        meta: { title: '仪表盘', icon: 'Odometer' }
      }
    ]
  },
  { path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/404.vue') }
]

const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes
})

setupGuards(router)

export default router
// src/router/guards.js
import { useUserStore } from '@/stores/user'
import { usePermissionStore } from '@/stores/permission'

const whiteList = ['/login']

export function setupGuards(router) {
  router.beforeEach(async (to, from, next) => {
    document.title = `${to.meta.title || ''} - 系统名称`

    const userStore = useUserStore()

    if (userStore.token) {
      if (to.path === '/login') {
        next({ path: '/' })
      } else {
        // 未加载用户信息和动态路由
        if (!userStore.user) {
          try {
            // 获取用户信息
            await userStore.getProfile()

            // 生成动态路由
            const permissionStore = usePermissionStore()
            const accessRoutes = await permissionStore.generateRoutes()

            // 动态添加路由
            accessRoutes.forEach(route => router.addRoute(route))

            // 重新导航,确保路由已注册
            next({ ...to, replace: true })
          } catch (error) {
            userStore.resetToken()
            next(`/login?redirect=${to.path}`)
          }
        } else {
          next()
        }
      }
    } else {
      // 白名单直接放行
      if (whiteList.includes(to.path)) {
        next()
      } else {
        next(`/login?redirect=${to.path}`)
      }
    }
  })
}
// src/stores/permission.js
import { defineStore } from 'pinia'
import api from '@/api'

// 视图模块映射
const viewModules = import.meta.glob('@/views/**/*.vue')

export const usePermissionStore = defineStore('permission', () => {
  const routes = ref([])

  async function generateRoutes() {
    // 从后端获取菜单权限
    const menus = await api.menu.getUserMenus()

    // 转换后端菜单为路由
    const asyncRoutes = transformRoutes(menus)
    routes.value = asyncRoutes

    return asyncRoutes
  }

  function transformRoutes(menus, parentPath = '') {
    return menus.map(menu => {
      const route = {
        path: menu.path.startsWith('/') ? menu.path : `${parentPath}/${menu.path}`,
        name: menu.name,
        meta: { title: menu.title, icon: menu.icon },
        children: []
      }

      // 组件解析
      const componentPath = menu.component || 'layouts/DefaultLayout'
      route.component = viewModules[`/src/views/${componentPath}.vue`]

      if (menu.children?.length) {
        route.children = transformRoutes(menu.children, route.path)
      }

      return route
    })
  }

  return { routes, generateRoutes }
})

七、Mock 数据

// vite.config.js 增加 mock 插件
import { viteMockServe } from 'vite-plugin-mock'

plugins: [
  vue(),
  viteMockServe({
    mockPath: 'mock',
    localEnabled: true
  })
]
// mock/user.js
export default [
  {
    url: '/api/user/profile',
    method: 'get',
    response: () => {
      return {
        code: 200,
        data: {
          id: 1,
          name: '张三',
          roles: ['admin'],
          permissions: ['system:user:list', 'system:user:create']
        }
      }
    }
  },
  {
    url: '/api/users',
    method: 'get',
    response: ({ query }) => {
      const { page = 1, pageSize = 10 } = query
      const list = Array.from({ length: 50 }, (_, i) => ({
        id: i + 1,
        name: `用户${i + 1}`,
        email: `user${i + 1}@example.com`,
        status: i % 2
      }))

      return {
        code: 200,
        data: {
          list: list.slice((page - 1) * pageSize, page * pageSize),
          total: list.length
        }
      }
    }
  }
]

八、代码规范

// eslint.config.js (Flat Config)
import js from '@eslint/js'
import vue from 'eslint-plugin-vue'
import prettier from 'eslint-config-prettier'

export default [
  js.configs.recommended,
  ...vue.configs['flat/recommended'],
  prettier,
  {
    rules: {
      'vue/multi-word-component-names': 'off',
      'no-console': 'warn',
      'no-debugger': 'warn'
    }
  }
]
// .prettierrc
{
  "semi": false,
  "singleQuote": true,
  "printWidth": 100,
  "trailingComma": "none",
  "arrowParens": "always"
}

九、Git 提交规范与 CI

// commitlint.config.js
export default {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [2, 'always', [
      'feat', 'fix', 'docs', 'style',
      'refactor', 'perf', 'test', 'chore'
    ]]
  }
}
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Test
        run: npm run test:unit

      - name: Build
        run: npm run build

      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist

十、小结

企业级 Vue 项目的工程化核心:规范的目录结构让团队协作有章可循,统一的请求封装处理认证与错误,动态路由实现权限控制,Mock 让前后端并行开发,ESLint + CI 保证代码质量。这套脚手架可以直接作为新项目的起点。


本文由 inspirecl.asia 原创,转载请注明出处。
0

评论 (0)

取消
0:00