跳到主要内容
预计阅读 53 分钟

工程化实践

一个 SaaS 仪表盘项目从原型到上线,往往不是死在功能实现上,而是死在构建慢、类型报错无人管、代码风格混乱、环境变量泄漏、缺少测试这些工程化短板上。本章将以一个真实的 SaaS 数据看板项目为蓝本,系统讲解 Vue 3 生产级项目的全链路工程化方案。


📋 开篇自测

在正式学习之前,请先回答以下三个问题,检验自己对工程化主题的认知基线:

  1. 当你执行 npm run dev 时,Vite 为什么能在毫秒级启动开发服务器,而传统打包工具需要数十秒?
  2. .vue 单文件组件的 <script setup lang="ts"> 中,defineProps 的泛型参数和运行时声明有什么区别?
  3. 你的团队是否遇到过”本地跑得好好的,上了预发布环境就白屏”的问题?你认为根因是什么?

如果以上问题你能清晰作答,可以跳读感兴趣的小节;否则,请跟随本章逐一攻破。


一、Vite 构建体系

1.1 生产问题:为什么冷启动要等 40 秒?

设想这样一个场景:你的 SaaS 仪表盘项目已经有 200 多个组件、30 多个路由页面。每次执行 npm run dev,终端转了将近 40 秒才输出 ready。你修改一行 CSS,浏览器又要等 3 秒才热更新。团队成员开始抱怨——“工具比需求更拖进度”。

问题的根源在于传统打包工具的工作模式:先打包,再启动。它需要把所有模块递归解析、编译、合并成一个或几个 bundle,然后才启动开发服务器。项目越大,冷启动越慢。

Vite 反转了这个流程:先启动,按需编译

1.2 ESM 原生模块:Vite 的核心引擎

Vite 的核心思想建立在浏览器原生 ES Modules(ESM)之上。现代浏览器已经内置了对 import/export 的支持,可以直接通过 HTTP 请求加载模块——不需要提前打包。

当你在项目入口写下:

<script type="module" src="/src/main.ts"></script>

浏览器遇到 import 语句时,会自动发起 HTTP 请求去获取对应模块。Vite 的开发服务器拦截这些请求,按需对单个模块进行即时编译并返回。

打个比方:传统打包工具像是一本厚厚的百科全书——你想查一个词条,需要等整本书印刷完毕才能翻阅。而 Vite 是一个在线知识库——你点击哪个词条,后台才实时排版那一页返回给你。

Vite 开发服务器的工作流程

  1. 启动 HTTP 服务器(几乎零耗时)
  2. 浏览器请求 main.ts -> Vite 即时编译该文件并返回
  3. main.tsimport { createApp } from 'vue' -> 浏览器请求 vue 模块 -> Vite 从预构建缓存中返回
  4. import App from './App.vue' -> Vite 编译 .vue 单文件组件,拆分为 render 函数和样式,返回 JS 模块
  5. 只有浏览器实际请求到的模块才会被编译——未访问的路由页面完全不参与

这就是 Vite 能在毫秒级启动的原因。

1.3 依赖预构建

你可能会疑惑:vueecharts 这些第三方库有成百上千个内部模块,如果每个都走一次 HTTP 请求,岂不是更慢?

Vite 的解决方案是依赖预构建(Dependency Pre-Bundling)。在首次启动时,Vite 将 node_modules 中的第三方依赖预先打包成单个 ESM 文件,缓存到 node_modules/.vite 目录下。在 Vite 7 及更早版本中,预构建使用 esbuild(Go 编写,速度极快);Vite 8 起已切换为 Rolldown(Rust 编写),统一了开发和生产环境的构建工具链。

预构建解决了两个关键问题:

  • 请求瀑布:将 lodash-es 的 600 多个内部模块合并为一个请求
  • 格式兼容:将 CommonJS 格式的包(如 moment)转换为 ESM 格式

1.4 vite.config.ts 实战配置

下面是我们 SaaS 仪表盘项目的完整 Vite 配置:

// vite.config.ts
import { defineConfig, type UserConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig(({ mode }): UserConfig => {
  return {
    plugins: [vue()],

    // 路径别名——让深层组件引用不再痛苦
    resolve: {
      alias: {
        '@': fileURLToPath(new URL('./src', import.meta.url)),
        '@widgets': fileURLToPath(new URL('./src/components/widgets', import.meta.url)),
        '@api': fileURLToPath(new URL('./src/services/api', import.meta.url)),
      },
    },

    // 开发服务器配置
    server: {
      host: '0.0.0.0',
      port: 5173,
      open: true,
      // 代理后端 API——解决开发环境跨域
      proxy: {
        '/api/v1': {
          target: 'http://localhost:8080',
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/api\/v1/, '/v1'),
        },
      },
    },

    // 构建配置
    build: {
      target: 'es2020',
      // 启用 CSS 代码分割
      cssCodeSplit: true,
      // Vite 8 默认使用 Oxc Minifier(内置,无需安装,比 terser 快 30-90 倍,压缩率仅差 0.5~2%)
      // 一般无需手动指定 minify 字段,默认即可获得最佳性能。
      // 如果需要 terser 独有的高级压缩选项,可手动切换:
      //   minify: 'terser',  // 需额外安装:npm install -D terser
      //   terserOptions: { compress: { drop_console: true } }
      // chunk 大小警告阈值
      chunkSizeWarningLimit: 500,
    },
  }
})

几个值得关注的配置细节

  • resolve.alias 使用 fileURLToPath + import.meta.url 而非 __dirname,因为 Vite 配置文件本身就是 ESM 模块,__dirname 在 ESM 中不可用。
  • server.proxy 通过 rewrite 重写路径,让前端请求 /api/v1/dashboard 自动代理到后端的 /v1/dashboard,消除跨域问题。
  • build.target 设为 es2020,既能享受现代语法的性能优化,又覆盖了绝大多数用户的浏览器版本。

二、TypeScript 集成

2.1 生产问题:接口字段改了,前端一周后才发现

后端团队将仪表盘 API 返回的 chartData 字段从数组改成了分页对象。前端没有类型约束,代码照常通过编译,直到 QA 发现页面图表渲染为空——此时已经过去了整整一周。

TypeScript 的价值不仅是”代码补全更好用”,更是在编译时捕获这类接口契约变更,防止问题流入测试甚至生产环境。

2.2 tsconfig.json 核心配置

创建 Vue 3 + TypeScript 项目后,我们需要重点关注以下配置:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "skipLibCheck": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@widgets/*": ["src/components/widgets/*"],
      "@api/*": ["src/services/api/*"]
    },
    "types": ["vite/client"]
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx",
    "src/**/*.vue",
    "env.d.ts"
  ]
}

关键点解读:

  • moduleResolution: "bundler" 是 TypeScript 5.0+ 引入的新策略,专为 Vite/esbuild 等现代打包工具设计,支持 package.jsonexports 字段和条件导出。
  • paths 必须与 vite.config.ts 中的 resolve.alias 保持一致,否则 TypeScript 能编译但 Vite 找不到文件(或反过来)。
  • types: ["vite/client"] 让 TypeScript 识别 import.meta.env.vue 文件等 Vite 特有类型。

2.3 组件中的类型标注实战

在 SaaS 仪表盘项目中,我们的图表组件需要精确的类型定义:

<!-- src/components/widgets/ChartWidget.vue -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'

// ---- Props 类型定义 ----
interface ChartDataPoint {
  timestamp: string
  value: number
  label?: string
}

interface ChartWidgetProps {
  /** 图表标题 */
  title: string
  /** 数据源 */
  dataPoints: ChartDataPoint[]
  /** 图表类型 */
  chartType?: 'line' | 'bar' | 'pie'
  /** 是否显示图例 */
  showLegend?: boolean
  /** 刷新间隔(毫秒),0 表示不自动刷新 */
  refreshInterval?: number
}

const props = withDefaults(defineProps<ChartWidgetProps>(), {
  chartType: 'line',
  showLegend: true,
  refreshInterval: 0,
})

// ---- Emit 类型定义(Vue 3.3+ 推荐的对象字面量语法,更简洁) ----
const emit = defineEmits<{
  dataLoaded: [payload: { count: number; duration: number }]
  pointClick: [payload: ChartDataPoint]
  error: [payload: Error]
}>()

// ---- 响应式状态 ----
const isLoading = ref(false)
const renderDuration = ref(0)

const formattedTitle = computed(() => {
  const typeLabel: Record<string, string> = {
    line: '折线图',
    bar: '柱状图',
    pie: '饼图',
  }
  return `${props.title} - ${typeLabel[props.chartType]}`
})

// ---- 方法 ----
function handlePointClick(point: ChartDataPoint): void {
  emit('pointClick', point)
}

onMounted(() => {
  const start = performance.now()
  // 模拟图表渲染
  isLoading.value = true
  setTimeout(() => {
    isLoading.value = false
    renderDuration.value = performance.now() - start
    emit('dataLoaded', {
      count: props.dataPoints.length,
      duration: renderDuration.value,
    })
  }, 100)
})
</script>

<template>
  <div class="chart-widget">
    <h3>{{ formattedTitle }}</h3>
    <div v-if="isLoading" class="chart-widget__loading">加载中...</div>
    <div v-else class="chart-widget__canvas" @click="handlePointClick(dataPoints[0])">
      <!-- 图表渲染区域 -->
    </div>
  </div>
</template>

类型标注的三个层次

层次做法适用场景
基础defineProps<{ title: string }>()简单组件,Props 少于 5 个
标准提取 interface,使用 withDefaults大多数业务组件
高级泛型组件 <script setup lang="ts" generic="T">通用表格、列表等可复用组件

2.4 API 层类型安全

为了让后端接口变更在第一时间被捕获,我们为 API 层建立完整的类型链:

// src/services/api/types.ts
export interface ApiResponse<T> {
  code: number
  message: string
  data: T
}

export interface DashboardSummary {
  totalUsers: number
  activeUsers: number
  revenue: number
  conversionRate: number
  updatedAt: string
}

export interface UserPreference {
  userId: string
  theme: 'light' | 'dark' | 'system'
  language: 'zh-CN' | 'en-US'
  dashboardLayout: string[]
  notificationEnabled: boolean
}
// src/services/api/dashboard.ts
import type { ApiResponse, DashboardSummary } from './types'

export async function fetchDashboardSummary(): Promise<ApiResponse<DashboardSummary>> {
  const response = await fetch('/api/v1/dashboard/summary')
  if (!response.ok) {
    throw new Error(`请求失败: ${response.status}`)
  }
  return response.json()
}

当后端将 revenue 字段重命名为 totalRevenue 时,TypeScript 会立刻在所有使用 summary.revenue 的组件中标红报错——而不是等到用户反馈”收入数字显示 undefined”。


三、代码规范

3.1 生产问题:合并代码后样式全乱了

周五下午,两位开发者同时修改了仪表盘布局组件。一位使用 2 空格缩进和单引号,另一位使用 4 空格缩进和双引号。代码合并后 Git 显示 300 多行变更,其中有意义的逻辑修改只有 15 行,其余全是格式差异。更糟的是,合并过程中一个关键的 CSS 类名被意外删除,导致生产环境布局错乱。

代码规范不是”好看”的问题,而是降低协作成本和事故概率的问题。

3.2 ESLint + Prettier 配置

从 ESLint v9 开始,推荐使用扁平配置(Flat Config)格式。结合 Vue 专用规则,配置如下:

// eslint.config.js
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import tseslint from 'typescript-eslint'
import prettierConfig from 'eslint-config-prettier'

export default [
  js.configs.recommended,
  ...tseslint.configs.recommended,
  ...pluginVue.configs['flat/recommended'],
  prettierConfig,
  {
    files: ['**/*.vue'],
    languageOptions: {
      parserOptions: {
        parser: tseslint.parser,
      },
    },
  },
  {
    rules: {
      // Vue 专用规则
      'vue/multi-word-component-names': 'error',
      'vue/define-macros-order': ['error', {
        order: ['defineOptions', 'defineProps', 'defineEmits', 'defineSlots'],
      }],
      'vue/block-order': ['error', {
        order: ['script', 'template', 'style'],
      }],
      'vue/no-unused-refs': 'error',

      // TypeScript 规则
      '@typescript-eslint/no-explicit-any': 'warn',
      '@typescript-eslint/consistent-type-imports': 'error',
    },
  },
  {
    ignores: ['dist/**', 'node_modules/**', '*.config.js'],
  },
]

Prettier 配置保持简洁:

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

3.3 husky + lint-staged:提交门禁

光有规则不够,必须在代码提交时自动执行检查,防止不合规代码进入仓库:

# 安装依赖
npm install -D husky lint-staged

# 初始化 husky
npx husky init

配置 package.json

{
  "scripts": {
    "lint": "eslint . --fix",
    "format": "prettier --write src/"
  },
  "lint-staged": {
    "*.{ts,tsx,vue}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{css,scss}": [
      "prettier --write"
    ]
  }
}

编辑 Git 钩子文件:

# .husky/pre-commit
npx lint-staged

这样一来,每次 git commit 时,只有暂存区(staged)的文件会被检查和自动修复。如果存在无法自动修复的 ESLint 错误,提交会被阻止——从源头杜绝不合规代码入库。


四、环境变量与多环境管理

4.1 生产问题:测试环境的数据写进了生产数据库

一位开发者在调试时将 API 地址硬编码为测试服务器地址,忘记改回来就提交了。代码经过 CI/CD 直接部署到生产环境,结果用户的操作数据被写入了测试数据库,而生产数据库一整天没有新数据。排查了 6 小时才定位到原因。

环境变量管理的核心原则:环境相关的配置绝不能硬编码,必须通过环境变量注入

4.2 .env 文件体系

Vite 内置了对 .env 文件的支持,按照以下优先级加载(后者覆盖前者):

.env                  # 所有环境加载
.env.local            # 所有环境加载,被 .gitignore 忽略
.env.[mode]           # 仅在指定模式加载
.env.[mode].local     # 仅在指定模式加载,被 .gitignore 忽略

为 SaaS 仪表盘项目创建三套环境配置:

# .env(公共配置)
VITE_APP_NAME=SaaS Dashboard
VITE_API_TIMEOUT=10000
# .env.development(开发环境)
VITE_API_BASE_URL=http://localhost:8080/v1
VITE_ENABLE_MOCK=true
VITE_LOG_LEVEL=debug
# .env.staging(预发布环境)
VITE_API_BASE_URL=https://staging-api.example.com/v1
VITE_ENABLE_MOCK=false
VITE_LOG_LEVEL=warn
# .env.production(生产环境)
VITE_API_BASE_URL=https://api.example.com/v1
VITE_ENABLE_MOCK=false
VITE_LOG_LEVEL=error

4.3 import.meta.env 在代码中使用

Vite 通过 import.meta.env 将环境变量暴露给客户端代码。只有以 VITE_ 为前缀的变量才会被注入——这是一道安全防线,防止服务端密钥(如数据库密码)意外泄漏到浏览器端。

// src/services/api/client.ts
const apiClient = {
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: Number(import.meta.env.VITE_API_TIMEOUT),
}

// 开发环境启用请求日志
if (import.meta.env.VITE_LOG_LEVEL === 'debug') {
  console.log('[API Client] 配置:', apiClient)
}

// 生产环境禁用 mock
if (import.meta.env.VITE_ENABLE_MOCK === 'true') {
  console.log('[Mock] 模拟数据已启用')
}

export default apiClient

为了获得 TypeScript 的类型提示,需要声明环境变量类型:

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

interface ImportMetaEnv {
  readonly VITE_APP_NAME: string
  readonly VITE_API_BASE_URL: string
  readonly VITE_API_TIMEOUT: string
  readonly VITE_ENABLE_MOCK: string
  readonly VITE_LOG_LEVEL: 'debug' | 'warn' | 'error'
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

4.4 多环境构建脚本

package.json 中配置对应的构建命令:

{
  "scripts": {
    "dev": "vite --mode development",
    "build:staging": "vite build --mode staging",
    "build:prod": "vite build --mode production",
    "preview": "vite preview"
  }
}

执行 npm run build:staging 时,Vite 会加载 .env + .env.staging 中的变量,并在构建时将 import.meta.env.VITE_API_BASE_URL 静态替换为 "https://staging-api.example.com/v1"。这种编译时替换意味着最终产物中不会残留任何环境变量读取逻辑,既安全又高效。


五、测试策略

5.1 生产问题:重构后回归测试靠人肉

仪表盘的数据聚合逻辑经历了一次大规模重构——从命令式循环改为函数式管道。重构后”看起来”一切正常,但上线两天后发现:当某个时间段没有数据时,聚合函数返回了 NaN 而非 0,导致图表显示异常。

如果有单元测试覆盖边界场景,这个 bug 在重构完成的那一秒就会被捕获。

5.2 Vitest 单元测试

Vitest 是 Vite 生态的原生测试框架,与 Vite 共享配置和插件体系,无需额外搭建测试环境:

// vitest.config.ts(推荐独立文件,也可合并到 vite.config.ts)
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'jsdom',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      include: ['src/**/*.{ts,vue}'],
      exclude: ['src/**/*.d.ts', 'src/main.ts'],
    },
  },
})

为仪表盘的数据聚合工具函数编写单元测试:

// src/utils/dataAggregator.ts
export interface MetricRecord {
  date: string
  value: number
}

export function aggregateDailyMetrics(records: MetricRecord[]): Map<string, number> {
  const result = new Map<string, number>()
  for (const record of records) {
    const current = result.get(record.date) ?? 0
    result.set(record.date, current + record.value)
  }
  return result
}

export function calculateGrowthRate(current: number, previous: number): number {
  if (previous === 0) return current > 0 ? 100 : 0
  return Number((((current - previous) / previous) * 100).toFixed(2))
}
// src/utils/__tests__/dataAggregator.test.ts
import { describe, it, expect } from 'vitest'
import { aggregateDailyMetrics, calculateGrowthRate } from '../dataAggregator'

describe('aggregateDailyMetrics', () => {
  it('应合并同一日期的多条记录', () => {
    const records = [
      { date: '2026-03-01', value: 100 },
      { date: '2026-03-01', value: 250 },
      { date: '2026-03-02', value: 80 },
    ]
    const result = aggregateDailyMetrics(records)
    expect(result.get('2026-03-01')).toBe(350)
    expect(result.get('2026-03-02')).toBe(80)
  })

  it('空数组应返回空 Map', () => {
    const result = aggregateDailyMetrics([])
    expect(result.size).toBe(0)
  })

  it('单条记录应原样保留', () => {
    const records = [{ date: '2026-03-15', value: 42 }]
    const result = aggregateDailyMetrics(records)
    expect(result.get('2026-03-15')).toBe(42)
  })
})

describe('calculateGrowthRate', () => {
  it('正常增长', () => {
    expect(calculateGrowthRate(150, 100)).toBe(50)
  })

  it('负增长', () => {
    expect(calculateGrowthRate(80, 100)).toBe(-20)
  })

  it('前值为 0 且当前有值时返回 100', () => {
    expect(calculateGrowthRate(50, 0)).toBe(100)
  })

  it('前值和当前值都为 0 时返回 0', () => {
    expect(calculateGrowthRate(0, 0)).toBe(0)
  })
})

5.3 Vue Test Utils 组件测试

组件测试验证的是”给定输入(Props、事件),组件是否产生正确的输出(渲染内容、发出的事件)“。

// src/components/widgets/__tests__/MetricCard.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import MetricCard from '../MetricCard.vue'

// 假设 MetricCard.vue 接收 title, value, trend 三个 props
describe('MetricCard', () => {
  it('渲染标题和数值', () => {
    const wrapper = mount(MetricCard, {
      props: {
        title: '日活用户',
        value: 12580,
        trend: 5.2,
      },
    })
    expect(wrapper.find('.metric-card__title').text()).toBe('日活用户')
    expect(wrapper.find('.metric-card__value').text()).toContain('12580')
  })

  it('正增长显示上升样式', () => {
    const wrapper = mount(MetricCard, {
      props: { title: '收入', value: 99000, trend: 12.5 },
    })
    expect(wrapper.find('.metric-card__trend').classes()).toContain('trend--up')
  })

  it('负增长显示下降样式', () => {
    const wrapper = mount(MetricCard, {
      props: { title: '跳出率', value: 35, trend: -3.1 },
    })
    expect(wrapper.find('.metric-card__trend').classes()).toContain('trend--down')
  })

  it('点击卡片触发 detail-click 事件', async () => {
    const wrapper = mount(MetricCard, {
      props: { title: '转化率', value: 8.7, trend: 0 },
    })
    await wrapper.trigger('click')
    expect(wrapper.emitted('detail-click')).toHaveLength(1)
  })
})

5.4 测试最佳实践

原则说明
测试行为,不测实现不要断言内部 ref 的值,而是断言渲染结果和发出的事件
优先测试纯函数工具函数、composables 的返回值是最容易测试的
边界先行空数组、零值、undefined、超长字符串——这些边界场景是 bug 的高发地
保持测试独立每个 it 块不应依赖其他测试的执行结果或顺序
合理设置覆盖率目标工具函数追求 90%+,UI 组件追求 70%+,不必追求 100%

六、构建优化

6.1 生产问题:首屏加载 8 秒,用户流失过半

数据看板上线后,运营团队反馈:移动端用户的首屏加载时间中位数为 8.2 秒,首周用户留存率不到 30%。分析发现,打包产物是一个 2.8MB 的巨型 JS 文件——所有路由页面、所有图表库、所有工具函数全部打在一起。

6.2 代码分割:路由级懒加载

最直接的优化是路由级代码分割。通过动态 import() 语法,Vite 会自动将每个路由对应的组件拆分为独立的 chunk:

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

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      name: 'DashboardOverview',
      // 路由级懒加载——首屏只加载概览页
      component: () => import('@/views/DashboardOverview.vue'),
    },
    {
      path: '/analytics',
      name: 'AnalyticsDetail',
      component: () => import('@/views/AnalyticsDetail.vue'),
    },
    {
      path: '/settings',
      name: 'UserSettings',
      component: () => import('@/views/UserSettings.vue'),
    },
    {
      path: '/reports',
      name: 'ReportExport',
      component: () => import('@/views/ReportExport.vue'),
    },
  ],
})

export default router

这样,用户访问首页时只下载 DashboardOverview 的代码。只有当用户导航到 /analytics 时,才会按需加载 AnalyticsDetail 的 chunk。

6.3 手动分包策略

对于第三方依赖,我们可以通过 build.rolldownOptions 进一步控制分包粒度(Vite 8 已将生产构建引擎切换为 Rolldown,配置项从 rollupOptions 更名为 rolldownOptions;旧名暂作兼容别名但已废弃)。Rolldown 提供了 codeSplitting 选项替代原来的 manualChunks,通过正则匹配和优先级实现更灵活的分包控制:

// vite.config.ts -> build
build: {
  rolldownOptions: {
    output: {
      codeSplitting: {
        groups: [
          // Vue 核心库单独成包——命中浏览器长期缓存
          { name: 'vendor-vue', test: /node_modules[\\/](vue|vue-router|pinia)/, priority: 20 },
          // 图表库单独成包——只有图表页面才加载
          { name: 'vendor-charts', test: /node_modules[\\/]echarts/, priority: 20 },
          // 工具库单独成包
          { name: 'vendor-utils', test: /node_modules[\\/](dayjs|lodash-es)/, priority: 20 },
        ],
      },
    },
  },
}

分包后的效果对比:

优化前优化后
index-[hash].js 2.8MBindex-[hash].js 180KB
vendor-vue-[hash].js 120KB
vendor-charts-[hash].js 850KB(按需加载)
vendor-utils-[hash].js 65KB
DashboardOverview-[hash].js 45KB
AnalyticsDetail-[hash].js 92KB

6.4 Gzip / Brotli 压缩

使用 vite-plugin-compression2vite-plugin-compression 已停止维护)在构建时生成压缩文件,配合 Nginx 直接返回预压缩产物:

// vite.config.ts
import { compression } from 'vite-plugin-compression2'

export default defineConfig({
  plugins: [
    vue(),
    // Gzip 压缩
    compression({
      algorithm: 'gzip',
      threshold: 10240, // 大于 10KB 才压缩
      ext: '.gz',
    }),
    // Brotli 压缩(压缩率比 Gzip 高 15-25%)
    compression({
      algorithm: 'brotliCompress',
      threshold: 10240,
      ext: '.br',
    }),
  ],
})

对应的 Nginx 配置:

# 优先返回预压缩的 Brotli 文件,其次 Gzip
location /assets/ {
    gzip_static on;
    brotli_static on;  # 需安装 ngx_brotli 模块,标准 Nginx 不包含此功能
    expires 1y;
    add_header Cache-Control "public, immutable";
}

6.5 打包分析

当你不确定”到底是什么东西这么大”时,可视化分析工具是最好的诊断手段:

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

export default defineConfig(({ mode }) => ({
  plugins: [
    vue(),
    // 仅在分析模式下生成报告
    mode === 'analyze' && visualizer({
      open: true,
      filename: 'dist/stats.html',
      gzipSize: true,
      brotliSize: true,
    }),
  ],
}))

package.json 中添加分析命令:

{
  "scripts": {
    "analyze": "vite build --mode analyze"
  }
}

执行 npm run analyze 后,会自动打开一个交互式的 treemap 图,每个模块的大小一目了然。常见的发现包括:

  • moment.js 的 locale 文件占了 400KB -> 换成 dayjs(2KB)
  • lodash 全量引入 -> 换成 lodash-es 并 tree-shaking
  • 某个图标库打包了 5000 个图标 -> 按需导入

🤔 思考题

  1. Vite 的 HMR(热模块替换)为什么比传统工具更快? 提示:思考模块图的粒度和更新范围。

  2. <script setup> 中使用 defineProps 的纯类型声明(泛型语法)时,Vue 编译器如何在运行时进行 Props 校验? 提示:查阅 Vue 编译器的 SFC 编译输出。

  3. 如果你的项目需要同时支持 SSR(服务端渲染)和 CSR(客户端渲染),环境变量的注入方式需要做哪些调整? 提示:import.meta.env 在 Node.js 端和浏览器端的行为差异。

  4. codeSplitting 分包配置不当可能导致什么问题? 提示:思考循环依赖和公共模块重复打包的场景。

  5. Vitest 的 --reporter=verbose--coverage 在 CI 流水线中分别扮演什么角色?如何将测试报告集成到 Pull Request 的自动审查中?


📝 结尾自测

学完本章后,请尝试回答以下五个问题,检验学习效果:

  1. Vite 开发服务器利用了浏览器的什么原生能力来实现按需编译?依赖预构建在其中承担了什么角色?

  2. tsconfig.json 中,moduleResolution: "bundler" 相比 "node" 有什么优势?为什么 Vite 项目推荐使用它?

  3. ESLint Flat Config(扁平配置)相比传统的 .eslintrc 有何结构性变化?eslint-config-prettier 在配置数组中为什么必须放在最后?

  4. Vite 为什么规定只有 VITE_ 前缀的环境变量才会注入到客户端代码?如果去掉这个限制会有什么安全隐患?

  5. 给定一个打包分析报告,你发现 echarts 占了总体积的 60%。请列举至少三种可行的优化方案。


本章小结:工程化不是项目完成后的”锦上添花”,而是项目启动时就应该铺设的”基础设施”。从 Vite 的极速构建,到 TypeScript 的类型守卫,到 ESLint + husky 的提交门禁,到环境变量的安全隔离,到 Vitest 的自动化测试,再到构建产物的极致压缩——这六道防线共同构成了生产级 Vue 应用的质量保障体系。在下一章中,我们将在这套工程化基座之上,构建真实的业务功能。

购买课程解锁全部内容

渐进式到全面掌控:12 章系统精通 Vue 3

¥29.90