工程化实践
一个 SaaS 仪表盘项目从原型到上线,往往不是死在功能实现上,而是死在构建慢、类型报错无人管、代码风格混乱、环境变量泄漏、缺少测试这些工程化短板上。本章将以一个真实的 SaaS 数据看板项目为蓝本,系统讲解 Vue 3 生产级项目的全链路工程化方案。
📋 开篇自测
在正式学习之前,请先回答以下三个问题,检验自己对工程化主题的认知基线:
- 当你执行
npm run dev时,Vite 为什么能在毫秒级启动开发服务器,而传统打包工具需要数十秒? - 在
.vue单文件组件的<script setup lang="ts">中,defineProps的泛型参数和运行时声明有什么区别? - 你的团队是否遇到过”本地跑得好好的,上了预发布环境就白屏”的问题?你认为根因是什么?
如果以上问题你能清晰作答,可以跳读感兴趣的小节;否则,请跟随本章逐一攻破。
一、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 开发服务器的工作流程:
- 启动 HTTP 服务器(几乎零耗时)
- 浏览器请求
main.ts-> Vite 即时编译该文件并返回 main.ts中import { createApp } from 'vue'-> 浏览器请求vue模块 -> Vite 从预构建缓存中返回import App from './App.vue'-> Vite 编译.vue单文件组件,拆分为 render 函数和样式,返回 JS 模块- 只有浏览器实际请求到的模块才会被编译——未访问的路由页面完全不参与
这就是 Vite 能在毫秒级启动的原因。
1.3 依赖预构建
你可能会疑惑:vue、echarts 这些第三方库有成百上千个内部模块,如果每个都走一次 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.json的exports字段和条件导出。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.8MB | index-[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-compression2(vite-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 个图标 -> 按需导入
🤔 思考题
-
Vite 的 HMR(热模块替换)为什么比传统工具更快? 提示:思考模块图的粒度和更新范围。
-
在
<script setup>中使用defineProps的纯类型声明(泛型语法)时,Vue 编译器如何在运行时进行 Props 校验? 提示:查阅 Vue 编译器的 SFC 编译输出。 -
如果你的项目需要同时支持 SSR(服务端渲染)和 CSR(客户端渲染),环境变量的注入方式需要做哪些调整? 提示:
import.meta.env在 Node.js 端和浏览器端的行为差异。 -
codeSplitting分包配置不当可能导致什么问题? 提示:思考循环依赖和公共模块重复打包的场景。 -
Vitest 的
--reporter=verbose和--coverage在 CI 流水线中分别扮演什么角色?如何将测试报告集成到 Pull Request 的自动审查中?
📝 结尾自测
学完本章后,请尝试回答以下五个问题,检验学习效果:
-
Vite 开发服务器利用了浏览器的什么原生能力来实现按需编译?依赖预构建在其中承担了什么角色?
-
在
tsconfig.json中,moduleResolution: "bundler"相比"node"有什么优势?为什么 Vite 项目推荐使用它? -
ESLint Flat Config(扁平配置)相比传统的
.eslintrc有何结构性变化?eslint-config-prettier在配置数组中为什么必须放在最后? -
Vite 为什么规定只有
VITE_前缀的环境变量才会注入到客户端代码?如果去掉这个限制会有什么安全隐患? -
给定一个打包分析报告,你发现
echarts占了总体积的 60%。请列举至少三种可行的优化方案。
本章小结:工程化不是项目完成后的”锦上添花”,而是项目启动时就应该铺设的”基础设施”。从 Vite 的极速构建,到 TypeScript 的类型守卫,到 ESLint + husky 的提交门禁,到环境变量的安全隔离,到 Vitest 的自动化测试,再到构建产物的极致压缩——这六道防线共同构成了生产级 Vue 应用的质量保障体系。在下一章中,我们将在这套工程化基座之上,构建真实的业务功能。
购买课程解锁全部内容
渐进式到全面掌控:12 章系统精通 Vue 3
¥29.90