Skip to content
返回博客
教程

代码压缩完全指南:CSS、JS 与 HTML 详解

代码压缩是什么、CSS/JS/HTML 如何被压缩,以及 minify 与 gzip/brotli 有何本质区别。理解叠加顺序,并用免费在线工具压缩你的代码。

15 分钟

代码压缩完全指南:CSS、JS 与 HTML 详解

代码压缩(minify)会从你的 CSS、JavaScript 和 HTML 源码里删掉机器并不需要的字符(空白、注释、换行),并把冗长的写法改写成更短的等价形式。行为完全不变,文件只是变小了、加载更快了。

有一点需要先理清:minify 不等于传输压缩(compression)。minify 作用于你的源码,剥除语法上的冗余;gzip 和 Brotli 作用于传输中的字节,对重复出现的模式重新编码。二者发生在不同阶段,针对不同类型的冗余,并且能彼此叠加。这就是为什么即便你的服务器已经在用 Brotli,你仍然应该做 minify。

想现在就压点东西,可以直接跳到 CSS 格式化JavaScript 格式化HTML 格式化,每个工具都完全在你的浏览器里运行。但只有理解了背后的机制,你才能判断该在哪里压缩、以及是否真的需要手动去做。本文依次讲解:minify 到底做了什么,CSS、JS、HTML 各自如何被 minify,minify 如何与 gzip 和 Brotli 叠加,什么时候你的构建工具已经替你处理好了,以及 source map 如何让压缩后的代码依然可调试。

minify 是什么

minify 做两件事:删掉对解析器没有意义的字符,再把你的源码改写成更短但含义完全相同的形式。输出对机器而言完全等价,对人而言几乎无法阅读。代码的运行方式毫无变化,变的只是它的外观。

这一点是贯穿全文要牢记的不变量:minify 只编辑源码的表层(空白、注释、标识符名称、冗余语法),绝不改动行为或输出。它是格式化(formatting)的镜像。格式化添加空白让代码可读,minify 剥除空白让代码变小。二者都落在同一条「语义等价」轴上,只是方向相反。

人们常常把三个听起来相似的操作搞混。下表把它们厘清:

维度格式化(beautify)minify传输压缩(gzip/Brotli)
改变了什么添加空白、换行、缩进移除空白和注释,缩短语法对重复模式做字节级编码
在哪一层源码源码传输 / 存储
仍是源码吗是(可读)是(可运行,难读)否(二进制,必须解码)
由谁完成开发者 / 编辑器构建工具 / minifier服务器 + 浏览器
可逆吗语义上可逆语义上可逆(行为不变)完全可逆(解压即还原字节)

格式化和 minify 处在同一条语义等价轴上,传输压缩则处在一条完全不同的轴上。格式化过的文件和 minify 过的文件都是合法源码;而压缩后的文件是一坨二进制,必须先解码才能运行任何东西。

一个代价高昂的误区往往就在这里冒出来:「我的服务器已经开了 gzip,所以 minify 没意义。」并非如此,后面的数字会说明原因。minify 和传输压缩移除的是不同的冗余,所以做了其中一个并不会让另一个变得多余。逐一讲解每种语言时,请记住这一点。

不妨想想 minifier 删掉的那些字节当初为什么存在。你写下空白、注释和描述性命名,是为了你自己和队友,它们让代码可审查、可维护。而解析你 CSS、运行你 JavaScript、构建你 DOM 的那台机器,对它们全都视而不见。minify 这一步,就是在人类用完源码之后,把这些只服务于人的材料丢掉。这也是为什么 minify 是生产环境的事、从不是开发环境的事:你在仓库里保留可读版本,把精简后的版本发给浏览器。可读副本是事实来源,minify 后的副本是随时可以重新生成的构建产物。

CSS 压缩的工作原理

三者之中 CSS 最容易 minify,因为它的语法几乎没有歧义空间。minifier 会剥除注释、把连续空白折叠为零、删掉每个块里的最后一个分号,并去掉 {}:; 周围的空格。光这些就清掉了大部分字节。

CSS 还允许一组其他语言都没有的等价改写,一个好的 minifier 会安全地应用它们:

  • 缩短颜色值。#ffffff 变成 #fff#ff0000 收缩为 red(或反过来,哪个写起来更短就用哪个)。
  • 去掉零值的单位。0px 变成 0margin: 0 0 0 0 变成 margin: 0
  • 去掉前导零。0.5em 变成 .5em
  • 合并简写。分开的四条 margin-topmargin-rightmargin-bottommargin-left 声明折叠成一条 margin
  • 合并规则。选择器或声明相同的相邻规则可以合并,重复的声明被删除。

这每一项都让渲染结果保持一致,这是合规 minifier 永远不会越过的边界。但 CSS 是顺序敏感的:靠层叠(cascade),后面的规则会覆盖前面的。所以安全的 minifier 不会盲目重排那些可能改变「谁生效」的规则。压缩字节是允许的,改变层叠则不允许。

这个约束比听上去更微妙。两条看起来可以合并的声明,可能其实不能合并,因为它们之间有别的东西以同样的特异性引用了同一个属性。看这个例子:

.btn { color: #ff0000; }
.alert .btn { color: blue; }
.btn { color: #f00; }

第一条和第三条规则共用选择器、本可合并,但前提是合并不会把声明挪到中间那条规则的另一侧,从而改变某个同时匹配两者的元素上「谁生效」的结果。一次会重排这些规则的天真合并,可能会破坏层叠。这类边界情形正是 CSSO 这种生产级引擎被设计来推理的,也是为什么你不该用正则去自己手搓一个「删掉空白」的 minifier。这些变换看起来很机械,但背后的安全性分析并不机械。

我们的 CSS 格式化 用 CSSO 引擎来做这种无损 minify,完全在你的浏览器里运行,还带一个字节节省量的读数,让你看到每一遍处理对负载的影响。同一个工具也能反向格式化,所以你可以把从某个线上站点拷来的、已被 minify 的样式表,重新展开为可读、带缩进的规则。当你拷了一段 CSS 想看看它压缩后的体积,或者你在发布一个没有构建步骤帮你做这件事的静态页面时,就该用上它。

JavaScript 压缩的工作原理

JavaScript 的 minify 比 CSS 走得远得多,收益和陷阱都藏在这里。先看一个小函数在经过 Terser 前后的样子:

// 压缩前
function calculateTotal(items, taxRate) {
  let runningTotal = 0;
  for (const item of items) {
    runningTotal += item.price * item.quantity;
  }
  return runningTotal * (1 + taxRate);
}
// 压缩后
function calculateTotal(t,a){let n=0;for(const o of t)n+=o.price*o.quantity;return n*(1+a)}

函数名 calculateTotal 之所以保留,是因为它被导出了(或者说可能从别处被调用);而参数和循环变量都收缩成了单个字母。除此之外,JS minifier 还会做几件各不相同的事:

  • 标识符混淆(mangling)。局部变量和参数被重命名为单个字母:getUserPreferences 变成 a。默认只有局部变量会被混淆,全局名和导出名保持原样,因为重命名它们会破坏从外部引用它们的代码。
  • 死代码消除(dead-code elimination)。无法到达的分支和未使用的变量被移除,与打包器层面的 tree-shaking 协同工作。
  • 常量折叠与语法压缩。表达式被缩短:true 变成 !0false 变成 !1return undefined; 变成 return;

JS minify 里有一个需要特别记住的点:自动分号插入(ASI)陷阱。JavaScript 允许你省略分号,解析器会按特定规则替你补上。当 minifier 删掉这些规则所依赖的换行时,代码含义可能改变。最经典的翻车,是以 ([ 开头的语句被悄悄粘到上一行:

const x = getValue()
[1, 2, 3].forEach(handle)

没有分号时,这会被解析成 getValue()[1, 2, 3],是一个索引表达式,而不是两条语句。一旦被 minify 到同一行,这个 bug 就被锁死了。同样的隐患也出现在以 ( 开头的行上,那时上一个表达式会被当成函数来调用。现代 Terser 能稳妥处理大多数真实场景,因为它先把代码解析成抽象语法树(AST),再在需要的地方重新发出分号,它做的并不是盲目的文本删除。但糟糕的源码加上激进的 minify,确实是生产环境 bug 的真实来源,而且这类故障特别讨厌,因为它们只在 minify 后的构建里出现,开发时看不到。修复在你这一侧:写代码时显式加分号、用无歧义的语法,minifier 就安全了。一条 lint 规则,或者一个在源码层面自动补分号的格式化工具,就能彻底消除这个风险。

合规的 minifier 会保持行为不变,但前提是输入是合法的标准 JavaScript。Terser 解析的是 ECMAScript,它不懂 TypeScript,也不懂 JSX。这些必须先转译成纯 JS,否则 minify 会在解析阶段就失败。如果你把一个 .ts 文件粘进 JS minifier 后报错,原因就在这里。

有一个命名问题经常被问到:minify 和 uglify。它们实际上是一回事。「uglify」来自早期流行的 JS minifier UglifyJS;Terser 是它支持 ES2015 及之后语法的现代分支。如今「minify」是横跨三种语言的通用术语,而「uglify」作为一个更老的、JS 专属的叫法,指的是完全相同的过程。

我们的 JavaScript 格式化 在浏览器里运行 Terser,重命名局部变量、删除死代码、剥除注释,并报告每一遍处理省下了多少字节。

HTML 压缩的工作原理

HTML 的 minify 从基本操作开始:移除注释(保留 <!DOCTYPE> 声明和你仍在依赖的条件注释)、折叠标签之间的空白、去掉属性列表里多余的空格。一个小片段就能看出它的样子:

<!-- 导航 -->
<ul>
  <li><a href="/">Home</a></li>
  <li><a href="/about">About</a></li>
</ul>

变成:

<ul><li><a href=/>Home</a><li><a href=/about>About</a></ul>

注释没了,标签之间的缩进被折叠,可选的 </li> 闭合标签被删掉,没有空格的属性值也丢掉了引号。在此基础上,minifier 还能再用上几招 HTML 专属的技巧:

  • 移除可选的闭合标签。HTML 规范允许省略 </li></p></td> 等若干标签,所以 minifier 可以删掉它们。
  • 移除属性引号。当值不含空格或特殊字符时,class="x" 变成 class=x
  • 折叠布尔属性。disabled="disabled" 变成只剩 disabledchecked="checked" 变成 checked
  • 压缩内嵌的 CSS 和 JS。<style><script> 块里的内容也会被 minify,所以单次处理就能缩小整个文档。

这里有一条最关键的边界:在 HTML 里,空白有时是有意义的。在 <pre><textarea> 内部,每个空格和换行都会被原样渲染。设置了 white-space: pre 的元素也是同样的行为。而行内元素之间的空白会影响布局,两个 <a> 标签之间的一个空格,会在页面上表现为一道间隙。把这些空白压平的激进 minify,可能会改变页面的外观。经验法则是:minify 之后,在发布前先验证 pretextarea 以及行内元素边界附近的渲染。

我们的 HTML 格式化 用 js-beautify 做格式化,用 CSSO 和 Terser 处理内嵌的样式和脚本,全部在客户端完成。它对邮件 HTML 和 CMS 导出的标记尤其好用,这些场景下很少有构建步骤替你做压缩。

minify vs gzip vs Brotli:它们如何叠加

核心问题来了:如果你的服务器已经在用 gzip 或 Brotli,你还需要 minify 吗?需要,原因在于这两种技术移除的是不同的冗余。

minify 移除的是源码层面的语法冗余:那些为了人类可读性而存在的空白、注释、长名字和冗长写法。gzip 和 Brotli 移除的是字节层面的统计冗余:在文件里重复出现的字符串和模式,会被替换成更短的编码。一个理解你代码的语法,另一个只看到一串字节流。正因为它们针对的东西不同,叠加才有效,而且效果很好。

打个具体的比方:gzip 很擅长发现 function 这个字符串在一个 bundle 里出现了两百次,并把每一次出现都替换成一个简短的回溯引用。但它根本不知道 getUserPreferencesgetUserSettings 是可以被缩短的变量名,也不知道整段 if (false) { ... } 永远不会运行。minify 处理的恰恰是这些字节级压缩器看不见的结构性和语义性收益。把它们一起跑,每一个都能清理掉另一个看不见的东西。

下面是这笔账,按它实际发生的顺序:

  1. 单独 minify 通常能把 CSS、JS、HTML 缩小 20–30%,靠的是移除空白和注释、缩短语法。
  2. 在 minify 后的输出上做 gzip 再削掉 60–80%,靠的是对文本中残留的重复模式进行编码。
  3. 用 Brotli 代替 gzip 能让输出再小 15–25%,得益于更大的内置字典和更好的算法。

简单说就是:先 minify,再压缩,合起来的结果往往比原始源码小 80–90%。两者并不互斥,跳过任何一个都是在白白浪费字节。

为什么在 Brotli 之上 minify 依然值回票价?三个原因:

  1. 更小的输入会压得更小。minify 过的文件给压缩器留下的冗余材料更少,而更小、更干净的输入通常会产出更小的输出。
  2. minify 能做压缩做不到的事。死代码消除和短变量名是语义层面的删除。gzip 不理解你的代码,它只看到字节,所以它永远无法删掉一个未使用的函数、或重命名一个变量。
  3. 浏览器要解析的字节更少。解压之后,浏览器拿到的是 minify 后的代码。代码更少意味着解析和执行更快,而不只是下载更小。

这个顺序不是一种选择,而是由每一步所处的位置自然推导出来的。minify 属于构建时(你或你的构建工具做一次)。压缩属于传输时(服务器按请求做,浏览器在收到时解压)。所以流水线天然就是 minify → 部署 → 服务器压缩。你没法反过来跑:根本不存在「先压缩、再 minify」,因为压缩后的输出已经不是源码了。

对「先 minify 再压缩」还有一个小而重要的提醒:内容一旦已经被压缩,再压一次要么没意义、要么适得其反。已经是二进制的高熵资源,例如 JPEG、PNG、WebP、WOFF2 格式的字体,从 gzip 或 Brotli 那里得不到任何好处,根本就不该出现在你的文本压缩规则里。minify 是纯文本变换,所以它从不碰这些文件;而压缩这一环,你必须有所取舍。把服务器配置成只压缩文本类 MIME 类型(HTML、CSS、JS、JSON、SVG),让那些已经压缩过的二进制文件保持原样。

配置传输层(启用 Brotli、设置 Content-Encoding)是由你的服务器或 CDN 处理的运维事务。本文停留在 minify 所在的源码层。如果你在更广泛地优化负载,「在编码层省字节」的同一套思路也适用于图片,我们的 图片格式选型 讲的就是 WebP/AVIF/JPEG 那一面的故事。

什么时候你不需要手动 minify

有一条很多 minifier 的宣传都跳过不提:如果你有构建步骤,你的生产产物已经被 minify 过了。现代构建流水线会自动做这件事。

Vite 和 esbuild 开箱即用地 minify JavaScript 和 CSS。Rollup 和 webpack 通过 TerserPluginCssMinimizerPlugin 来做。Lightning CSS 以原生速度处理 CSS。Next.js、Astro 以及类似框架在生产构建中 minify、tree-shake、拆分 chunk,全程无需你动手。命令通常无非就是 vite buildnpm run build,minify 是「为生产构建」这件事本身的一部分,而不是你额外加上去的独立步骤。如果这描述的正是你的项目,那么之后再把文件过一遍单独的 minifier,往好了说是多余,往坏了说是有害:对已经 minify 过的代码再混淆一次,可能产出令人困惑的结果,也省不下多少额外字节。

构建工具还能做一件单独的 minifier 做不到的事:它能在你整个依赖图的上下文里做 minify。尤其是 tree-shaking,只有当打包器能看到每一个 import 和 export、并证明某个函数从未被使用时才会生效。单文件 minifier 没有图可供推理,它能删掉你给它的那个文件内部的死代码,却无法判断整个被导入的模块是不可达的。这是构建流水线才是生产 minify 正确归宿的又一个原因。

那么什么时候单独的 minifier 才是对的工具?就在那些没有构建步骤替你做的场景里:

  • 静态站点和手写的单文件页面,回路里没有打包器。
  • 邮件 HTML 模板,很多系统按字节计费,且完全没有构建流水线。
  • 第三方代码片段和小部件代码,你要把它们嵌进别人的页面里。
  • 快速体积检查,粘进一段,看看 minify 后它有多大、你省了多少。这正是字节节省读数的用途。
  • 阅读别人 minify 过的代码,这时你反向运行格式化工具,让它重新变得可读。

这个决定很简单。有构建,就让构建去 minify。没有构建、只是一次性操作、或者只想查个体积,在线工具是最快的路径,而且因为这些工具完全在你的浏览器里运行,你的代码从不离开你的设备。这对专有或未发布的代码很重要,这类代码你绝不应该粘进一个会收到一份完整副本的服务端格式化工具里。这和贯穿我们 SQL 风格指南 的隐私论点是同一个,那是本系列里另一篇关于格式化的深度文章。

source map:调试 minify 后的代码

minify 后的代码单看就是调试的噩梦。一旦 Terser 把每一个局部变量都重命名成了 abc,一条指向 bundle.min.js:1:48211 的生产环境堆栈跟踪,几乎没法告诉你到底是哪里出了问题。

source map 解决了这个问题。它是一个 .map 文件,记录了 minify 输出里每个位置与你原始源码里对应位置之间的映射。当浏览器的 DevTools 加载它时,会把 minify 后的错误翻译回真实的文件名、行号和变量名。你对着自己写下的代码调试,尽管浏览器跑的是你构建产出的代码。

实践中,你的构建工具会在生成 minify bundle 的同时生成 source map,再用一行 //# sourceMappingURL=bundle.min.js.map 注释(或一个 HTTP 头)告诉浏览器去哪找这个 .map。打开 DevTools、命中一个错误,堆栈跟踪显示的就是你真实的文件名和行号,而不是 minify 后的那坨乱码。这个 map 是惰性加载的,只在 DevTools 打开时才加载,所以它对你的访客毫无成本。

还有一个值得了解的隐私角度。一个公开的 source map,实际上等于把你的原始源码发给了任何打开 DevTools 的人。对开源代码这没问题,对专有代码就不行了。这时就该用隐藏式(hidden)source map:bundle 里不带 sourceMappingURL 注释,公众永远看不到这个 map,但你仍然把它上传到像 Sentry 这样的错误监控服务。该服务在它那一侧把生产堆栈跟踪反 minify,给你可读的错误,而不会把你的源码暴露给全世界。

注意这如何印证了前面的观点:source map 是一项构建工具的能力。一个普通的在线 minifier 通常不会产出它,因为一次性压缩并不需要。这是又一个让构建去处理生产 minify 的理由,它会免费给你这个 map。还要记住,source map 永远不会改变 minify bundle 本身,它是一个挨在 bundle 旁边的、纯粹的调试辅助。别把这个 .map 错当成生产依赖。

常见问题

minify 和传输压缩是一回事吗?

不是。minify 改写你的源码,剥除空白、注释、缩短名字,使它仍是合法代码,只是更小。传输压缩(gzip、Brotli)则把得到的字节编码以便传输,浏览器再解码。它们针对不同的冗余、工作在不同的阶段,并且可以叠加:先 minify,再压缩。

如果我用了 gzip 或 Brotli,还需要 minify 吗?

需要。即便有 gzip 和 Brotli,minify 依然重要。minify 后的代码给压缩器的冗余输入更少,所以压得更小;而且 minify 会做语义层面的删除,比如清掉死代码、缩短变量名,这是字节级压缩做不到的。浏览器要解析的字节也更少。两个都用,并且按这个顺序。

minify 会破坏我的代码吗?

合规的 minifier 会保持行为不变:CSS 渲染一致,Terser 让 JavaScript 保持等价。输出运行起来和源码一样。两点提醒:依赖自动分号插入的 JavaScript 需要合法的语法;而像 <pre><textarea> 这类对空白敏感的 HTML,应在 minify 后加以验证。

minify 和 uglify 有什么区别?

对 JavaScript 来说它们实际上是一回事。「uglify」来自 UglifyJS,一个早期流行的 JS minifier;Terser 是它支持当前语法的现代分支。如今人们横跨 CSS、JS、HTML 通用地说「minify」,而「uglify」是一个更老的、JS 专属的叫法,指的是同一个过程。

我应该在开发环境 minify 吗?

不应该。minify 生产构建,而不是开发环境。minify 后的代码不可读、难调试,所以开发时你想要的是完整、格式化好的源码。你的构建工具,无论是 Vite、esbuild 还是 webpack,都会在你为生产构建时自动 minify,通常还带着 source map,让你依然能调试已部署的 bundle。

minify 能把文件体积减小多少?

单独 minify 通常能把 CSS、JS、HTML 缩小约 20–30%,主要靠移除空白和注释、缩短名字。再叠加上 gzip 或 Brotli,合起来的结果往往比原始源码小 80–90%。具体数字取决于文件里原本有多少空白和冗余。

标签: minification css javascript html web-performance build-tools code-optimization