1609 字
8 分钟
前端 Bundle 优化:从 5MB 到 2MB 的工程瘦身
本文价值:这篇文章保留的是前端工程结果:不是泛泛谈优化,而是从包体结构拆出问题并落到可验证的瘦身。
背景
项目是一个 Vue3 + Vite + Element Plus 的后台管理系统,打包后发现 bundle 体积高达 5.2MB,其中:
| 模块 | 体积 | 占比 |
|---|---|---|
| 图标 (ep-icons) | 2.9MB | 56% |
| 编辑器 (wangEditor) | 810KB | 16% |
| 阿里云 OSS SDK | 691KB | 13% |
| Vue 全家桶 | 213KB | 4% |
| 其他 | 587KB | 11% |
图标占了一半以上!这显然不合理。
问题分析
1. Element Plus 图标全量导入
很多项目图省事,直接全局注册所有图标:
// ❌ 反模式:全量导入import * as ElementPlusIconsVue from '@element-plus/icons-vue'
for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component)}这会把 300+ 个图标 全部打进 bundle,而实际使用的可能不到 20 个。
2. 云存储 SDK 强依赖
项目支持腾讯云 COS 和阿里云 OSS 两种存储,但 99% 的用户只用 COS,OSS 的 691KB 成了”死重”。
3. SSE 渲染无节流
AI 对话使用 SSE 流式输出,每个 token 都触发一次 DOM 更新,高频渲染导致页面卡顿。
优化方案
Phase 1: 图标按需加载
方案选型
| 方案 | 优点 | 缺点 |
|---|---|---|
| 手动按需导入 | 体积最小 | 维护成本高,易遗漏 |
| unplugin-icons | 自动按需 | 配置复杂 |
| Iconify + CDN | 零配置,图标库丰富 | 首次加载需网络 |
选择 Iconify,原因:
- 支持 100+ 图标库(Material Design、FontAwesome、Element Plus…)
- 按需从 CDN 获取,本地零依赖
- 有缓存机制,相同图标只请求一次
实现
安装依赖:
npm install @iconify/vue创建统一图标组件:
<template> <Icon v-if="iconName" :icon="iconName" :width="size" :height="size" /></template>
<script setup lang="ts">import { computed } from 'vue'import { Icon } from '@iconify/vue'
const props = defineProps<{ icon?: string size?: number | string}>()
// 图标名称标准化:支持多种格式const iconName = computed(() => { const icon = props.icon if (!icon) return ''
// 已经是 iconify 格式:mdi:home if (icon.includes(':')) return icon
// Element Plus 图标:ep:user → ep:user if (icon.startsWith('ep:')) return icon
// 兼容旧格式:User → ep:user // PascalCase 转 kebab-case const kebab = icon.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() return `ep:${kebab}`})</script>菜单图标迁移:
后端返回的菜单 icon 字段从 User 改为 ep:user 格式,前端统一使用 <DynamicIcon :icon="menu.icon" />。
静态图标按需导入:
对于登录页等固定场景,直接导入具体图标:
<script setup>import { User, Lock, Message } from '@element-plus/icons-vue'</script>
<template> <el-icon><User /></el-icon></template>效果
| 指标 | 优化前 | 优化后 | 改进 |
|---|---|---|---|
| 图标体积 | 2957KB | 171KB | -94% |
| 首屏加载 | 包含全部图标 | 仅加载使用的 | 按需 |
Phase 2: 可选依赖策略
问题
阿里云 OSS SDK 体积 691KB,但大部分用户不用它。能否”用时再装”?
方案:运行时可选依赖
核心思路:
- 从
package.json移除ali-oss - 代码中动态 import,catch 住模块不存在的错误
- 给出友好提示,引导用户安装
实现:
const loadOSS = async () => { try { // @vite-ignore 绕过 Vite 静态分析 const pkg = 'ali-oss' const m = await import(/* @vite-ignore */ pkg) return m.default } catch { throw new Error( '阿里云 OSS 依赖未安装。\n' + '请执行:npm install ali-oss\n' + '或切换上传驱动为腾讯云 COS' ) }}Vite 配置:
export default defineConfig({ build: { rollupOptions: { // 标记为外部依赖,构建时不打包 external: ['ali-oss'], } }})效果
- Bundle 减少 691KB
- 依赖包减少 76 个(ali-oss 的依赖树)
- 用户配置 OSS 但未安装时,得到明确的错误提示
Phase 3: SSE 渲染节流
问题复现
AI 流式对话中,模型每输出一个 token 就触发一次 onmessage,高频更新导致:
- 大量 DOM 操作,页面卡顿
- 滚动跳跃,用户体验差
解决方案:双重节流
1. 内容缓冲 + 定时刷新
const FLUSH_INTERVAL = 50 // 50ms flush 一次,约 20fps
let deltaBuffer = ''let flushTimer: ReturnType<typeof setTimeout> | null = null
const flushBuffer = () => { if (!deltaBuffer) return
streamingContent.value += deltaBuffer const lastMsg = messages.value[messages.value.length - 1] if (lastMsg) lastMsg.content = streamingContent.value
deltaBuffer = '' throttledScroll()}
const onContent = (delta: string) => { // 不直接更新 UI,先存入缓冲区 deltaBuffer += delta
if (!flushTimer) { flushTimer = setTimeout(() => { flushBuffer() flushTimer = null }, FLUSH_INTERVAL) }}2. 滚动节流
const SCROLL_THROTTLE = 100 // 100ms
let lastScrollTime = 0
const throttledScroll = () => { const now = Date.now() if (now - lastScrollTime >= SCROLL_THROTTLE) { lastScrollTime = now requestAnimationFrame(() => scrollToBottom()) }}效果
| 指标 | 优化前 | 优化后 |
|---|---|---|
| DOM 更新频率 | ~100次/秒 | ~20次/秒 |
| 滚动频率 | ~100次/秒 | ~10次/秒 |
| 页面流畅度 | 明显卡顿 | 丝滑 |
Phase 4: 防串话 Bug 修复
问题场景
- 用户在会话 A 发起 AI 对话
- 流正在进行中,用户切换到会话 B
- 流结束时,内容被写入了会话 B
根因分析
onDone: (data) => { flushBuffer() // ❌ 先 flush
// 再检查会话是否切换 if (currentConversationId.value !== requestConversationId) { return // 为时已晚,buffer 已经写入了 }}修复
onDone: (data) => { // ✅ 先检查,切换了就丢弃 buffer if (currentConversationId.value !== requestConversationId) { deltaBuffer = '' clearTimers() return }
flushBuffer() // 确认是当前会话才 flush}Phase 5: Prometheus 监控埋点
为什么需要
优化效果需要数据验证,而不是”感觉快了”。
后端埋点
class Metrics implements MiddlewareInterface{ private static array $metrics = [ 'http_requests_total' => 0, 'http_request_duration_seconds' => [], 'http_request_errors_total' => 0, ];
public function process(Request $request, callable $next): Response { $start = microtime(true);
try { $response = $next($request); self::$metrics['http_requests_total']++;
$duration = microtime(true) - $start; self::recordDuration($request->path(), $duration);
if ($response->getStatusCode() >= 400) { self::$metrics['http_request_errors_total']++; }
return $response; } catch (\Throwable $e) { self::$metrics['http_request_errors_total']++; throw $e; } }
public static function export(): string { // 输出 Prometheus 格式 $output = "# HELP http_requests_total Total HTTP requests\n"; $output .= "# TYPE http_requests_total counter\n"; $output .= "http_requests_total " . self::$metrics['http_requests_total'] . "\n"; // ... return $output; }}暴露 /metrics 端点:
Route::get('/metrics', function () { return response(Metrics::export(), 200, [ 'Content-Type' => 'text/plain; charset=utf-8' ]);});监控数据示例
# HELP http_requests_total Total HTTP requests# TYPE http_requests_total counterhttp_requests_total 69
# HELP http_request_duration_seconds HTTP request duration# TYPE http_request_duration_seconds summaryhttp_request_duration_seconds{quantile="0.5"} 0.045http_request_duration_seconds{quantile="0.95"} 0.156http_request_duration_seconds_sum 6.97http_request_duration_seconds_count 69
# HELP http_request_errors_total Total HTTP errors# TYPE http_request_errors_total counterhttp_request_errors_total 0总结
| 优化项 | 改进效果 |
|---|---|
| 图标按需加载 | -94% (2.9MB → 171KB) |
| OSS 可选依赖 | -691KB,-76 个依赖包 |
| SSE 渲染节流 | DOM 更新降至 20fps |
| 串话 Bug | 修复会话切换数据污染 |
| 监控埋点 | 可量化的性能基线 |
最终效果:
- Bundle 总体积:5.2MB → ~2MB(-60%)
- 首屏关键 JS:控制在 300KB 以内
- API P95 延迟:< 200ms
经验总结
- 先测量,再优化:用
rollup-plugin-visualizer找到真正的”大户” - 按需加载是王道:图标、编辑器、SDK 都应该懒加载
- 可选依赖优于强依赖:不是所有用户都需要所有功能
- 节流不是偷懒:高频场景必须控制更新频率
- 监控让优化可持续:没有数据的优化就是盲人摸象