组里的前端项目,每次 build 都要等 8 分钟。等的人多了,大家就默认这是"正常的",打包的时候顺手去泡杯咖啡。产物打出来 80M,也没人细究——反正内网系统,能跑就行。
直到今天我实在受不了这 8 分钟,决定坐下来看看它到底慢在哪。
结论有点出乎意料:真正拖慢构建的和真正拖慢用户的,是两个完全不同的东西,而后者根本不在打包里。
下面是完整的排查过程。
先搞清楚:8 分钟到底花在哪
项目是个 Vue 3 + Element Plus 的管理后台,用 Vite 打包。第一反应是去看构建产物体积,但那是"结果",不是"原因"。慢的是过程,得先知道构建时间花在什么阶段。
盯着构建日志看了几轮,发现卡最久的是 SCSS 编译那一段。项目全量引入了 Element Plus 的 SCSS 源码:
// src/styles/element/index.scss @use 'element-plus/theme-chalk/src/index.scss' as *; // 80+ 个组件的完整样式源码
这一行会把 Element Plus 全部组件的 SCSS 拉进来编译,几千行变量、mixin、规则,每次打包都要重新算一遍。
但真正的问题不在"编译量大",而在用什么去编译。翻了下 package.json,装的是 sass——也就是纯 JavaScript 版的 Dart Sass。而 vite.config.ts 里明明写着:
css: { preprocessorOptions: { scss: { api: 'modern-compiler' // 声明了要用新版编译 API } } }
配置摆出了要用高性能编译器的姿态,装的却是慢的那个。等于开了跑车模式,但发动机还是拖拉机的。
第一刀:换编译器,啥代码都不用改
解决办法简单到有点好笑——把 sass 换成官方的原生实现 sass-embedded:
pnpm remove sass pnpm add -D sass-embedded
sass-embedded 是 Dart Sass 编译成的原生二进制,API 和 sass 完全兼容,@use / @forward / mixin 那些语法一个都不用改。它也是 api: 'modern-compiler' 能真正生效的前提。
就这一步,构建从 8 分钟降到了 3 分钟。一行业务代码没动,SCSS 没删一句,纯粹是把编译引擎换了。
顺带踩了个坑:这项目
node_modules是 pnpm 装的,我一开始手滑用了npm install,直接报了个莫名其妙的错。看 lockfile 是pnpm-lock.yaml才反应过来。多人协作的项目,包管理器别混用。
顺手的两刀:该按需的别全量引
既然打开了引擎盖,顺手收拾两个一眼就不对的地方。
一个是 echarts。 项目里大部分图表页都是按需引入的,统一走一个 @/utils/echarts,只注册了实际用到的图表类型。但有个页面是这么写的:
// 全量,把整个 echarts 都打进来 import * as echarts from 'echarts';
改成和其他页面一致的按需版本:
import echarts from '@/utils/echarts';
主包立马瘦了 700 多 KB。改之前我特意确认了这页只用到了饼图,而 @/utils/echarts 里已经注册了饼图——不然改完图表会直接白给。
另一个是字体。 一个中文字体,四种格式全打进了包里:
AlimamaShuHeiTi-Bold.otf 1005 KB
AlimamaShuHeiTi-Bold.ttf 1.4 MB
AlimamaShuHeiTi-Bold.woff 786 KB
AlimamaShuHeiTi-Bold.woff2 663 KB
现代浏览器早就全都支持 woff2 了(压缩率还最高),留其他三个纯属浪费。删掉,只保留 woff2:
@font-face { font-family: 'AlimamaShuHeiTi'; src: url('@/assets/fonts/AlimamaShuHeiTi-Bold.woff2') format('woff2'); font-display: swap; // 字体没加载完先用系统字体顶上,别让文字空白 }
一下省了 3MB 多。
转折:80M 其实是个伪命题
处理完这些,回头看那个"80M 产物"。我下意识想继续砍体积,但砍之前先问了自己一个问题:这 80M,用户真的会下载 80M 吗?
不会。80M 是打包产物在硬盘上占的空间,里面还包含一堆构建时生成的 .gz 预压缩副本。用户打开网页,浏览器请求的是单个文件,不是整个文件夹。
那用户到底下了多大?打开线上站点,F12 → Network,点开那个最大的 CSS,看响应头:
content-type: text/css
content-length: 6538172 # 6.38MB,原样发出去了
(没有 content-encoding 这一行)
问题一下子清楚了:响应头里根本没有 content-encoding: gzip。 服务器把 6.38MB 的 CSS 原封不动、未经压缩地丢给了每一个用户。
这才是真正拖慢用户的东西,而它压根不在打包环节——在 Nginx。
真正的大招:一段 Nginx 配置
翻了下 Nginx 配置,果然,从头到尾没有一条 gzip 指令。前端辛辛苦苦打包时生成的那些 .gz 副本,服务器一个都没用上,纯占硬盘。
补上压缩配置,放进 nginx.conf 的 http {} 块里(这样底下所有站点一起生效):
http { gzip on; gzip_static on; # 有打包好的 .gz 就直接用,省 CPU;没有则实时压缩(需模块支持) gzip_vary on; gzip_comp_level 6; gzip_min_length 1024; gzip_types text/plain text/css application/javascript application/json image/svg+xml font/ttf; # ... 原有 server 配置不动 ... }
gzip_static依赖一个可选模块,加之前先跑nginx -V 2>&1 | grep gzip_static确认。 没有这个模块就把那行删掉,只留gzip on,实时压缩效果一样,只是多花点 CPU。 内网系统这点 CPU 完全无所谓。
nginx -t 测一下语法,nginx -s reload 重载。再刷新页面看响应头:
content-encoding: gzip # 出现了
transfer-encoding: chunked
那个 2.67MB 的 JS,传到浏览器只剩 800 多 KB。6.38MB 的 CSS 同理,压到 700KB 左右。压缩率 70% 以上,一行前端代码没改。
一个我决定不做的优化
排查时其实找到了体积的头号元凶:那个全量引入的 Element Plus SCSS,编译出来的 CSS 有 6.38MB。理论上按需引入能砍到 2MB 左右。
但我最后决定不做,原因值得说一下:
- 风险不对等。 按需引入要手动列出用到的每个组件的样式,漏一个,对应组件在所有页面直接变裸奔——尤其是弹窗、下拉面板、消息提示这类挂在 body 上、容易被忘的组件。
- 它会变成一个长期的坑。 项目是一个微前端项目,已经利用 el-config-provider namespace="ep" 前缀换成了ep,以后每次有人新增一个组件,都得记得回来补一行样式,否则就是"某个弹窗莫名其妙没样式"的诡异 bug,全局换掉不划算且浪费时间。
- 收益已经被 gzip 吃掉了。 6.38MB 经 gzip 压缩后到浏览器只有 ~700KB,而且加载一次就进缓存。为了把这 700KB 再压一点,去承担上面那些风险,不划算。
优化不是"能做就做",是"值不值得做"。这一项,我在文档里明确标了"暂缓",省得以后有人手痒。
最后对比一下
| 指标 | 优化前 | 优化后 | 怎么做到的 |
|---|---|---|---|
| 构建时间 | 8 分钟 | ~3 分钟 | 换 sass-embedded |
| 主 JS 传输量 | 2.67MB | ~800KB | Nginx gzip |
| 主 CSS 传输量 | 6.38MB | ~700KB | Nginx gzip |
| 产物瘦身 | — | -3.7MB | 字体只留 woff2 + echarts 按需 |
几点体会
- 先量,再改。 别一上来就删代码。慢在哪、大在哪,日志和 F12 会告诉你,猜的往往是错的。
- 分清"硬盘体积"和"传输体积"。 产物 80M 不代表用户下 80M。真正影响用户的是单个文件经过压缩后的大小。我差点就把力气全花在削那个跟用户没关系的数字上。
- 最狠的优化,有时候不在你最熟的那一层。 前端折腾半天的体积问题,最后是一段 Nginx 配置解决的。视野别只框在自己的一亩三分地。
- 优化要懂得收手。 花两小时省 700KB 的 gzip 后体积,还搭上长期维护风险,不如把这两小时用在别处。
