MonkeyCode搞定前端工程化:从Vue3项目搭建到CI/CD部署

前端工程化的痛点

每个前端项目都会遇到这些问题:

  • 依赖版本冲突(同事A的node_modules能跑,B的跑不起来)
  • 构建速度慢(每次npm run build要等3分钟)
  • 代码风格不统一(Prettier和ESLint打起来了)
  • 部署到测试环境需要手动scp

这篇文章用MonkeyCode生成完整的Vue3项目工程化方案,包括配置、脚本和CI/CD。

给MonkeyCode的统一Prompt

用Vite + Vue3 + TypeScript搭建前端项目,要求:
1. ESLint + Prettier + stylelint配置
2. Pinia状态管理
3. Vue Router路由
4. Axios封装(拦截器、错误处理)
5. 环境变量配置(dev/staging/prod)
6. 组件自动导入(unplugin-vue-components)
7. Git Hooks(commit前检查、commit信息规范)
8. Docker构建配置
9. GitHub Actions CI/CD流水线

项目是一个后台管理系统,包含用户管理、订单管理模块

MonkeyCode生成的完整项目结构

my-vue3-admin/
├── .github/
│   └── workflows/
│       └── ci-cd.yml
├── .husky/
│   ├── commit-msg
│   └── pre-commit
├── docker/
│   ├── Dockerfile
│   └── nginx.conf
├── src/
│   ├── api/
│   │   ├── index.ts          # Axios封装
│   │   ├── user.ts           # 用户API
│   │   └── order.ts          # 订单API
│   ├── assets/
│   │   └── styles/
│   │       ├── variables.scss
│   │       └── common.scss
│   ├── components/
│   │   ├── common/           # 通用组件
│   │   └── business/         # 业务组件
│   ├── composables/          # 组合式函数
│   ├── layouts/
│   │   ├── DefaultLayout.vue
│   │   └── AuthLayout.vue
│   ├── router/
│   │   └── index.ts
│   ├── stores/               # Pinia stores
│   │   ├── user.ts
│   │   └── order.ts
│   ├── views/
│   │   ├── user/
│   │   │   ├── List.vue
│   │   │   └── Detail.vue
│   │   └── order/
│   │       ├── List.vue
│   │       └── Detail.vue
│   ├── App.vue
│   └── main.ts
├── env.d.ts
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
├── .eslintrc.cjs
├── .prettierrc
├── .stylelintrc.cjs
└── docker-compose.yml

MonkeyCode生成的配置文件

// vite.config.ts
import { defineConfig } 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 viteCompression from 'vite-plugin-compression'

export default defineConfig({
  plugins: [
    vue(),
    // 自动导入(API + 组件)
    AutoImport({
      imports: [
        'vue',
        'vue-router',
        'pinia',
        {
          'axios': ['default']
        }
      ],
      resolvers: [ElementPlusResolver()],
      dts: 'src/auto-imports.d.ts',
    }),
    Components({
      resolvers: [ElementPlusResolver()],
      dts: 'src/auto-components.d.ts',
    }),
    // Gzip压缩
    viteCompression({
      algorithm: 'gzip',
      ext: '.gz',
    }),
  ],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
    },
  },
  // 构建优化
  build: {
    target: 'es2015',
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true,
      },
    },
    rollupOptions: {
      output: {
        // 拆分vendor
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          'element-plus': ['element-plus'],
          'echarts': ['echarts'],
        },
      },
    },
  },
  // 开发服务器配置
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
})
// .eslintrc.cjs
module.exports = {
  root: true,
  env: {
    browser: true,
    es2021: true,
    node: true,
  },
  extends: [
    'eslint:recommended',
    'plugin:vue/vue3-recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:prettier/recommended', // 最后加载,覆盖ESLint规则
  ],
  parser: 'vue-eslint-parser',
  parserOptions: {
    ecmaVersion: 'latest',
    parser: '@typescript-eslint/parser',
    sourceType: 'module',
  },
  plugins: ['vue', '@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',
  },
}
// .prettierrc
{
  "semi": false,
  "singleQuote": true,
  "printWidth": 100,
  "tabWidth": 2,
  "trailingComma": "es5",
  "bracketSpacing": true,
  "arrowParens": "avoid",
  "endOfLine": "lf"
}

Axios封装(企业级)

// src/api/index.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'

// 环境配置
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL

// 创建axios实例
const service: AxiosInstance = axios.create({
  baseURL: API_BASE_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  },
})

// 请求拦截器
service.interceptors.request.use(
  (config) => {
    // 添加Token
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    
    // 添加租户ID
    const tenantId = localStorage.getItem('tenantId')
    if (tenantId) {
      config.headers['X-Tenant-ID'] = tenantId
    }
    
    // 请求防重复(POST/PUT/DELETE)
    const method = config.method?.toUpperCase()
    if (['POST', 'PUT', 'DELETE'].includes(method || '')) {
      const requestKey = `${config.url}-${JSON.stringify(config.data)}`
      if (window.__requestCache?.has(requestKey)) {
        return Promise.reject(new Error('重复请求'))
      }
      window.__requestCache?.add(requestKey)
      setTimeout(() => window.__requestCache?.delete(requestKey), 5000)
    }
    
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

// 响应拦截器
service.interceptors.response.use(
  (response: AxiosResponse) => {
    const res = response.data
    
    // 业务状态码处理
    if (response.config.responseType === 'blob') {
      // 文件下载直接返回
      return response
    }
    
    if (res.code !== 200 && res.code !== 0) {
      // 业务错误
      ElMessage.error(res.message || '请求失败')
      
      // Token过期
      if (res.code === 401) {
        localStorage.removeItem('token')
        router.push('/login')
      }
      
      return Promise.reject(new Error(res.message || '请求失败'))
    }
    
    return res
  },
  (error) => {
    // 网络错误处理
    let message = '网络错误,请稍后重试'
    
    if (error.code === 'ECONNABORTED') {
      message = '请求超时,请重试'
    } else if (error.response) {
      switch (error.response.status) {
        case 400:
          message = '请求参数错误'
          break
        case 401:
          message = '登录已过期,请重新登录'
          localStorage.removeItem('token')
          router.push('/login')
          break
        case 403:
          message = '没有权限'
          break
        case 404:
          message = '请求资源不存在'
          break
        case 500:
          message = '服务器错误'
          break
      }
    }
    
    ElMessage.error(message)
    return Promise.reject(error)
  }
)

// 扩展Request方法
service.form = <T = any>(url: string, data?: object, config?: AxiosRequestConfig) => {
  return service.post<T>(url, data, {
    ...config,
    headers: { 'Content-Type': 'multipart/form-data' },
  })
}

service.download = <T = any>(url: string, data?: object, config?: AxiosRequestConfig) => {
  return service.post<T>(url, data, {
    ...config,
    responseType: 'blob',
  })
}

export default service

// src/auto-imports.d.ts - 自动导入的类型声明
declare global {
  const service: typeof import('./api/index').default
  const useUserStore: typeof import('@/stores/user').useUserStore
  const useOrderStore: typeof import('@/stores/order').useOrderStore
}

Pinia Stores

// src/stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login, logout, getUserInfo } from '@/api/user'

interface UserInfo {
  id: number
  username: string
  nickname: string
  avatar: string
  roles: string[]
  permissions: string[]
}

export const useUserStore = defineStore('user', () => {
  // State
  const token = ref(localStorage.getItem('token') || '')
  const userInfo = ref<UserInfo | null>(null)
  const permissions = ref<string[]>([])
  
  // Getters
  const isLoggedIn = computed(() => !!token.value)
  const hasPermission = computed(() => (perm: string) => permissions.value.includes(perm))
  
  // Actions
  async function loginAction(username: string, password: string) {
    const res = await login({ username, password })
    token.value = res.data.token
    localStorage.setItem('token', res.data.token)
    return res.data
  }
  
  async function getUserInfoAction() {
    const res = await getUserInfo()
    userInfo.value = res.data.userInfo
    permissions.value = res.data.permissions
    return res.data
  }
  
  async function logoutAction() {
    await logout()
    token.value = ''
    userInfo.value = null
    permissions.value = []
    localStorage.removeItem('token')
  }
  
  return {
    token,
    userInfo,
    permissions,
    isLoggedIn,
    hasPermission,
    loginAction,
    getUserInfoAction,
    logoutAction,
  }
})

Git Hooks配置

# .husky/commit-msg
#!/bin/sh
npx --no -- commitlint --edit "$1"
# .husky/pre-commit
#!/bin/sh
npx lint-staged
// package.json 添加
{
  "lint-staged": {
    "*.{vue,ts,tsx,js,jsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{css,scss,vue}": [
      "stylelint --fix",
      "prettier --write"
    ]
  },
  "commitlint": {
    "extends": ["@commitlint/config-conventional"],
    "rules": {
      "type-enum": [
        2,
        "always",
        [
          "feat",     // 新功能
          "fix",      // Bug修复
          "docs",     // 文档更新
          "style",    // 代码格式
          "refactor", // 重构
          "perf",     // 性能优化
          "test",     // 测试
          "chore",    // 构建/工具
          "revert"    // 回滚
        ]
      ]
    }
  }
}

Docker + Nginx配置

# docker/Dockerfile
FROM node:18-alpine as builder

WORKDIR /app

COPY package.json ./
RUN npm install --registry=https://registry.npmmirror.com

COPY . .
RUN npm run build

# 生产镜像
FROM nginx:alpine

# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html

# 复制nginx配置
COPY docker/nginx.conf /etc/nginx/nginx.conf

# 复制入口脚本
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

EXPOSE 80

ENTRYPOINT ["/entrypoint.sh"]
# docker/nginx.conf
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

    access_log /var/log/nginx/access.log main;

    sendfile on;
    tcp_nopush on;
    keepalive_timeout 65;
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
    gzip_min_length 1000;

    server {
        listen 80;
        server_name localhost;
        root /usr/share/nginx/html;
        index index.html;

        # SPA history模式支持
        location / {
            try_files $uri $uri/ /index.html;
        }

        # 静态资源缓存
        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
            expires 30d;
            add_header Cache-Control "public, immutable";
        }

        # API代理
        location /api/ {
            proxy_pass http://backend:8080/;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }

        # 健康检查
        location /health {
            access_log off;
            return 200 "OK";
        }
    }
}

GitHub Actions CI/CD

# .github/workflows/ci-cd.yml
name: CI/CD

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

env:
  NODE_VERSION: '18'

jobs:
  lint:
    name: Lint & Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run ESLint
        run: npm run lint
      
      - name: Run Type Check
        run: npm run type-check
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: lint
    strategy:
      matrix:
        environment: [staging, production]
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Build
        run: npm run build:${{ matrix.environment }}
        env:
          VITE_API_BASE_URL: ${{ vars.API_BASE_URL }}
      
      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: dist-${{ matrix.environment }}
          path: dist/

  deploy:
    name: Deploy to ${{ matrix.environment }}
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'
    environment:
      name: ${{ matrix.environment }}
      url: ${{ matrix.environment == 'production' && vars.PRODUCTION_URL || vars.STAGING_URL }}
    strategy:
      matrix:
        include:
          - environment: staging
            server: ${{ secrets.STAGING_SERVER }}
          - environment: production
            server: ${{ secrets.PRODUCTION_SERVER }}
    steps:
      - name: Download artifacts
        uses: actions/download-artifact@v4
        with:
          name: dist-${{ matrix.environment }}
          path: dist/
      
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ matrix.server.host }}
          username: ${{ matrix.server.user }}
          key: ${{ matrix.server.key }}
          script: |
            cd /var/www/my-vue3-admin
            docker-compose pull
            docker-compose up -d
            docker system prune -f

一键启动脚本

#!/bin/bash
# scripts/init.sh

# 安装依赖
echo " Installing dependencies..."
npm install

# 启动开发服务器
echo " Starting dev server..."
npm run dev

# 或者构建生产版本
# npm run build:staging
# npm run build:production

用MonkeyCode生成前端工程化配置的优势:一次性拿到完整的配置模板,不需要东拼西凑。但环境变量和部署脚本需要根据实际情况修改。

文章摘自:https://www.cnblogs.com/jaryn/p/20218949