1609 字
8 分钟
前端 Bundle 优化:从 5MB 到 2MB 的工程瘦身

本文价值:这篇文章保留的是前端工程结果:不是泛泛谈优化,而是从包体结构拆出问题并落到可验证的瘦身。

背景#

项目是一个 Vue3 + Vite + Element Plus 的后台管理系统,打包后发现 bundle 体积高达 5.2MB,其中:

模块体积占比
图标 (ep-icons)2.9MB56%
编辑器 (wangEditor)810KB16%
阿里云 OSS SDK691KB13%
Vue 全家桶213KB4%
其他587KB11%

图标占了一半以上!这显然不合理。

问题分析#

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 获取,本地零依赖
  • 有缓存机制,相同图标只请求一次

实现#

安装依赖

Terminal window
npm install @iconify/vue

创建统一图标组件

components/DynamicIcon/src/index.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>

效果#

指标优化前优化后改进
图标体积2957KB171KB-94%
首屏加载包含全部图标仅加载使用的按需

Phase 2: 可选依赖策略#

问题#

阿里云 OSS SDK 体积 691KB,但大部分用户不用它。能否”用时再装”?

方案:运行时可选依赖#

核心思路

  1. package.json 移除 ali-oss
  2. 代码中动态 import,catch 住模块不存在的错误
  3. 给出友好提示,引导用户安装

实现

utils/cosUpload.ts
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 配置

vite.config.ts
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 修复#

问题场景#

  1. 用户在会话 A 发起 AI 对话
  2. 流正在进行中,用户切换到会话 B
  3. 流结束时,内容被写入了会话 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 监控埋点#

为什么需要#

优化效果需要数据验证,而不是”感觉快了”。

后端埋点#

middleware/Metrics.php
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 端点

routes/admin.php
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 counter
http_requests_total 69
# HELP http_request_duration_seconds HTTP request duration
# TYPE http_request_duration_seconds summary
http_request_duration_seconds{quantile="0.5"} 0.045
http_request_duration_seconds{quantile="0.95"} 0.156
http_request_duration_seconds_sum 6.97
http_request_duration_seconds_count 69
# HELP http_request_errors_total Total HTTP errors
# TYPE http_request_errors_total counter
http_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

经验总结#

  1. 先测量,再优化:用 rollup-plugin-visualizer 找到真正的”大户”
  2. 按需加载是王道:图标、编辑器、SDK 都应该懒加载
  3. 可选依赖优于强依赖:不是所有用户都需要所有功能
  4. 节流不是偷懒:高频场景必须控制更新频率
  5. 监控让优化可持续:没有数据的优化就是盲人摸象