压缩器到底做了什么——经过 gzip 之后还剩多少收益
真正的 JavaScript 压缩器(terser、esbuild、swc)做的远不止删空白。它们先把源码解析成 AST,再施加保持语义的变换:标识符改名(把局部变量改成单字母——因为作用域已知,所以安全)、常量折叠(1000 * 60 * 60 变成 3600000)、死代码消除(if (false) 分支直接消失)、布尔值与属性简写重写(true 变 !0,obj['key'] 变 obj.key)、函数内联。带 pure 注释标记的调用如果结果未被使用可以整体删除——tree-shaking 后的库能达到宣传的体积,靠的正是这个。
数字之所以重要,是因为 gzip 会改变结论。仅压缩本身通常能去掉应用代码 30–60% 的原始字节。但 gzip 极擅长压缩空白和重复的长标识符,所以压缩后差距会缩小:一个从 400 KB 压到 160 KB 的文件,gzip 后可能是压缩版 52 KB 对未压缩版 61 KB。剩下的 15–20% 依然值得要——而且改名对压缩率的帮助比想象的小,因为短名字削减的冗余本来 gzip 也能利用。
实用规则:测量压缩后的体积,而不是原始体积。一个能省 40% 原始字节但 gzip 后只省 3% 的压缩步骤,可能不值得为它引入 source map 的复杂性。反过来,死代码消除和 tree shaking 往往比所有空白删除加起来省的压缩字节还多——因为被删掉的代码压缩后就是零。
# same bundle, four ways (typical mid-size app)
source 412,000 bytes
minified 168,000 bytes (-59% raw)
minified + gzip 52,000 bytes
minified + brotli-11 46,000 bytes
gzip WITHOUT minify ≈ 61,000 bytes
# → minification still buys ~15% after compression,
# mostly from dead-code elimination, not whitespace正则 vs AST:为什么字节级压缩 JavaScript 不安全
JavaScript 无法用字符串替换安全压缩,原因是结构性的。第一,斜杠字符有歧义:a / b 是除法,而 /b/.test(a) 是正则字面量。某个斜杠到底是哪种,取决于解析状态,而基于正则的替换器没有这个状态。把 /https?:\/\// 这类正则字面量里的"注释"删掉,代码语义就悄悄变了。
第二,自动分号插入(ASI)让换行具有语义。return 后跟换行再跟对象字面量会返回 undefined——ASI 在 return 后插入了分号。粗暴地合并行要么掩盖这个 bug,更糟的是制造新 bug:以变量结尾的一行和以左括号或左方括号开头的下一行拼在一起,会融合成函数调用或属性访问。真正的压缩器先解析,因此知道哪里必须有分号,甚至能反过来利用 ASI 安全地省掉分号。
第三,字符串和模板字面量是不透明容器。含两个斜杠的字符串不是注释;模板字面量里的空白是数据,必须逐字节保留。
对这类快捷在线压缩器的结论:用于事后能人工过目的小片段——书签脚本、内联事件处理器、短样式块——没问题。凡是要发布给用户的代码,请在构建中使用 terser、esbuild 或 swc。esbuild 比 terser 快大约 10–100 倍,输出只大约 1–2%;terser 压得最狠;swc 居中,且对 TypeScript 支持出色。
// 1. ASI: this returns undefined, not the object
function pick() {
return // <- semicolon auto-inserted here
{ ok: true };
}
// 2. line joining creates a call that never existed
const total = fn
(a || b).forEach(log) // joined: fn(a || b).forEach(log)
// 3. slash ambiguity — is it division or a regex?
const x = a / b / c; // two divisions
const ok = /a\/b/.test(s); // one regex containing a slash
// a byte-level replacer cannot tell these apartCSS 压缩:安全的变换与 calc() 陷阱
CSS 比 JavaScript 简单,所以更多部分可以机械地压缩——但不是全部。可靠安全的变换有:删除注释(以 /*! 开头的许可证注释除外,构建工具按惯例保留)、折叠字符串之外的空白、去掉每个声明块最后一个分号、缩短成对数字的六位十六进制颜色(#ffffff 变 #fff)、去掉零长度的单位(0px 变 0——因为零在任何长度单位下都是零)、合并四个相同的盒模型值(margin: 4px 4px 4px 4px 变 margin: 4px)。
经典陷阱是 calc():CSS 语法要求 calc 内的 + 和 - 两侧必须有空白,因为紧贴数字的减号会被解析成负值而不是减法。calc(100% - 20px) 合法;calc(100%-20px) 不合法,浏览器会悄悄丢弃整条声明。类似的空白敏感性也存在于媒体查询(screen and (min-width: ...))和 CSS 自定义属性中——自定义属性的值按 token 原样存储,压缩 var() 回退值或自定义属性值的内部,可能改变使用方读到的内容。
另一个不直观的点:0% 并不总能替换成 0(在 flex-basis 和某些动画上下文中百分号有意义);去掉字体名或 url() 值的引号,只有在内容不含保留字符时才安全。
实际管线中由 cssnano(基于 PostCSS,配置极细)或 Lightning CSS(Rust 编写,极快,还能把现代语法转译给旧浏览器)处理。两者都解析成 AST,所以既能安全执行上述变换,也能在上下文不允许时跳过。
/* before */
.card { margin: 0px 0px 0px 0px; background: #ffffff; }
.col { width: calc(100% - 20px); }
/* correct minify */
.card{margin:0;background:#fff}.col{width:calc(100% - 20px)}
/* BROKEN: whitespace inside calc() is syntax, not formatting */
.col{width:calc(100%-20px)} /* declaration silently dropped */
/*! preserved license header — the ! is the convention */Source Map:压缩的同时不丧失调试能力
指向 bundle.min.js 第 1 行第 48213 列的生产环境堆栈毫无用处。Source map 解决了这个问题:.map 文件(内含 VLQ 编码 mappings 字符串的 JSON)为每个生成后的位置记录原始文件、行、列,通常还有原始标识符名。压缩文件通过最后一行的 sourceMappingURL 注释引用它,浏览器开发者工具按需加载——普通访客永远不会下载。
部署时的问题是谁能看到 map。三种常见方案:(1)公开 map,最简单,但任何好奇的人都能看到你的原始源码;(2)隐藏 map——照常生成,但不在 bundle 中引用,而是上传到错误追踪平台(Sentry、Datadog、Rollbar),由服务端符号化堆栈;(3)仅向通过认证的内网 IP 提供的受限 map。闭源产品的标准答案是隐藏 map;注意嵌入 sourcesContent 的 map 文件本身就包含完整的原始源码文本。
两个实战坑。链路上的每个变换步骤都必须生成并消费 map(TypeScript 到 esbuild 到 terser),否则最终 map 指向中间产物,所有栈帧都解析到错误位置。其次,重新发布时 map 必须与生成它的 bundle 逐字节对应——错误追踪平台用发布标识或内容哈希来关联 map 正是为此。如果符号化后的堆栈看着合理却微妙地不对,十有八九是发布版本错配的过期 map 在作怪。
// last line of bundle.min.js
//# sourceMappingURL=bundle.min.js.map
// hidden-maps workflow (Sentry example)
esbuild app.ts --minify --sourcemap=external --outfile=dist/app.js
sentry-cli sourcemaps upload --release=v1.4.2 ./dist
rm dist/*.map # maps never reach the CDN
// stack trace before / after symbolication
at t (bundle.min.js:1:48213)
at checkout (src/cart/checkout.ts:87:11) // with map + matching release压缩、传输压缩、打包:三件不同的事,三件都要做
团队经常把三种作用于不同层面的优化混为一谈。压缩(minify)在构建时一次性重写源码文本;传输压缩(gzip 或 Brotli)在响应时对字节编码,浏览器透明解码;打包(bundle)合并模块以减少请求开销并支持跨模块 tree shaking。三者效果相乘——谁也替代不了谁。
传输压缩的配置比多数人想的更重要。静态资源应在构建时以最高级别预压缩(Brotli 质量 11、gzip 9),让服务器通过内容协商选择编码;Brotli-11 按请求实时跑太慢,但部署时跑一次几乎零成本。动态响应用 Brotli 4–5 或 gzip 6——更高级别烧掉大量 CPU 只换来个位数百分比的收益。典型文本压缩率:gzip 约 3–4 倍,Brotli 在 JS/CSS 上比 gzip 再小约 10–20%。
针对本工具服务的内联片段工作流,操作顺序是:可读源码留在仓库里,只对要粘贴进 HTML 属性、GTM 容器或邮件模板的副本做压缩,并验证结果仍能解析——JS 贴进浏览器控制台,CSS 用校验器。永远不要把压缩后的文本当作可编辑的原本:随时可以重新生成的单向产物无害,而手工修改过的压缩代码块是无法撤销的技术债。