前端打包优化经验:包体积不是只靠压缩就能降下来的
打包优化最容易被低估的,是它要担的代价——不是"文件小一点"这么轻松。真正决定成本的是:你得先花时间把 bundle 拆开看清楚每个 chunk 里装了什么,再一个个判断哪些该进首屏、哪些该延后,每一次拆分都要在请求数、缓存命中和维护复杂度之间做取舍。压缩只是最后一层,它省不下结构上的浪费。
很多人一提打包优化,第一反应是开 gzip、开 brotli、调 splitChunks。这些当然重要,但它们更像结果优化而不是根因优化。一个项目包越来越大,通常不是压缩没做好,而是依赖结构、代码组织和资源策略本身就不够克制。打包优化真正要回答的问题是:用户首屏到底下载了什么、执行了什么,哪些内容本不该这么早出现。
我第一次认真做打包优化,是因为一个中后台首页从"能接受"变成了"肉眼能等"。大家第一反应都是开压缩、调 splitChunks,但 bundle 分析图一打开就很尴尬:首页包里混进了图表库、富文本编辑器、Excel 导出库,还有一个只在报表页才用到的地图 SDK。压缩能省一点,但真正的问题是这些东西根本不该跟首页一起下载。压缩优化的是"怎么把这堆东西传得快一点",可它们压根就不该出现在首屏——这才是那次让我改变思路的地方。
从那以后,我看打包优化会先问两个问题:这个模块是不是首屏必须,用户是不是一定会用。答案是否的,就先从加载时机上找空间,而不是先去调压缩参数。压缩的收益有天花板,结构的收益没有。
先弄清楚体积是怎么长起来的
一个前端包变大,来源就那么几类:引入了过大的第三方库、首屏加载了本不该首屏加载的代码、公共依赖拆得不合理、图片图标字体这些静态资源过重、工具函数或组件重复打进多个 chunk、多语言和编辑器图表这类低频模块被提前加载。
不先定位来源,只盯着构建配置调参数,优化很快就撞瓶颈。更合理的第一步是做 bundle 分析:看清每个 chunk 里有什么、哪些依赖最大、哪些模块重复出现,再决定怎么改。
分析工具不用迷信某一个。Webpack 用 webpack-bundle-analyzer,Vite / Rollup 看 rollup-plugin-visualizer,Next.js 有 @next/bundle-analyzer。工具只是把体积可视化,真正要做的是把图里的模块和业务场景对上号。
看分析图有个门道:别只看谁最大,要看"谁不该在这里"。一个 200KB 的框架运行时占首屏很正常,但一个 150KB 的 Excel 导出库出现在首页 chunk 里就是问题。前者是必要成本,后者是错放位置。所以我看图时脑子里一直挂着一个问题——这个模块,首屏用户真的会碰到它吗。碰不到就该延后,无论它大小。分析工具给的是"有什么",判断"该不该"还得靠人对业务的理解,这部分没法自动化。
Vite 项目里我还会顺手看一眼 build.rollupOptions.output.manualChunks 的分组结果,确认框架、大依赖、业务代码有没有按预期落到不同 chunk。默认的自动分包在多数场景够用,但一旦手动干预分组,就要盯着分析图确认没把不该合的合到一起。
我一般盯三个数字:首屏 JS 总体积、最大单个 chunk 体积、首屏实际执行耗时。只看压缩后体积不够——300KB 的 JS 下载完还要解析和执行,低端设备上这一步经常比下载更明显。这也是那个反直觉的代价:你把体积压得再狠,解析执行的账还得单独算。
这里要拆穿一个常见误会:压缩降的是传输体积,不是执行成本。gzip、brotli 能把 JS 文本压掉六七成,网络上传的字节确实少了,但浏览器拿到之后要先解压,再把完整的源码交给 JS 引擎解析、编译、执行。这三步花的时间跟解压后的原始体积挂钩,跟你压了多狠没关系。所以一个 gzip 后 80KB、解压后 300KB 的包,在千元安卓机上照样能卡出几百毫秒的主线程占用。压缩省的是流量和首字节时间,省不了 CPU 的活。想真的降执行成本,只有一条路——让那些代码根本别进首屏。
顺带说 brotli 和 gzip 的取舍。brotli 在同样内容下通常比 gzip 再省 10% 到 20%,但最高压缩等级很费 CPU,不适合每次请求实时压。合理做法是静态资源在构建期就预压好 .br 文件,让服务器直接吐现成的,把压缩这笔一次性成本挪到构建阶段,而不是压在每个用户的请求路径上。这也是一种典型的成本转移——花构建时间,换运行时便宜。
别把"功能能跑"当成引依赖的标准
很多包体积问题,是从一行 import 开始的。为一个日期格式化引入完整日期库,为几个图标引入整套图标包,为一个弹窗带进整套 UI 框架。这类问题最常见也最容易被放过,因为功能层面完全没毛病。
但从长期看,引依赖的判断标准不该是"功能满足了没",而是"这笔长期成本值不值"。评估一个依赖,我会过几个问题:真的需要第三方库吗,它支不支持按需导入,会不会进首屏包,项目里是不是已经有同类依赖,会不会带来运行时开销,将来好不好替换。
有时候手写几十行稳定代码,比引入一个大依赖更划算。但也别为了"零依赖"走极端——密码强度、日期时区、富文本解析、图表渲染这类问题,自己写的隐藏成本往往更高。我的取舍标准是:业务规则是否稳定、边界是否复杂、库的体积和维护状态是否配得上需求。简单格式化可以手写,复杂领域能力不要硬造。
举个我们真实做过的取舍。项目里最早用 moment.js 处理日期,它功能全,但体积大、还默认把一大堆 locale 全打进来,是分析图里的常客。后来做新模块时换成了更轻的方案——只需要格式化的地方用 date-fns 按需引入几个函数,或者干脆用原生 Intl.DateTimeFormat。这不是说 moment 不好,而是我们的使用场景其实只用到它一小部分能力,为这点能力背整个库不划算。判断依据始终是"我用到的部分,值不值这个体积",而不是"这个库好不好"。
再举一个反方向的例子:表格里的虚拟滚动,我们一度想自己写,写到一半发现要处理动态行高、横向虚拟、滚动锚定这些细节,成本远超预期,最后还是引了成熟的库。"手写还是引库"说到底不是一条原则能一次性拍板的,是每个具体场景各自算一遍成本——覆盖的场景越复杂、越不稳定,越该把它交给经过千锤百炼的库。
导入方式会左右 tree shaking
不少库支持 tree shaking,但前提是导入方式和构建配置都对。只要一个函数时,优先用明确的子路径或命名导入:
1import debounce from "lodash/debounce";
而不是:
1import _ from "lodash"; 2 3var debounce = _.debounce;
后者可能把一大堆用不到的代码顺带打进来。不同库表现不完全一样,最终仍要以 bundle 分析结果为准。图标库也一样,只要几个就只导入实际用到的:
1import { Search, Settings } from "lucide-react";
别为了省事把整套图标注册到全局。还有一种重复打包很隐蔽:同一个库被两种入口方式引进来,一部分代码走 import debounce from 'lodash/debounce',另一部分走 import { debounce } from 'lodash-es',功能都对,产物里却可能出现两套实现。分析图里看到重复依赖,要顺手把导入规范统一掉。
tree shaking 还有个前提常被忽略——sideEffects。构建工具要判断"删掉某个没被引用的导出安不安全",得知道这个模块有没有副作用(比如在顶层修改全局、注入样式)。如果 package.json 里 "sideEffects": false 标错了,构建工具可能把有副作用的代码也当成安全删掉,或者反过来因为不敢删而保留一堆死代码。用第三方库时,如果发现某个明明按需引入的库还是把整包打进来了,值得去查一下它的 sideEffects 配置——很多老库根本没标,构建工具只能保守地全留下。这类问题光看导入方式看不出来,得回到分析图和库的 package.json 里对。
顺带说一个今年上手的取舍工具:TypeScript 4.9 起有了 satisfies。它和体积没直接关系,但在维护一份大的配置常量或图标映射时,satisfies 能既保留字面量的精确类型、又校验它符合某个约束,避免为了类型正确硬套一个宽泛的接口、结果反而写多余代码。工程上的取舍常常就是这种细节堆出来的。
路由级拆分是最基本的一步
一个页面用户根本没访问,它相关的代码理论上就不该出现在首屏。这也是代码分割最直接的价值。对多页面或中后台系统来说,路由级拆分通常是最先该做的事,它不是锦上添花,是把不必要的首屏负担直接拿掉。
React 里低频页面动态加载:
1import { lazy, Suspense } from "react"; 2 3const ReportPage = lazy(() => import("./pages/ReportPage")); 4 5export function App() { 6 return ( 7 <Suspense fallback={<div>加载中...</div>}> 8 <ReportPage /> 9 </Suspense> 10 ); 11}
Next.js、Vue Router 都有各自的路由级拆分方式。关键不在 API 怎么写,而在首屏不该替所有页面买单。
路由级拆分基本没有副作用,因为页面之间天然是"用户一次只看一个"的关系,拆开完全符合直觉。它是我做优化的第一刀,也是收益最稳的一刀。但有个前提要注意——公共布局、导航、鉴权这些每个页面都要用的东西,别跟着某个页面一起拆出去,那样反而每进一个页面都重新下一遍。把"框架级共用"和"页面级独有"分清楚,路由拆分才拆得干净。
Vue Router 里对应的写法是把组件换成动态导入:
1const routes = [ 2 { 3 path: "/report", 4 component: () => import("./pages/ReportPage.vue"), 5 }, 6];
写法各家不同,判断标准是一样的:这段代码只服务这一个路由吗?是,就该跟着路由一起按需加载。
组件级懒加载也得掂量
发现包大之后,有些团队会把大量组件都改成懒加载。这不是不行,但要分场景。组件本身很轻、用户几乎一定会看到,为了"拆得更碎"而懒加载,收益未必大,反而多了请求碎片和复杂度。
真正适合懒加载的,是那些又重又低频的:
- 富文本编辑器
- 图表模块
- 大型表格与虚拟列表
- 低频弹窗与抽屉
- 后台高级筛选面板
- 代码编辑器、Markdown 预览
判断维度就两条:够不够重、够不够低频。又重又低频的优先拆;又轻又高频的别碰,拆了反而多一次请求。懒加载的目标不是 chunk 越多越好,是让用户先拿到当下需要的内容。切得太碎,首屏请求数上去了,弱网下每个请求的往返成本叠加起来,可能比不拆还慢——这又是一个要拿捏的度。
拆出去之后还要接着算体验的账。一个低频弹窗,用户点开才开始下载 800KB 编辑器、界面空等两秒,照样被当成卡。可以在用户靠近动作时预加载——鼠标悬停按钮、进入某个页面后趁空闲再拉低频模块:
1function EditorButton() { 2 function preload() { 3 // 悬停时提前把 chunk 拉下来,点击时就不用等 4 import("./HeavyEditor"); 5 } 6 7 return ( 8 <button onMouseEnter={preload} onClick={openEditor}> 9 编辑 10 </button> 11 ); 12}
浏览器层面还有 <link rel="prefetch">,能在浏览器空闲时提前把资源塞进缓存,等真正需要时直接命中。Webpack 的魔法注释 /* webpackPrefetch: true */、/* webpackPreload: true */ 就是干这个的。要分清两者:prefetch 是"将来可能用到,空闲时慢慢拉",preload 是"这次导航马上要用,优先拉"。用错方向反而会抢占首屏带宽。拆包不是把成本消掉,是把它挪到更合适的时间点——而挪去哪个时间点,本身就是要权衡的。
图片和图标常常是被低估的体积来源
不少人只盯 JS 包,忽略了静态资源。实际项目里这些很常见:首页大图没压缩、多个页面重复加载同一张大图、只用几个图标却引整套图标包、字体文件过大、多套字重同时加载、移动端加载了桌面尺寸的图。
这些加起来,用户的感知可能比 JS 包还重。图片该做尺寸裁剪、格式优化和懒加载,字体该限制字重和字符集,图标优先按需导入。我后来会把图片也纳入"打包体检"——很多页面 JS 抠了半天省 80KB,结果首屏一张没压的 PNG 就 1.8MB。WebP、AVIF、响应式图片、懒加载这些手段不新,但对用户感知非常直接。尤其移动端,别让手机去加载桌面尺寸的海报图。
图标这块还有一个把 JS 包顺带撑大的坑。有些团队用 SVG-as-component 的方式引图标,每个图标编译成一个 React 组件打进 JS 包,图标一多,JS 体积就跟着涨,还占了解析执行时间。换个思路,图标其实更适合走独立的静态资源——用 SVG sprite 或者 CSS background,让它们不进 JS 包、能单独缓存:
1<!-- 一个雪碧图,用到哪个引哪个,不占 JS 体积 --> 2<svg><use href="/icons.svg#search" /></svg>
字体也一样有取舍。中文字体动辄几 MB,全量加载对首屏是灾难。能用字体子集化(subsetting)只保留用到的字符最好;配合 font-display: swap 让文字先用系统字体渲染出来、字体到了再替换,避免"字体加载完之前一片空白"。这些都不是 JS 打包问题,但它们和 JS 一起决定了用户首屏到底要下多少字节,放在同一个"体检"里看才完整。
公共代码不是越集中越好
拆公共 chunk 时一个常见误区是:只要公共就全抽出来。问题在于,如果一个公共 chunk 特别大,而里面大部分内容其实只服务少数页面,它反而变成新负担——用户访问首页,却被迫下载后台报表才用得到的依赖。
公共代码拆分追求的不是"集中",是"合理复用"。我常用的原则:框架运行时可以稳定抽出,多数页面都用的基础组件可以抽出,低频页面的大依赖不要进首屏公共包,变化频繁的业务代码不要和稳定依赖混在一起。这样也对缓存友好——稳定依赖不常变、业务代码频繁变,分开后缓存命中率更高。
但也别切得过度。我们有次把 vendor 拆得非常细,结果首屏请求数暴涨,HTTP/2 下影响小一点,但在弱网和老 WebView 里调度成本仍然明显。后来收敛成"稳定框架依赖一组、核心组件一组、低频大依赖按页面拆",首屏才稳下来。这就是拆分的取舍:太粗浪费带宽,太细浪费连接。
缓存策略也是打包优化的一部分
打包优化不只是让文件变小,也包括让用户不重复下载没变的文件。常见做法是文件名带 content hash:
1app.8f3a1c.js 2vendor.92bc10.js
内容不变文件名不变,浏览器就能长期缓存;内容一变文件名跟着变,用户自动下载新版本。这要求构建产物和服务器缓存头配合好:
- HTML 通常不能长期缓存,因为它要引用最新的资源文件名,一般设
no-cache或很短的 max-age。 - 带 hash 的 JS、CSS、图片可以设很长的缓存,配上
immutable告诉浏览器"这文件永远不变,别再回头问我"。
这里有个和拆分策略交叉的取舍点:hash 是按内容算的,所以 chunk 划分方式直接影响缓存命中率。如果把稳定的框架依赖和天天改的业务代码打进同一个 chunk,每次发版这个 chunk 的 hash 都变,用户就得连框架一起重新下——明明框架半年没动过。反过来,把框架单独拆成一个稳定 chunk,发版时它的 hash 不变,用户直接命中缓存,只下变了的那部分。
所以拆分和缓存不是两件事,是一件事的两面:拆分决定了缓存的粒度。我们后来定的规矩是"越稳定的越往下沉、单独成 chunk,越易变的越隔开",正是为了让缓存尽可能多命中。这也解释了为什么前面说"变化频繁的业务代码不要和稳定依赖混在一起"——混在一起,等于每次发版都作废一大块本可以复用的缓存。
体积预算最好能自动卡住
打包优化最怕只靠人肉记忆。今天大家刚优化完都很谨慎,过两个月新需求一赶,某个大依赖又被顺手塞回首屏。我的做法是给构建产物加一个简单的体积预算,超了就在 CI 里失败。
不用额外依赖也能先做个粗版本,直接用 Node 读 dist 里的 JS,算原始体积和 gzip 体积:
1// scripts/check-bundle-size.mjs 2import { gzipSync } from 'node:zlib' 3import { readdirSync, readFileSync, statSync } from 'node:fs' 4import { join } from 'node:path' 5 6const dist = 'dist/assets' 7const limit = 220 * 1024 8 9function walk(dir) { 10 return readdirSync(dir).flatMap((name) => { 11 const file = join(dir, name) 12 return statSync(file).isDirectory() ? walk(file) : file 13 }) 14} 15 16const jsFiles = walk(dist).filter((file) => file.endsWith('.js')) 17const totalGzip = jsFiles.reduce((sum, file) => { 18 const code = readFileSync(file) 19 const gzipSize = gzipSync(code).length 20 console.log(`${file}: ${(gzipSize / 1024).toFixed(1)} KB gzip`) 21 return sum + gzipSize 22}, 0) 23 24if (totalGzip > limit) { 25 throw new Error(`JS gzip ${(totalGzip / 1024).toFixed(1)} KB exceeds ${(limit / 1024)} KB`) 26}
这脚本不如专业 analyzer 细,但胜在门槛低,能先把"包突然变大"拦住。成熟一点之后,可以把首屏 chunk、路由 chunk、CSS、图片分别设预算,而不是只看总和。总和没变不代表没问题——一个低频页面瘦了 100KB、首页胖了 100KB,用户感知反而可能更差。社区里也有 size-limit 这类现成方案,能直接在 CI 里对指定入口设阈值,团队如果想省掉自己维护脚本的成本,这是个更省心的选择。
别让体积数字骗了你,最终要看真机
体积预算能拦住"包变大",但包小不等于快。有几层现实很容易被漂亮的体积数字盖过去。
一是解析执行成本前面说过,压缩后的数字掩盖了解压后的真实工作量。二是请求瀑布:首屏可能不是被某个大 chunk 拖慢,而是被一长串串行依赖的小请求拖慢——A 加载完才知道要加载 B,B 完才轮到 C,每一跳都是一个网络往返。这种问题在体积总和上完全看不出来,得看 Network 面板的瀑布图。三是设备差异:同一个包在你的开发机上秒开,在用户的千元机、弱网、老 WebView 上是另一回事。
所以我优化到后期,会离开体积数字,去看几个更贴近用户的指标:Lighthouse 跑一轮拿 LCP、TBT(Total Blocking Time)这些数;用 Performance 面板录一段首屏,看主线程被哪段脚本占住;有条件的话在真机或者 DevTools 的 CPU 降速档位下复现。今年 Google 已经在推 INP 替代 FID 作为交互响应指标了,虽然还没正式成为 Core Web Vitals,但方向很明确——衡量的重点在从"能不能交互"往"交互跟不跟手"走,而重 JS 首屏正是拖累交互的大头。体积只是手段,用户感知才是目的,两者别搞反。
优化前后一定要留记录
如果一个项目已经明显变重,最不该做的就是靠猜优化。这时最有效的办法是把它可视化:看 bundle 分析图、看每个 chunk 的组成、找出最大的第三方依赖、找出重复出现的模块、对比优化前后的首屏资源大小。很多优化方向只要一可视化就非常明显——一个图表库进了首页包、一个富文本编辑器被公共组件间接引用、一个工具库被两种方式重复打包。
优化前后要留记录。我会把分析图截图、关键体积数字和改动原因写进 PR。否则几个月后有人又把大依赖引回首屏,大家只知道包变大了,却没人记得当初为什么把它拆出去。
这一步的价值容易被低估。打包优化最大的敌人不是技术难度,是时间——今天拆干净的首屏,架不住半年里几十个需求各塞一点。每个人塞的时候都觉得"就一个小依赖",加起来就是首屏重新胖回去。留下记录,等于给未来的自己和同事留了一句"这里当初是特意拆掉的,别随手加回来"。配合前面说的 CI 体积预算,一个靠人记、一个靠机器卡,才拦得住这种慢性回潮。
依赖去重是容易被忽略的一块
包变大还有一个隐蔽来源——同一个依赖被打进来好几份不同版本。项目依赖树深了以后,很容易出现 A 组件依赖 [email protected]、B 组件依赖 [email protected],最终产物里两份都在。功能都正常,体积白白翻倍。
这类问题光看业务代码看不出来,得从依赖树入手。先用包管理器查一查:
1# npm 2npm ls some-lib 3# pnpm 4pnpm why some-lib
看清楚是谁引入了哪个版本,再决定能不能收敛到一个版本。有时候升一下某个依赖就统一了,有时候得靠包管理器的 resolutions / overrides 强制锁定。这两年团队 monorepo 用得多,pnpm 的硬链接机制在相同版本上天然去重,但跨版本该重复还是重复,这笔账仍然要人去核。
React 这类有单例要求的库更要警惕——同一个应用里混进两份 React,不只是体积问题,还会直接报运行时错误(多个 React 实例、hook 失效)。所以大型依赖的版本统一,既是体积优化,也是稳定性保障。
真正长期有效的,往往是结构优化
避免滥引第三方库、限制公共模块膨胀、拆分高成本页面、减少重复资源、让低频功能延后加载、保持依赖引入方式一致、为资源设合理缓存。这些事比单纯调构建参数费脑子,代价也更实在——每一步都要判断、都要担一点风险,但长期价值也在这里。
我现在做打包优化,会把它当成一次项目结构体检,而不是构建参数调参:先看首屏实际下载和执行了什么,再看哪些依赖不该进首屏、哪些低频页面该拆、哪些公共 chunk 已经膨胀、哪些资源该走缓存和按需加载。压缩、分包、缓存当然重要,但真正决定包体积的,还是代码和依赖本身的组织方式。结构合理,很多优化会自然发生;结构混乱,再多构建技巧也只是延后问题爆发。