我第一次用 webpack 打包前端项目时,看到 dist 目录下的 JS 文件比源码还大,第一反应是:这产物真的能直接放上线吗?后来才知道,光是构建还不够,还需要对 JavaScript 做压缩。而这个环节里最主流、也最值得小白的工具,就是 Terser。Terser 能把 JavaScript 代码体积大幅缩减:去掉注释和空格、缩短变量名、删掉永远执行不到的分支,整个过程不改变代码功能,但对你网页的加载速度提升非常明显。这篇文章我会从一个完全零基础的角度,把 Terser 的原理、安装、命令行使用、工程集成和踩坑经验一次讲清楚,保证你照着做就能上手。
1. 压缩 JavaScript 到底在压缩什么:先搞懂原理再动手
1.1 代码压缩和 gzip 压缩不是一回事
很多人一提到压缩,脑子里蹦出来的是 zip 压缩包、图片压缩、内存压缩这些东西。搜索引擎里搜"压缩",出来的也经常是"图片压缩""在线压缩""360压缩"这类结果。实际上,JavaScript 代码压缩是完全独立的一条技术线,它是在源代码层面做等价变换,把代码体积降下来,而不是把你整个文件夹打个包。
打个比方:原代码是一封写得很详细、有抬头空两格、还有大量客套话的信件;压缩后的代码是一封电报,只保留对方必须知道的内容。浏览器要的是电报的内容,至于你信里用了多少修辞和排版,它根本不关心。
代码压缩和 gzip 压缩也经常被混为一谈,但它们工作在完全不同的层级。Terser 这类工具做的是"代码层面的最小化",把变量名veryLongVariableName变成a,把1 + 2直接折叠成3,去掉所有换行和缩进。gzip 则是在 HTTP 传输层,对已经压缩过的字节流再做一次通用压缩。真正线上的最佳实践是两者配合:先用 Terser 压出最小体积的 JS,再让服务器开启 gzip 或 Brotli。这样叠加起来,体积能压到非常夸张的程度。
那么 JS 为什么要压缩?因为浏览器要先下载脚本,再进行解析和执行。体积小了,网络传输时间缩短,尤其移动端弱网环境下收益极其明显。一个首屏 JS 从 300KB 降到 80KB,用户感知就是转圈时间少了一截。这还没算解析消耗的 CPU,代码越小,解析也越快。
1.2 Terser 的核心三步:去空白、缩短变量名、改写逻辑
Terser 干的事情可以拆成三个层面来理解。
第一步,也是最基础的:删除注释、空白字符、换行和缩进。这一步没有技术含量,但收益很直观。一个格式良好的源码文件,去掉这些"视觉辅助"之后往往就能减少 30% 左右的体积。
第二步,叫 mangle,也就是变量名缩短。比如源码里写:
let totalPrice = 100; let discountRate = 0.8; let finalPrice = totalPrice * discountRate; console.log(finalPrice);mangle 之后可能变成:
let a=100,b=.8,c=a*b;console.log(c);这里要特别强调:mangle 只对函数内部的局部变量、参数名这些"私人名字"做缩短。如果一个变量是挂在window上的全局变量,或者是模块导出的对外接口,名字就不能随便改,否则别的文件引用时就找不到了。这也是 mangle 最容易踩坑的地方,后面我会单独讲。
第三步,叫 compress,这是 Terser 真正体现水平的地方。它会对代码做更复杂的等价改写,常见手段包括:常量折叠(1 + 2直接算成3)、删除未使用变量、删除永远执行不到的代码分支、合并重复的逻辑、把if (condition) { return a; } else { return b; }改写为三元表达式,等等。如果配合passes参数,Terser 可以像打磨一样多轮压缩,每轮都在上一轮结果的基础上继续找可优化点。
这三个步骤叠加,才是 Terser 真正的威力所在。你不需要每一条都知道具体原理,但理解了这个逻辑,后面调参数时就不会一脸懵。
2. Terser 凭什么成为主流:从 UglifyJS 到 Terser 的来龙去脉
2.1 为什么不用 UglifyJS
在 Terser 之前,JavaScript 压缩领域的老大哥是 UglifyJS。早些年做前端构建,基本离不开它。但 UglifyJS 有个非常要命的问题:它对 ES6+ 新语法的支持一直跟不上。很长一段时间里,你要压缩带箭头函数、const、模板字符串的代码,得先用 Babel 把 ES6 转成 ES5,然后再丢给 UglifyJS 压缩。
这条路非常膈应人。首先是构建链路变长,多了一个环节就多一分出错风险。其次是 Babel 转译出来的 ES5 代码本身就是膨胀过的,比如箭头函数会被改写成普通函数,类会被改写成原型链写法,代码量反而变大,再压也压不回原来的水平。等于你先给它增肥,再拼命减肥,纯属折腾。
后来社区出现了一个叫 uglify-es 的分支,试图给 UglifyJS 加上 ES6 支持。刚开始确实看到了希望,很多人都在用。但遗憾的是,这个项目更新到某个版本之后基本就停更了。新语法层出不穷,一个不活跃的工具很难继续扛起重任。在这种背景之下,社区从 uglify-es fork 出了 Terser,专门处理和适配现代 JavaScript 语法,慢慢地就成了事实标准。
2.2 Terser 的优势:ES6+ 支持、活跃维护、生态广泛
Terser 最核心的优势就是原生支持 ES6+ 语法解析。你不需要提前转译,直接丢给它压就行。对于现代前端项目来说,这省了太多事。
另一个优势是生态。webpack 5 内置的压缩方案是terser-webpack-plugin,Vite 可以切到 Terser 做压缩,Rollup 也有官方维护的@rollup/plugin-terser。也就是说,不管你的项目是哪个构建工具在一线,Terser 几乎都是"标配"或者"一键可选"的方案。这种生态地位让它可以被持续维护,任何新语法问题都能比较快地修复。
有朋友会问:那现在不是有 esbuild、SWC 这类更快的新工具吗?它们确实快,压缩也做得不错,但压缩率通常不如 Terser 激进。我做过一个对比,同一份代码用 Terser 默认参数压出来,比 esbuild 压出来还要再小 5% 到 10%。Terser 是纯 JavaScript 实现的,速度上不如原生编译工具,但优势是跨平台、稳定、可嵌入任何 Node.js 脚本。
所以我的建议是:追求极速构建、对产物体积不是特别敏感的项目,可以用 esbuild;但如果你的目标是把产物做到尽可能小,并且希望压缩行为高度可配置、可预测,Terser 依然是更稳的选择。
| 工具 | ES6+ 支持 | 压缩率 | 压缩速度 | 生态成熟度 | 典型使用场景 |
|---|---|---|---|---|---|
| UglifyJS | 需要先转译 | 一般 | 一般 | 基本停止维护 | 老项目遗留 |
| Terser | 原生支持 | 高 | 中 | 非常成熟 | webpack/Vite/Rollup/Node 脚本 |
| esbuild | 原生支持 | 中上 | 极快 | 较成熟 | 追求构建速度 |
| SWC | 原生支持 | 中上 | 极快 | 较成熟 | Rust 生态工具链 |
3. 动手前的准备工作:Node.js 环境安装与 Terser 安装方式
3.1 检查 Node.js 和 npm 是否就绪
Terser 是一个基于 Node.js 的命令行工具和库。所以第一步是确认电脑上有没有 Node.js 环境。打开终端(macOS 的 Terminal、Windows 的 PowerShell 或 CMD),输入下面两个命令:
node -v npm -v如果显示出版本号,比如v20.11.0和10.2.4,说明环境没问题,可以直接跳到下一步。如果提示"node 不是内部或外部命令"这类报错,说明 Node.js 还没装好,或者没有加入系统 PATH。解决办法很简单,去 Node.js 官网下载 LTS 版本,一路默认安装。Windows 用户安装完记得重新开一个终端窗口,有时候 PATH 环境变量不会立即刷新。
3.2 全局安装还是项目安装:我建议这样选
Node.js 就绪之后,安装 Terser 有两种主流方式。
第一种是全局安装:
npm install -g terser这种方式的优点是方便,任何目录下都能直接用terser命令。适合那种"我就想偶尔压一下某个 JS 文件"的场景。缺点也很明显:全局装的版本容易和某个项目里依赖的版本不一致,导致行为差异。前端项目的依赖管理本来就强调可复现,全局拟合容易出问题。
第二种是项目内安装:
npm install -D terser-D表示作为开发依赖安装,因为压缩是在构建阶段做的,线上运行时不需要。这种方式配合package.json的 scripts 使用非常舒服。甚至如果你是配合 webpack、Vite 使用,压根不需要手动装,因为构建工具内部会依赖它,你只需要保证最终用的版本符合预期就行。
我个人更推荐的做法:工程项目里始终用项目内安装;偶尔命令行临时压文件,可以用npx terser,不需要全局装,后面我会详细说npx的用法。这样既控制了版本,又不会污染全局环境。
4. 命令行实战:一条命令压缩一个 JS 文件
4.1 第一个压缩命令
假设你已经准备好了一个src/index.js文件,里面是一些格式良好的 JavaScript 代码。最简单的压缩命令如下:
terser src/index.js -o dist/index.min.js-o是 output 的缩写,后面跟输出文件路径。执行完打开dist/index.min.js,你会看到整个文件变成了一行或者几行极长的代码,所有换行和缩进全没了。
这里有个小白最容易踩的坑:如果你不写-o,Terser 会把压缩结果直接打印到终端标准输出。终端会刷出一大段密不透风的字符,看着就像"出 bug 了"。其实没出问题,只是输出方向不对。记住,要保存到文件就必须用-o指定路径。
4.2 高频参数逐个说
第一条命令其实只做了"去空白、去注释"这一步,还远远没有发挥出 Terser 的全部能力。真正生产环境,至少要加上-c和-m:
terser src/index.js -c -m -o dist/index.min.js-c是--compress的缩写,启用代码压缩优化。-m是--mangle的缩写,启用变量名缩短。
就这两个参数,基本就能带来最明显的压缩效果。
再往下,还有几个参数非常实用:
--toplevel:允许 mangle 缩短顶层变量名。如果你的 JS 文件是独立脚本,里面所有变量都是"自己用",可以开启它。但如果你的文件是一个模块,或者会在window上挂一些全局变量供其他脚本调用,千万不要随意开,开了之后外部引用很可能就断了。--comments:控制注释保留策略。默认情况下,Terser 只保留以/*!开头的注释,这通常是为了保留版权等关键信息。如果想把注释全部删干净,可以加--comments false;如果想保留@license或@preserve标记的注释,可以传正则,例如--comments '/@license|@preserve/'。--source-map:生成 source map 文件,方便压缩后调试定位。后面第七部分我会重点说这个。
一个比较综合的命令示例:
terser src/index.js -c passes=2 -m --toplevel --comments '/License/' --source-map "filename='index.min.js.map',url='index.min.js.map'" -o dist/index.min.js这条命令做了两轮 compress、启用了顶层 mangle、只保留包含License的注释,并输出了 source map。参数看起来多,但每个都是有用功。
4.3 npx 用法:不全局安装也能用
如果你不想全局安装 Terser,又想在项目目录里快速压一个文件,用npx是最省事的:
npx terser src/index.js -c -m -o dist/index.min.jsnpx会先看当前项目里有没有安装 terser,没有的话会临时下载并执行,不会永久占用全局环境。第一次运行时会提示你是否安装,输入y就好。实测下来,npx这种方式非常适合临时快速处理文件,尤其是换了一台新电脑、还没来得及配环境的时候。
5. 更进阶的玩法:在 Node.js 脚本和构建工具里集成 Terser
5.1 在 Node.js 中调用 Terser API
命令行适合单文件快速处理,但真实项目往往需要把压缩嵌入自动化流程里。这时候就要用 Terser 的编程接口。Terser 的核心 API 是minify函数,它接收代码字符串和配置对象,返回一个 Promise,解析后得到压缩代码和 source map。
下面是一个完整的 Node.js 脚本示例,读取源码目录的index.js,压缩后写入dist目录,同时生成 source map:
const { minify } = require('terser'); const fs = require('fs'); const path = require('path'); async function build() { const inputPath = path.join(__dirname, 'src', 'index.js'); const outputPath = path.join(__dirname, 'dist', 'index.min.js'); const code = fs.readFileSync(inputPath, 'utf8'); const result = await minify(code, { compress: { passes: 2, drop_console: true, }, mangle: true, sourceMap: { filename: 'index.min.js', url: 'index.min.js.map', }, }); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, result.code); if (result.map) { fs.writeFileSync(`${outputPath}.map`, result.map); } console.log('压缩完成'); } build().catch((err) => { console.error('压缩失败', err); process.exit(1); });这段脚本里,compress.passes设置为 2,表示做两轮压缩。drop_console为true,表示把console.log这类调试语句删掉。在 Node.js 脚本里调用 Terser 的另一个好处是,你可以拼接多个文件、做条件处理、动态设置参数,非常灵活。
Terser 的minify返回结果里有两个关键字段:code是压缩后的代码,map是 source map 的字符串内容。如果配置里没开 sourceMap,map会是undefined,写文件之前要做好判断。
5.2 集成到 webpack
如果你用的是 webpack,生产环境默认就会用 Terser 压缩。webpack 5 里内置了terser-webpack-plugin,但很多项目还会主动安装它,为的是自定义压缩参数。
先安装:
npm install -D terser-webpack-plugin然后在webpack.config.js里配置:
const TerserPlugin = require('terser-webpack-plugin'); module.exports = { mode: 'production', optimization: { minimize: true, minimizer: [ new TerserPlugin({ terserOptions: { compress: { drop_console: true, passes: 2, }, mangle: true, }, extractComments: true, }), ], }, };extractComments: true会把/*!和@license之类的注释提取到单独的.LICENSE.txt文件里,而不是混在 JS 里,这样既保住了版权信息,又不影响压缩体积。很多团队为了应付开源协议检查,都会在压缩配置里加上这一项。
5.3 集成到 Vite 和 Rollup
Vite 在默认情况下用的是 esbuild 做压缩,速度飞快。但如果你想要更极致的压缩率,Vite 也支持切换到 Terser。在vite.config.js里配置:
export default { build: { minify: 'terser', terserOptions: { compress: { passes: 2, }, mangle: true, }, }, };Rollup 则需要安装@rollup/plugin-terser:
npm install -D @rollup/plugin-terser然后在rollup.config.js里使用:
import terser from '@rollup/plugin-terser'; export default { input: 'src/index.js', output: { file: 'dist/index.min.js', format: 'iife', }, plugins: [terser()], };@rollup/plugin-terser底层调用的是同一个 Terser 核心,所以配置语义完全一致。因为 Rollup 通常用来打包库,这时候要特别注意mangle会不会破坏对外导出,必要时配置mangle.reserved保留名字。
6. 压缩参数调优经验:如何在体积和兼容性之间找平衡
6.1 compress 选项里值得关注的开关
Terser 的compress选项有几十个,但对大多数人来说,真正值得调整的就那几个。
第一个是passes。默认值是 1,也就是只做一轮压缩。设置成2或3之后,Terser 会在上一轮压缩结果的基础上继续优化。我实测过不少文件,passes: 2通常能再压掉 2% 到 5% 的体积,passes: 3收益就会明显递减,但构建时间会成倍增加。所以一般passes: 2是性价比最高的选择。
第二个是drop_console。是否删除所有console.log、console.warn、console.error。这个很多人会直接设成true,但我建议你根据团队场景想清楚。如果线上遇到问题时需要远程查看console输出,删掉之后所有日志都没了,排查会变得非常困难。更稳健的写法是只删console.log,保留console.error和console.warn:
compress: { drop_console: false, pure_funcs: ['console.log'], }pure_funcs表示"标记为无副作用的函数",Terser 在认为调用结果没有被使用且没有副作用时,会把这种调用删掉。用pure_funcs: ['console.log']就可以只删console.log而不动其他 console 方法。
第三个是drop_debugger。默认就是true,会把debugger语句删掉,这个一般不用管。
第四个是ecma。它控制 Terser 输出的语法版本。比如设置ecma: 5,输出会尽量避免生成箭头函数之类的 ES6 语法,但注意 Terser 不是转译器,它不会帮你做完整的降级转换,极端情况还是需要 Babel 先转一遍。
6.2 mangle 选项的正确姿势
mangle的核心问题就一个:哪些名字不能改。
默认情况下,Terser 只缩短函数内部的局部变量名。这个策略很安全,因为局部变量作用域不跨文件,改成什么样都无所谓。但遇到以下情况,你就要考虑配置reserved:
- 你的代码是多个 JS 文件靠
window.xxx联动,外部文件通过全局变量引用。 - 你的代码用到了第三方全局变量,虽然没声明,但通过字符串名称访问。
- 项目里用了
eval或new Function,字符串拼接的代码里引用了变量名。
配置方式:
mangle: { reserved: ['jQuery', '$', 'myGlobal'], }还有一个mangle.properties选项,开启后会尝试把对象属性名也缩短,比如user.name变成a.b。这个选项风险极高,因为属性名经常通过obj[key]这种动态方式访问,Terser 无法准确判断。我强烈建议新手直接关掉,不要碰。
6.3 一个真实文件的压缩前后数据
用一段实际代码做个演示对比更直观。我取了一段没有经过任何优化、约 15KB 的源码文件,依次处理,得到下面的数据:
| 处理方式 | 文件体积 | 说明 |
|---|---|---|
| 原始源码 | 15.2 KB | 含注释、空行、完整可读 |
| 仅去空格和注释 | 9.1 KB | 基本没变化,只少了一部分字符 |
| 开启 -c -m | 4.3 KB | 变量名缩短 + 逻辑优化,效果显著 |
| 继续 gzip | 1.4 KB | 服务器开启 gzip 后的传输体积 |
可以看出,从 15.2KB 降到 4.3KB,是 Terser 的主要功劳;再叠加 gzip,只有 1.4KB,几乎是原始的十分之一。这也是为什么我经常说:代码压缩永远不要只依赖 gzip,先让 Terser 把该减的肉减掉,再交给传输层去二次瘦身。
7. 压缩踩坑实录:这些坑我替你踩过了
7.1 压缩后代码报错,先从 source map 入手
压缩后的代码若出问题,报错信息基本只有一行超长字符串,根本没法定位。这时候最实用的办法是开启 source map。开启之后,浏览器 DevTools 会自动解析.map文件,让你在 Sources 面板里看到原始源码,断点、报错位置都能映射回真实行号。
在 webpack 里开启:
module.exports = { devtool: 'source-map', };如果使用 JavaScript API,就在minify的配置里加sourceMap: true,然后把result.map写到.map文件里。注意,服务器部署时要确保.map文件不会被公开访问到,否则别人能反推出你的完整源码。通常建议 source map 只在内网或者可控环境使用,线上生产环境除非有专门的前端监控系统,否则不上传。
7.2 版权注释和特殊注释被删掉
Terser 默认只保留/*!开头的注释。如果你的注释用的是@license或者// @preserve这种形式,默认策略下很可能被直接删除。处理方式是用comments配置指定保留规则:
minify(code, { format: { comments: /@license|@preserve|Copyright/i, }, });同时,terser-webpack-plugin里的extractComments也可以把这些注释提取到独立文件中。注意,如果不用format.comments而是用--comments命令行参数,正则的写法是--comments '/@license|@preserve|Copyright/i',别漏掉斜杠。
7.3 eval 和 new Function 会破坏 mangle
这是我在实际业务代码里踩过最深的一次坑。某次压缩后页面白屏,排查半天,最后发现代码里有一段动态创建函数的逻辑,函数体是通过字符串拼接出来的,里面引用了外部变量名。Terser 在 mangle 时把外部变量从config改成了a,但字符串里的名字没跟着变,运行时自然就ReferenceError了。
解决方案有两种:一是把动态代码涉及的变量名加入reserved,强制不缩短;二是改写代码,尽量避免用eval和new Function。现代业务代码里真的很少需要这两种写法,能避开就避开。如果你在维护老项目,压缩前一定要全局搜一下eval(和new Function,提前处理。
7.4 压缩不能替代转译:老浏览器兼容不要指望 Terser
Terser 是一个压缩器,不是一个转译器。它不会帮你把 ES6 的class、箭头函数转成 ES5,也不会给Promise打 polyfill。如果你要兼容 IE 或者老版本浏览器,正确顺序是:先用 Babel 把代码转译成目标浏览器能识别的 ES5,再进行压缩。顺序反了会出各种莫名其妙的问题。
更隐蔽的一个场景是:团队里有人写了一些非严格模式下的老旧代码,比如隐式全局变量、with语句,压缩时 Terser 可能会报语法错误或行为异常。遇到这种情况,优先考虑把代码重构掉,而不是试图通过压缩参数去兼容。压缩工具默认假设你写的是规范、可解析的现代 JavaScript。
8. 用一个小案例看 Terser 的完整输出:从源码到产物
8.1 准备一段示例代码
为了让你直观看到效果,我准备了一段简单的源码:
// 计算商品折扣价 function getDiscountPrice(price, discount, isVip) { const minDiscount = 0.5; if (discount < minDiscount) { discount = minDiscount; } if (isVip) { discount = discount - 0.1; } return price * discount; } function printPrice(price, discount, isVip) { const result = getDiscountPrice(price, discount, isVip); console.log("final price is: " + result); } printPrice(100, 0.3, true);代码功能非常简单:计算折扣价,然后打印结果。
8.2 执行压缩并展示输出
执行命令:
terser src/index.js -c -m -o dist/index.min.js得到的压缩结果类似这样:
function getDiscountPrice(e,t,r){const n=.5;t<n&&(t=n),r&&(t-=.1);return e*t}function printPrice(e,t,r){const n=getDiscountPrice(e,t,r);console.log("final price is: "+n)}printPrice(100,.3,!0);对比一下:源码里十几个变量名全部变成了单字母;if (discount < minDiscount)这类的条件改写成t<n&&(t=n);注释和空格全部消失;整个文件变成一行。功能完全一样,可读性基本为 0。
如果你加上了passes=2之类的参数,Terser 可能还会进一步优化,不过对于这种小函数,能压的空间已经不大。实际问题里,代码量越大、重复逻辑越多,压缩收效越明显。
8.3 source map 能用在哪里
在浏览器里直接加载上面这种压缩产物,一旦运行时报错,控制台只会指向index.min.js:1:xxx。而如果配合 source map,DevTools 就能帮你还原到原始文件的具体行号,定位效率完全不一样。
实际项目里我建议给开发环境的 webpack 开启devtool: 'eval-source-map',保证日常调试时能映射到源码;生产环境看情况开hidden-source-map,让报错信息能映射但不会在 Sources 面板里直接暴露源码。如果你没有专门的前端监控平台,生产环境不部署 source map 也是一种常见选择,毕竟源码暴露的风险还是要权衡的。
我工作里最常用的姿势,其实是写一个简单的 Node 脚本,把 Terser 的minify接口包一层,专门用来处理构建产物或者临时文件。这样既能统一压缩参数,又能在压缩前后打印体积对比,碰到异常还能把原始输入缓存下来,排查方便很多。你刚入门时可能用命令行就够了,但等你真的开始维护一个有点规模的项目,我强烈建议把这套脚本整理出来,它会成为你日常开发里一个非常顺手的小工具。