25. 前端性能优化
Web/vite.config.ts 已经把能开的优化全开了:terser 压缩 + 去掉 console、gzip/brotli 双压缩、按包名分包、CDN 开关、rollup-plugin-visualizer 产物分析。你要做的是按业务特征做取舍:哪些包走 CDN、哪些页面要虚拟滚动、哪些接口要合并缓存。
1. 构建与加载链路图
源码 src/**
│
├─ import.meta.glob('../views/**/*.{vue,tsx}') ← 路由懒加载(router/backEnd.ts)
│ └─ 每个页面单独 chunk,首屏只加载当前页
│
├─ rollup manualChunks(id) ← 按包名分包
│ ├─ vue / @vue / @vueuse / vue-demi / element-plus → 'vue-vendor'
│ ├─ monaco-editor / vue-element-plus-x / @flyfish-group/file-viewer3 → 不拆(单独大包)
│ └─ 其余 node_modules → 按包名 → 'vendor'
│
├─ VITE_OPEN_CDN=true ? importToCDN + external ← vue/vue-demi/vue-router/element-plus 走 unpkg
│
├─ terser(drop_console / drop_debugger / comments:false)
│
├─ vite-plugin-compression2(> 5KB 才压,gzip + brotliCompress,保留原文件)
│
└─ visualizer({ open: false }) → Web/stats.html ← 包体积分析入口关键配置一览(Web/vite.config.ts):
| 配置 | 值 | 作用 |
|---|---|---|
build.minify | terser | 比 esbuild 压缩率更高 |
terserOptions.compress | drop_console: true、drop_debugger: true | 移除调试语句 |
build.assetsInlineLimit | 4096 | < 4KB 资源转 base64,减少请求数 |
build.chunkSizeWarningLimit | 2000(KB) | 超过就告警 |
build.sourcemap | false | 生产不产 sourcemap |
build.modulePreload.polyfill | false | 不做 modulePreload polyfill |
build.reportCompressedSize | true | 构建日志显示 gzip 后大小 |
css.preprocessorOptions.scss.silenceDeprecations | legacy-js-api 等 4 项 | 屏蔽 Sass 弃用告警 |
optimizeDeps.include | vue、vue-router、pinia、element-plus、@vueuse/core | 预构建,减少首屏瀑布 |
server.proxy | ^/api、^/[Uu]pload、^/[Ss]se | dev 代理 |
2. 首屏加载
2.1 路由懒加载(框架已默认开启)
router/backEnd.ts 用 import.meta.glob 收集全部页面,后端返回的菜单 Component 经 dynamicImport 映射成 () => import(...),天然就是懒加载:
// src/router/backEnd.ts
const layouModules: any = import.meta.glob('../layout/routerView/*.{vue,tsx}');
const viewsModules: any = import.meta.glob('../views/**/*.{vue,tsx}');
const dynamicViewsModules: Record<string, Function> = Object.assign({}, { ...layouModules }, { ...viewsModules });⚠️ 别在 src/main.ts 或 layout 里静态 import 大页面组件,那会把整页打进主包。
2.2 CDN 外链(开关:VITE_OPEN_CDN)
# .env
VITE_OPEN_CDN = false # 改成 true 开启开启后走 src/utils/build.ts 的 buildConfig.cdn()(vite-plugin-cdn-import,prodUrl 为 unpkg),并把 buildConfig.external 里的包从打包中剔除:
// src/utils/build.ts
export const buildConfig = {
cdn() {
return importToCDN({
prodUrl: 'https://unpkg.com/{name}@{version}/{path}',
modules: [
{ name: 'vue', var: 'Vue', path: 'dist/vue.global.js' },
{ name: 'vue-demi', var: 'VueDemi', path: 'lib/index.iife.js' },
{ name: 'vue-router', var: 'VueRouter', path: 'dist/vue-router.global.js' },
{ name: 'element-plus', var: 'ElementPlus', path: 'dist/index.full.js' },
],
});
},
external: ['vue', 'vue-router', 'element-plus', /* 'echarts' 可按需打开 */],
};内网/离线部署不要开 CDN(外链拿不到就会白屏);要开就把 prodUrl 换成自建静态源。
2.3 gzip / brotli
// vite.config.ts
compression({
deleteOriginalAssets: false, // 保留原文件,服务器可按需选择
threshold: 5120, // > 5KB 才压缩
skipIfLargerOrEqual: true, // 压完更大就跳过
algorithms: ['gzip', 'brotliCompress'],
});产物是 *.gz + *.br,需要服务器配合:
# nginx/conf/nginx.conf
gzip on;
gzip_min_length 5k;
gzip_types text/css application/javascript application/json image/svg+xml;
# 优先用 br(需 ngx_brotli 模块)
brotli on;
brotli_min_length 5k;
brotli_types text/css application/javascript application/json image/svg+xml;
# 静态资源强缓存(文件名带 hash)
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}2.4 首屏瘦身清单
□ 锁屏/登录页不引入 echarts、monaco、wangeditor 等重型库(按需 import)
□ 首页(/home/index)的图表用 v-if 延迟到可见区再渲染
□ element-plus 组件按需引入,不要 app.use(ElementPlus) 后又在页面全量 import
□ 字体图标只 @import 用到的那套(font-awesome / remix-icon / iconfont 三套全开体积很大)
□ 确认 dist/assets 里没有意外的巨大文件(看 stats.html)
□ nginx 开 gzip/br + 强缓存3. 包体积分析
3.1 用 visualizer(每次构建都会产出)
vite.config.ts 里 visualizer({ open: false }),每次 pnpm build 都会生成 Web/stats.html,直接用浏览器打开即可看到矩形树图:
cd Web
pnpm build
# 产物:dist/ 与 stats.html
start stats.html # Windows
# open stats.html # macOS看三件事:①最大的那个块是谁;②有没有被重复打进多个 chunk 的库;③业务代码里有没有把大库打进了主包。
本仓库没有单独的
analyzemode(package.json 里只有build脚本:node --max-old-space-size=8192 ./node_modules/vite/bin/vite build),分析产物固定是stats.html。
3.2 用构建日志
reportCompressedSize: true,构建时终端会打印每个 chunk 的 gzip 后大小,配合 chunkSizeWarningLimit: 2000(KB)定位超标块。
3.3 分包规则(manualChunks)
manualChunks(id) {
if (!id.includes('node_modules')) return;
const normalizedId = id.replace(/\\/g, '/');
// 这三个体积巨大且多为按需加载,故意不合并,让它们保持独立 chunk
if (normalizedId.includes('monaco-editor')
|| normalizedId.includes('vue-element-plus-x')
|| normalizedId.includes('@flyfish-group/file-viewer3')) return;
if (['/node_modules/vue/', '/node_modules/@vue/', '/node_modules/@vueuse/',
'/node_modules/vue-demi/', '/node_modules/vue-json-pretty/',
'/node_modules/element-plus/', '/node_modules/@element-plus/']
.some(p => normalizedId.includes(p))) return 'vue-vendor';
return normalizedId.match(/\/node_modules\/(?:\.pnpm\/[^/]+\/node_modules\/)?((?:@[^/]+\/)?[^/]+)/)?.[1]
.replace('/', '-') ?? 'vendor';
}要加自己的分组就往里加判断,例如把 echarts 单独拆出来:
if (normalizedId.includes('/node_modules/echarts/')) return 'echarts';3.4 体积目标与监控
| 指标 | 目标 | 检查方式 |
|---|---|---|
| 首屏 JS(gzip 后) | ≤ 800 KB | 构建日志 + stats.html |
| 单个 chunk(gzip 后) | ≤ 500 KB | 超过 chunkSizeWarningLimit 会告警 |
dist 总体积(未压缩) | ≤ 15 MB | du -sh dist |
| 首屏请求数 | ≤ 25 | Network 面板 |
| LCP | ≤ 2.5 s | Lighthouse |
加一条 CI 检查脚本:
#!/usr/bin/env bash
# check-size.sh —— 构建后卡体积
cd "$(dirname "$0")/.." || exit 1
pnpm build || exit 1
# 找最大的 5 个 js
echo "== 最大的 5 个 JS(未压缩) =="
find dist/assets -name '*.js' -printf '%s %p\n' | sort -rn | head -5 | awk '{printf "%.1f KB %s\n", $1/1024, $2}'
# 总体积
echo "== dist 总体积 =="
du -sh dist
# 超阈值就失败
LIMIT_KB=2048
BIGGEST=$(find dist/assets -name '*.js' -printf '%s\n' | sort -rn | head -1)
if [ "$BIGGEST" -gt $((LIMIT_KB * 1024)) ]; then
echo "❌ 最大 chunk 超过 ${LIMIT_KB}KB"; exit 1
fi
echo "✅ 体积检查通过"4. vxe-grid 大数据量
4.1 虚拟滚动(已全局开启)
// src/hooks/setupVXETableHook.ts
virtualXConfig: {
enabled: true, // 横向虚拟滚动
gt: 30, // 列数 > 30 才触发
},
virtualYConfig: {
enabled: true, // 纵向虚拟滚动
gt: 50, // 行数 > 50 才触发
scrollToTopOnChange: true,
},
resizeConfig: { refreshDelay: 300 },默认阈值已经够用。要在单页覆盖:
const options = useVxeTable<BizOrder>({ id: 'bizOrder', name: '业务订单', columns }, {
// 数据量大时提前开启虚拟滚动
virtualYConfig: { enabled: true, gt: 20 },
// 固定行高,虚拟滚动才能准确计算
rowConfig: { height: 48 },
// 关闭不必要的特性
tooltipConfig: { showAll: false },
});4.2 分页尺寸
useVxeTable 的 pagerConfig.pageSize 默认是 50(useVxeTableOptionsHook.ts)。用户改过之后会存进本地偏好(useGridPageParam),key 形如 localPageParam:{id}。
const { key: localPageParamKey, param: localPageParam } = useGridPageParam('bizOrder', {
pageSize: 50,
defaultSort: { field: 'orderTime', order: 'desc' },
});
const options = useVxeTable<BizOrder>({ id: 'bizOrder', name: '业务订单', columns }, {
proxyConfig: { autoLoad: true, ajax: { query: ({ page, sort }) => handleQueryApi(page, sort) } },
pagerConfig: { pageSize: localPageParam.pageSize },
});| 数据量 | 建议 |
|---|---|
| < 1000 | 分页即可,pageSize 50 |
| 1000 – 10000 | 分页 + 覆盖索引;导出走后端接口 |
| 10000 – 100000 | 分页 + 严格限制筛选条件;禁止全量导出;必要时列裁剪 |
| > 100000 | 服务端游标分页,前端不做全量渲染 |
4.3 列与渲染优化
□ 列数控制在 15 列以内,超过用列设置(customConfig.storage)让用户隐藏
□ 单元格渲染器别用复杂组件,用 formatter 或小函数组件
□ 关掉 tooltipConfig.showAll(默认已是 false);只在需要的列开
□ footerMethod 只在 showFooter=true 时才写,别无条件算
□ 编辑表格用 editConfig.trigger='click',避免整表进入编辑态
□ 树形表格大数据用 lazy load,别一次性展开全部
□ 导出:数据量 > 1 万行走后端导出(如 SysLogOpService.ExportLogOp),不要用前端 exportConfig5. 图片与图标优化
- 图标:三套字体图标(
font-awesome、remix-icon、iconfont)都在src/theme/index.scss里@import。只保留项目在用的那套,注释掉其余可以省下几百 KB 字体文件。 - 小图自动内联:
assetsInlineLimit: 4096,小于 4KB 的图会转 base64,不需要额外处理。 - 大图/照片:放 OSS(后端
Upload.json的OSSProvider),不要进src/assets。 - 列表里的缩略图:后端给缩略图 URL,不要在前端拉原图再缩放。
- 懒加载:
el-image的lazy属性,或loading="lazy"。 - SVG:优先 SVG(可内联、可压缩),避免用 PNG 存图标。
<!-- 列表头像/缩略图:懒加载 + 固定尺寸,避免布局抖动 -->
<el-image
:src="row.thumbUrl"
:preview-src-list="[row.url]"
lazy
fit="cover"
style="width: 48px; height: 48px"
/>6. 接口合并与缓存
- 取消重复请求:
utils/request.ts有cancelRequest(url)与cancelAllRequest()(基于AbortController),useBaseApi的每个方法都带cancel参数。切换 Tab / 路由离开时取消未完成请求。
import { cancelRequest } from '/@/utils/request';
// 离开页面时取消,避免旧请求覆盖新数据
onBeforeUnmount(() => cancelRequest('/api/sysUser/page'));- 字典/常量一次性加载:
initBackEndControlRoutes里setConstList()/setDictList()已经帮你把常量和字典拉到userInfostore,页面里不要每个下拉都单独请求字典,直接读 store。 - 避免 N 次单条请求:批量操作要后端提供批量接口(如
sysConfig/batchUpdate、sysRole/grantMenu),别在前端for循环调单条接口。 - 防抖搜索:输入联想/查询按钮加防抖(
v-reclick指令可防重复点击)。 - SSE / SignalR:实时数据走推送,别轮询。
// 用 v-reclick 防重复提交(directive/customDirective.ts)
<el-button v-reclick @click="onSubmit">提交</el-button>7. 长列表与 KeepAlive
- KeepAlive:
layout/routerView/parent.vue用<keep-alive :include="getKeepAliveNames">,名单来自stores/keepAliveNames的cachedViews(开了 tagsView 时以标签页为准)。菜单的IsKeepAlive控制。 router/index.ts的规则:只有顶级路由和子路由的meta.isKeepAlive同时为真才缓存——顶级关了,下面全不缓存。- 缓存太多会卡:每个缓存页面都保留完整 DOM 与组件实例。建议只给 3–5 个高频页面开缓存,其余关掉。
- 表单页不要缓存:缓存后表单数据残留,用户以为提交成功其实看到的是旧数据;或在
onActivated里重置。 - 表格页缓存注意:
virtualYConfig.scrollToTopOnChange: true,回到页面会回到顶部;proxyConfig.autoLoad决定是否重新拉数据。
<script setup lang="ts">
import { onActivated, onMounted } from 'vue';
// 缓存页面回到前台时按需刷新,而不是每次都全量重载
onActivated(() => {
if (needRefresh.value) handleQuery();
});
</script>8. 步骤清单:给一个慢页面做优化
pnpm build后打开Web/stats.html,确认该页面的 chunk 里有没有意外混入的大库。- 用 Lighthouse / Performance 面板录一次,看是加载慢还是渲染慢。
- 加载慢:确认页面是懒加载的(不在主包里);大库改动态
import();考虑开VITE_OPEN_CDN。 - 渲染慢:确认是否一次渲染上千行 → 开
virtualYConfig/ 调小pageSize/ 减少列数。 - 接口慢:Network 面板看请求条数与耗时;合并请求、加防抖、取消无用请求;后端侧按《性能调优与压测》排查 SQL。
- 确认 nginx 开了 gzip/br 与强缓存(
assets/下的文件带 hash,可immutable)。 - 再跑一次构建,对比
stats.html与首屏指标。
⚠️ 注意事项 / 常见坑
- 别在
main.ts里静态 import 大页面或重型库:会破坏import.meta.glob的懒加载,整页打进主包。 - CDN 开关是全局的:
VITE_OPEN_CDN=true会把vue/vue-router/element-plus全部外链,内网环境直接白屏。 visualizer每次构建都跑:会拖慢构建。只想快速出包时临时注释掉它。assetsInlineLimit: 4096会把小图转成 base64:图变多反而让 JS/CSS 变大,图标类资源才适合。- 虚拟滚动需要稳定的行高:
rowConfig.height不固定会导致滚动条跳动、白行。 pageSize默认 50 且存在本地:用户改过之后你的默认值不生效,排查时先清localPageParam:{id}。- 导出走前端
exportConfig是remote: false:数据量大时 ExcelJS 会在主线程卡死,超过 1 万行改后端导出。 - KeepAlive 缓存过多反而更卡:缓存的是完整实例,不是快照。
- 三套图标字体全开很占体积:
@import在src/theme/index.scss,注释掉不用的。 drop_console: true:生产包里没有 console,别指望线上打日志定位;需要时用customCatch或后端日志。- 构建 OOM 先加内存:
build脚本已设--max-old-space-size=8192,机器内存不足时调小或分包。
小结
优化三板斧:加载——靠 import.meta.glob 懒加载 + manualChunks 分包 + CDN/gzip/br;渲染——vxe-grid 用 virtualYConfig(gt 50)与合理 pageSize、控制列数与 tooltip;请求——字典常量进 store 复用、批量接口代替循环、cancelRequest 取消冗余请求。每次改动都用 stats.html 和 Lighthouse 做前后对比,别凭感觉。