2203 字
约 7 分钟
11
S14-A28 · 打包体积:为什么"引入了但没用"的代码还在包里

首屏包 1.8 MB,分析报告里最大的一块是某个 UI 组件库。团队确认只用到了其中的 Button 和 Modal,于是把 import { Button, Modal } from 'lib' 改成按需引入——体积只降了 40 KB。再查下去发现:包里进了整个库,因为它有一个顶层 registerTheme() 调用。

这件事的机制值得说明白:tree-shaking 不是"智能判断你有没有用到",而是"在静态可达性 + 副作用假设下的删除"。只要打包器认为某个模块"可能有副作用",它就不敢删——哪怕你一行都没用。


1. 删除的前提:它认为这段代码"删了也不影响"

判据 打包器怎么看 结果
静态可达(有没有被 import 引用) 分析 import/export 图 不可达 → 可删
是否无副作用 看 sideEffects 声明 / 顶层语句 有疑问 → 保留
是否可求值(能否在编译期算出) 常量折叠 算不出 → 保留
是否被动态引用 require(变量) / 字符串拼接 有动态 → 整包保留
// 同一个按钮组件,两种包的体积贡献(实测形状)
lib 未声明 sideEffects:  Button 2KB + 整个库 460KB 一起进包
lib 声明 sideEffects:false: Button 2KB + 它依赖的 6KB = 8KB
// 差的 452KB 全是"这段代码可能做事"的保险费

第二行是主战场:默认假设"有副作用"。所以一个库如果没有声明 sideEffects: false,它的每个模块都会被视为"可能做事",于是全部保留。

// 同一个 import,两种结果
import { Button } from 'lib'        // lib 未声明 sideEffects → 整个 lib 进包
import { Button } from 'lib'        // lib 声明 sideEffects:false → 只留 Button 及其依赖
// 差别不是语法,是包里的元数据

2. 三种"删不掉"的写法

写法 为什么留 怎么改
import 'polyfill'(无绑定) 导入本身可能就是副作用 确认后改为按需调用
顶层 registerTheme() / window.x = ... 顶层语句必定执行 移进函数或懒加载
装饰器 / prototype 扩展 元数据在运行期,静态分析看不见 换成显式注册
// 三种写法的静态分析结果对比
export const add = (a,b) => a+b          → 可摇(纯函数,无顶层语句)
registerPlugin({ name:'x' })             → 保留(顶层调用,被判有副作用)
import './theme.css'                     → 保留(且声明 sideEffects:false 后会消失)
// 第二条最常见:库的 index.js 里做了自动注册

第三行在前端框架里很常见:装饰器(或 Object.defineProperty 扩展原型)让"类"与"元数据"的关系只在运行时成立,静态分析无法判断"这个类有没有被用",只能整块保留。


3. sideEffects: false 是承诺,不是开关

库的真实行为 声明 sideEffects: false 后
纯函数 / 纯组件 正确,能安全摇掉
自动注册(插件、主题、polyfill) 会坏:注册代码被删了
注入 CSS(import './style.css') 会坏:样式消失
全局补丁(修改 Array.prototype) 会坏:运行时报错

第三行是最常见的翻车点:很多库在 JS 模块里 import 了 CSS。声明"无副作用"后,打包器把这条 import 删了,页面样式就丢了——而错误只在视觉上体现,测试通常发现不了。

// 正确的声明方式:只把纯模块标成无副作用
{ "sideEffects": ["*.css", "*.scss", "./src/register.js"] }
// 语义:除了这些,其余模块都无副作用 —— 而不是整个包都无副作用

4. 体积归因:先看报告,再改代码

手段 能看到什么
--analyze(webpack)/ rollup-plugin-visualizer 每个模块占多少
source map + 逐行归属 某个大文件里哪几行在包里
构建产物的 import 图 谁引入了谁
对比两次构建的 diff 这次改动新增了多少

第四行是最实用的日常动作:每次 PR 比一次产物体积,涨了就看 diff。体积不是一次涨上去的,而是每次"顺手加个依赖"累积出来的——而等到有人想起来优化时,已经很难判断哪 200 KB 是必要的、哪 200 KB 是历史遗留。,涨了就看 diff。它比"季度性优化"有效得多,因为体积是慢慢涨上去的。

// 一个可用的体积门禁(CI 里跑,超阈值就失败)
$ npm run build && du -k dist/*.js | sort -rn | head -5
$ node -e "const s=require('./dist/stats.json');console.log(s.total)" 
// 与基线比:+5% 警告,+10% 失败;并输出"新增最大的三个模块"

5. 什么时候"包大"不是问题

场景 为什么 说明
内网后台系统 首次加载慢一次,之后走缓存 不值得优化
首屏不是它 路由懒加载已隔离 看首屏包
用户量极小 省下的流量 < 优化成本 先看总量
有强缓存策略 只有首次付传输成本 关键在缓存头

第四行常被忽略:在强缓存 + 内容哈希的前提下,"体积大"只影响首次访问,而首次访问的转化损失远小于"为优化体积引入的复杂度与 bug"。这也是为什么后台系统与工具类产品几乎不该为体积立项——它们的用户每天来,缓存永远命中。,"体积大"只影响首次访问**,而首次访问的转化损失远小于"为优化体积引入的复杂度与 bug"。


6. 排错:五种"改了 imports 没变小"

现象 八成原因 处理方向
改按需引入只降一点 库未声明 sideEffects 查包的 package.json
删了组件体积没变 有动态 require 引用整包 查动态 import 写法
声明无副作用后样式丢了 CSS 被当作副作用删掉 白名单里加 *.css
某个类始终在包里 装饰器元数据不可静态分析 换显式注册
本地小了、线上没变 CDN 缓存未失效 换 hash 文件名

第五行是发布侧的问题,但它会让"优化生效"这件事被误判成失败——先确认线上真的拉到了新包。


7. 边界:什么情况不该为体积重构

场景 为什么 做法
已经满足性能预算 无收益 只监控
优化需要改库的导出结构 影响所有使用方 换库更划算
体积来自业务代码本身 摇不掉 拆分路由 / 懒加载
首屏指标达标但总包大 用户没感知 不必动

判断口径一句话:体积优化的收益要看"首屏包里有多少是这次能摇掉的"——如果大头是业务代码或已懒加载的 chunk,那么调 sideEffects 只会带来风险而没有收益。判据:先看分析报告里"未使用但被保留"的占比,低于 10 % 就别动。


8. 自测

  1. tree-shaking 的两个前提是什么?为什么"没声明 sideEffects"会导致整包保留?
  2. import 'x'(无绑定)为什么几乎必然被保留?
  3. sideEffects: false 在什么情况下会让线上坏掉?CSS 为什么是重灾区?
  4. 装饰器为什么让类无法被摇掉?静态分析缺的是什么信息?
  5. 为什么"每次 PR 比一次体积"比"季度性优化"更有效?
  6. 什么情况下"包大"根本不是问题?判据是什么?

9. 一句话总结

tree-shaking 是"静态可达 + 副作用假设",不是"智能判断有没有用到":默认假设有副作用,所以未声明 sideEffects 的库会整包保留;而 import 'x'(无绑定)几乎必然留下,因为导入本身可能就是副作用。sideEffects: false 是承诺不是开关——库里有 CSS 注入或自动注册时,声明它会让线上静默坏掉(样式消失、注册缺失)。判据:先看报告里"未使用但被保留"的占比,低于 10 % 就别动。

S14-A28 · 打包体积:为什么"引入了但没用"的代码还在包里
https://www.clxhxhhr.top/posts/6759/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。