我第一次用 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 a100,b.8,ca*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-pluginVite 可以切到 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你会看到整个文件变成了一行或者几行极长的代码所有换行和缩进全没了。这里有个小白最容易踩的坑如果你不写-oTerser 会把压缩结果直接打印到终端标准输出。终端会刷出一大段密不透风的字符看着就像出 bug 了。其实没出问题只是输出方向不对。记住要保存到文件就必须用-o指定路径。4.2 高频参数逐个说第一条命令其实只做了去空白、去注释这一步还远远没有发挥出 Terser 的全部能力。真正生产环境至少要加上-c和-mterser 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 passes2 -m --toplevel --comments /License/ --source-map filenameindex.min.js.map,urlindex.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 脚本和构建工具里集成 Terser5.1 在 Node.js 中调用 Terser API命令行适合单文件快速处理但真实项目往往需要把压缩嵌入自动化流程里。这时候就要用 Terser 的编程接口。Terser 的核心 API 是minify函数它接收代码字符串和配置对象返回一个 Promise解析后得到压缩代码和 source map。下面是一个完整的 Node.js 脚本示例读取源码目录的index.js压缩后写入dist目录同时生成 source mapconst { 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 的字符串内容。如果配置里没开 sourceMapmap会是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 和 RollupVite 在默认情况下用的是 esbuild 做压缩速度飞快。但如果你想要更极致的压缩率Vite 也支持切换到 Terser。在vite.config.js里配置export default { build: { minify: terser, terserOptions: { compress: { passes: 2, }, mangle: true, }, }, };Rollup 则需要安装rollup/plugin-tersernpm 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.warncompress: { 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 -m4.3 KB变量名缩短 逻辑优化效果显著继续 gzip1.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 压缩不能替代转译老浏览器兼容不要指望 TerserTerser 是一个压缩器不是一个转译器。它不会帮你把 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;tn(tn),r(t-.1);return e*t}function printPrice(e,t,r){const ngetDiscountPrice(e,t,r);console.log(final price is: n)}printPrice(100,.3,!0);对比一下源码里十几个变量名全部变成了单字母if (discount minDiscount)这类的条件改写成tn(tn)注释和空格全部消失整个文件变成一行。功能完全一样可读性基本为 0。如果你加上了passes2之类的参数Terser 可能还会进一步优化不过对于这种小函数能压的空间已经不大。实际问题里代码量越大、重复逻辑越多压缩收效越明显。8.3 source map 能用在哪里在浏览器里直接加载上面这种压缩产物一旦运行时报错控制台只会指向index.min.js:1:xxx。而如果配合 source mapDevTools 就能帮你还原到原始文件的具体行号定位效率完全不一样。实际项目里我建议给开发环境的 webpack 开启devtool: eval-source-map保证日常调试时能映射到源码生产环境看情况开hidden-source-map让报错信息能映射但不会在 Sources 面板里直接暴露源码。如果你没有专门的前端监控平台生产环境不部署 source map 也是一种常见选择毕竟源码暴露的风险还是要权衡的。我工作里最常用的姿势其实是写一个简单的 Node 脚本把 Terser 的minify接口包一层专门用来处理构建产物或者临时文件。这样既能统一压缩参数又能在压缩前后打印体积对比碰到异常还能把原始输入缓存下来排查方便很多。你刚入门时可能用命令行就够了但等你真的开始维护一个有点规模的项目我强烈建议把这套脚本整理出来它会成为你日常开发里一个非常顺手的小工具。
