为什么 drop: 'console' 删不掉 legacy 产物里的 console
前言
本文记录一次 Vite 构建产物排查:配置了 esbuild: { drop: ['console'] },现代产物里的 console 被删得干干净净,legacy 产物却几乎原样保留。适合正在使用 Vite + @vitejs/plugin-legacy 的前端开发者,前置知识只需了解"构建产物分现代/legacy 两套"这一基本概念。
适用版本 :Vite 7.x(实测 7.3.6)+ @vitejs/plugin-legacy 7.x(实测 7.2.1)+ esbuild 0.28.2 + terser 5.51.2。⚠️ Vite 8 起顶层 esbuild 选项已标记 Deprecated(内部转 oxc 选项),配置写法需相应调整,本文机制部分的结论以 Vite 7.x 实测为准。
目录
- 复现环境
- 一、现象:同一个构建,两套产物表现不一致
- [二、先排除误判:esbuild 没有方法白名单](#二、先排除误判:esbuild 没有方法白名单)
- [三、分界线:现代产物与 legacy 产物走两条压缩管线](#三、分界线:现代产物与 legacy 产物走两条压缩管线)
- [四、补充确认:drop 究竟在哪一步生效](#四、补充确认:drop 究竟在哪一步生效)
- [五、修复:给 legacy 侧补上 drop_console](#五、修复:给 legacy 侧补上 drop_console)
- 六、验证:如何确认真的生效(含一个假阴性陷阱)
- 七、仍未覆盖的边界
- 八、小结
- 参考链接
复现环境
排查所用的环境与配置如下,读者可对照复现:
| 组件 | 版本 |
|---|---|
| Node.js | 20.x |
| Vite | 7.3.6 |
| @vitejs/plugin-legacy | 7.2.1 |
| esbuild | 0.28.2 |
| terser | 5.51.2(显式安装,非 hoisting) |
与本文相关的 vite.config.js 片段(改造前状态,省略无关项):
js
// vite.config.js
export default defineConfig(({ mode }) => ({
plugins: [
vue(),
legacy({
additionalLegacyPolyfills: ['regenerator-runtime/runtime'],
}),
],
build: {
minify: mode === 'production' ? 'esbuild' : false,
},
// esbuild 配置(独立于 build.minify,即使不压缩也会生效)
esbuild: mode === 'production' ? { drop: ['console'] } : {},
}))
构建命令与产物结构:
bash
npm run build # 即 vite build,默认 mode=production
ls dist/assets/ # 同名模块会有两份:index-<hash>.js 与 index-legacy-<hash>.js
一、现象:同一个构建,两套产物表现不一致
项目启用了 @vitejs/plugin-legacy,一次生产构建后检查 dist/assets/:
| 产物 | console.log |
console.table |
|---|---|---|
现代 chunk(index-<hash>.js 等) |
0 | 0 |
legacy chunk(*-legacy-<hash>.js) |
全部保留 | 全部保留 |
第一直觉容易得出错误结论------"console.log 删掉了,但 console.table 没删,是不是 esbuild 对某些方法网开一面?"
自查看似支持这个结论:在 legacy 入口 chunk 里确实能搜到 console.table,而现代入口里连 console. 这个字符串都搜不到。
但这是跨两套产物对比造成的错觉:现代产物里 log 和 table 是一起被删的,legacy 产物里 log 和 table 是一起被留下的。真正的问题不是"哪个方法没删掉",而是"哪套产物没走删除流程"。
自查时务必分别统计两套产物,而不是混在一起搜:
bash
# 分别统计现代与 legacy 产物中的残留(用通配符,不要写 <hash>,原因见第六节)
grep -c "console\." dist/assets/index-*.js | grep -v "\-legacy\-"
grep -c "console\." dist/assets/*-legacy-*.js
若两条命令一条为 0、一条非 0,说明问题出在"产物分叉"而非"方法差异",可直接跳到第三节。
产物属于哪一套,认文件名即可:
| 产物形态 | 归属 | 加载条件 |
|---|---|---|
index-<hash>.js |
现代产物 | 支持原生 ESM 的浏览器(<script type="module">) |
index-legacy-<hash>.js |
legacy 产物 | 旧浏览器(<script nomodule>,由 SystemJS 加载) |
两套产物由同一次构建生成,文件名主体一致、以 -legacy- 区分,这是排查时快速定位的依据。
二、先排除误判:esbuild 没有方法白名单
用项目自带的 esbuild 直接验证 --drop:console 的行为(0.28.2):
bash
printf 'console.log(1);\nconsole.table([{a:1}]);\nconsole.foo(1);\nconsole["table"]([{a:2}]);\nconsole?.table([{a:3}]);\nwindow.console.log(2);\nvar c = console; c.log(3);\n' \
| ./node_modules/.bin/esbuild --drop:console
txt
window.console.log(2);
var c = console;
c.log(3);
结论一目了然:
| 写法 | drop: ['console'] 是否删除 |
|---|---|
console.log(...) / console.table(...) / 任意 console.xxx(...) |
✅ 删除 |
console["table"](...) / console?.table(...) |
✅ 删除 |
window.console.log(...) |
❌ 保留 |
别名引用(const c = console; c.log(...)) |
❌ 保留 |
esbuild 删除的是"对全局 console 对象的直接方法调用",且没有任何方法白名单 ------不认识的 console.foo() 一样删。所以 console.table 残留与 esbuild 的匹配规则无关。
另一个相关误判是"能不能只删掉某几个方法"。答案是不能------drop 只接受两个合法取值,实测传方法名会直接报错:
bash
printf 'console.log(1);console.error(2);' | ./node_modules/.bin/esbuild --drop:console.log
txt
X [ERROR] Invalid value "console.log" in "--drop:console.log"
Valid values are "console" or "debugger".
即 drop: ['console'] 的粒度就是"全删",没有 console.log 与 console.error 的区别对待。
三、分界线:现代产物与 legacy 产物走两条压缩管线
@vitejs/plugin-legacy 的作用是给不支持原生 ESM 动态导入的旧浏览器兜底。这些浏览器既不认 import() 也不认 <script type="module">,插件必须另给一套加载方案:用 SystemJS 作为模块加载器,配合 <script nomodule> 让旧浏览器加载 SystemJS 格式的产物。
这就要求 legacy 产物先被转换成 System.register(...) 格式,而这件事只能交给 Babel------esbuild 的 format 只有三种取值,均不是 SystemJS 格式:
iife(Immediately Invoked Function Expression,立即调用函数表达式):把整个模块包进一个立即执行的函数里,代码运行在私有作用域中、不污染全局,常见于用<script>直接引入的独立脚本或库cjs(CommonJS):Node.js 的传统模块格式,用require()/module.exports组织模块esm(ECMAScript Modules):浏览器原生模块格式,用import/export组织模块
System.register(...) 是 SystemJS 加载器专用的模块注册格式,也正是旧浏览器链路所需要的。这是 legacy 链路必须绕开 esbuild 的根本原因,console 删不掉只是它的连带后果。
实现上,插件在同一次构建里追加第二套输出 (format: 'system',产物名带 -legacy-),并在产物生成阶段向 Vite 声明两件事:
- 跳过 esbuild 处理------这套产物的语法降级由 Babel 负责,不需要 esbuild 再插手;
- 改用 terser 压缩------即使项目配置的压缩器是 esbuild,这套产物也交给 terser。
Vite 对这两个声明的响应,决定了 legacy 产物的命运:
txt
现代产物管线:
打包 → esbuild 处理(语法降级 + 压缩,携带项目 esbuild 配置里的 drop)
→ 完成(console 被删)
legacy 产物管线:
打包 → Babel 转译(降级到 ES5 + 包装成 SystemJS 格式)
→ 跳过 esbuild 处理
→ 由 terser 压缩(默认选项,未开启 drop_console)
→ 完成(console 全部保留)
把两条链路的三个环节拆开对比,差异一目了然:
| 环节 | 现代产物 | legacy 产物 |
|---|---|---|
| 语法转换 | esbuild | Babel(@babel/preset-env) |
| 压缩器 | esbuild(读取项目 esbuild 配置) |
terser(读取 build.terserOptions) |
项目 esbuild 配置 |
整体生效 | 被插件主动跳过 |
这三件事串起来就是:
- esbuild 处理环节被明确跳过 ,所以挂在项目
esbuild配置上的drop对 legacy 产物完全无效; - terser 压缩环节被明确指定,而它的默认参数里没有删除 console 的选项,所以 console 原样保留;
- 中间的 Babel 只负责语法降级与格式包装,它不认识 esbuild 的
drop选项。
也就是说:顶端配置的 esbuild.drop 只覆盖现代产物,legacy 产物从语法转换、压缩器到插件参数整条链路都换了一套 。同理,任何"配置在顶层 esbuild 里、指望 legacy 也跟着变"的优化(如定制 target、pure 标注)都有同样的失效风险。
四、补充确认:drop 究竟在哪一步生效
这里还有一个容易误解的点:drop 是在哪一步删除 console 的?
对普通 .js 文件和 .vue 组件的脚本来说,删除发生在打包完成后的处理阶段 ,而不是转译源码时。代码被打包成一个个 chunk 之后,Vite 会把 config.esbuild 里的选项(包括 drop)一并交给这个环节处理。
这也解释了配置里那句注释为什么成立:压缩确实由 build.minify 控制,但 drop 不是压缩的专属开关------它搭乘的是同一个 esbuild 处理环节,而这个环节同时负责"语法降级"和"压缩"两件事。只要其中一件需要做,环节就会运行:
- 默认情况:Vite 按一组具体的浏览器目标做语法降级 → 环节运行,因此即使关掉压缩(
minify: false),drop照样生效 - 例外情况:构建目标设成
esnext(无需降级)并且 关闭压缩 → 两件事都不需要做,环节被整体跳过,drop随之失效
⚠️ 一句话总结:drop 依赖"这个处理环节会被调用",而不是"压缩被开启" 。默认配置下它总会运行;只有"目标 esnext"与"关闭压缩"同时出现时才会被跳过。
五、修复:给 legacy 侧补上 drop_console
备选方案对比
删除 legacy 产物的 console,可选路径不止一条:
| 方案 | 做法 | 优点 | 代价 |
|---|---|---|---|
① 补 terserOptions(本文方案) |
build.terserOptions.compress.drop_console |
一行配置、走官方支持路径、天然只影响 legacy 产物 | 只能全删,无方法粒度 |
| ② 自定义构建插件 | 在产物生成后对 legacy 产物做代码删除 | 两侧策略可统一、粒度完全可控 | 需自行处理 sourcemap;纯正则实现会误伤字符串字面量中的 console.log;要跟随构建管线演进维护 |
| ③ Babel 插件 | 引入 babel-plugin-transform-remove-console |
生态成熟、可按方法粒度配置 | @vitejs/plugin-legacy 没有提供注入 Babel 插件的配置项,其内部配置固定,对 legacy 链路无效 |
| ④ 不处理 | 接受 legacy 侧保留 console | 保留旧浏览器上的现场诊断能力 | 与"生产环境清理日志"的目标冲突,也可能泄露内部信息 |
选择 ① 的理由很直接:legacy 的压缩器本来就是 terser,直接用它的原生选项即可,不必自己写代码去处理产物;改动面最小,也不会误伤其他环节。
落地配置
既然 legacy 产物由 terser 压缩,而 Vite 的 terser 默认选项不含删除 console,只需补一条 build.terserOptions:
diff
// vite.config.js
build: {
minify: 'esbuild',
+ // legacy 产物被 plugin-legacy 强制走 terser(跳过了 esbuild 的 drop),需单独配置
+ terserOptions: {
+ compress: { drop_console: true },
+ },
},
为什么这一条只影响 legacy 产物 :terser 只有在"本项目开启了压缩"且"这段产物被指定交给 terser"时才会介入。本项目 minify 是 'esbuild',现代产物交给 esbuild、不会走 terser,所以这项配置对它们不产生任何影响。
为什么开发模式不会误删:需要分两套产物来看。
| 产物 | 受哪个配置控制 | 非生产构建时为何不删 |
|---|---|---|
| legacy | build.minify(开关)+ build.terserOptions(参数) |
minify: false → 压缩环节整体跳过 |
| 现代 | esbuild 那行配置(drop 本身) |
esbuild 对象为空 → drop 未传入 |
补充两点容易被误解的地方:
- legacy 产物不读
esbuild那行配置------它在进入 esbuild 环节之前就被分流出去了。配置里minify虽然写的是'esbuild',但对 legacy 产物来说只用来判断"压缩开没开",开了就由 terser 执行,具体用哪个压缩器与它无关。 - 开发模式 (
npm run serve)不产出构建产物,以上两套逻辑都不涉及。
语义是否对齐 :对齐。两者都是删除整个调用表达式(含实参求值),实测 console.log(foo()) 经 --drop:console 后连 foo() 都不保留,terser 官方文档也注明 drop_console 不保留实参副作用(需要保留时应改用 pure_funcs)。
两侧真正的差异只有一处:terser 的 drop_console 支持传入方法名数组做精细化控制,而 esbuild 只能全删。若要"只删 log 保留 error",需要两侧分别配置:
diff
// vite.config.js ------ 只删 log/table,保留 error
build: {
terserOptions: {
- compress: { drop_console: true },
+ // terser 侧:传方法名数组
+ compress: { drop_console: ['log', 'table'] },
},
},
现代侧则不能靠 esbuild.drop 实现(它没有方法粒度),需要在构建管线里加一个 Babel 插件(如 babel-plugin-transform-remove-console)或自定义构建插件处理。若两侧方案不一致,会出现"现代产物只删了 log、legacy 产物全删"的新偏差,配置时务必成对检查。
六、验证:如何确认真的生效(含一个假阴性陷阱)
构建后用 grep 类工具核对产物,改造前后的实测数据:
| 检查项 | 改造前 | 改造后 |
|---|---|---|
legacy 应用 chunk 的 console.log / console.table |
70 处 / 2 处 | 0 / 0 |
| 现代 chunk | 0 | 0(无回归) |
.gz 产物抽样解压核对 |
含 log | 0 |
完整的核对分三步:统计残留 → 确认统计范围有效 → 抽查压缩产物 。命令用 rg(ripgrep,grep 的快速替代)执行。
bash
# 第 1 步:列出仍含 console.log / console.table 的产物文件,并给出各自次数
rg --no-ignore -c "console\.(log|table)" -g "*.js" dist/assets/
# 第 2 步:数一下实际扫到了多少个文件
# 若结果为 0,说明第 1 步的"没搜到"是假象------根本没扫描到文件
rg --no-ignore --files -g "*.js" dist/assets/ | wc -l
# 第 3 步:抽查压缩产物(浏览器实际加载的是 .gz,不是 .js)
# zcat 先解压再统计,避免只核对了源码版却漏掉压缩版
zcat dist/assets/index-legacy-*.js.gz | grep -o "console\.log" | wc -l
第 3 步容易被忽略:构建会同时产出 .js 与 .js.gz(由 vite-plugin-compression 之类的插件生成),而部署端若开启了 nginx gzip_static,实际返回给浏览器的是 .gz 文件。只核对 .js 而漏掉 .gz,可能放过"压缩包未随源码更新"的情况。
还有一类假阴性与命令写法有关:路径里的 *.js.gz 必须用通配符,不要写成 index-legacy-<hash>.js.gz 。bash 会把 <hash> 当成输入重定向(< 是重定向符号),报 hash: No such file or directory;而管道后续命令仍然成功、退出码为 0、统计结果还是 0------屏幕上看起来一切正常,实际根本没读到文件。跑完确认 stderr 无报错,再相信输出的 0。
⚠️ 本文最值得记住的陷阱 :用 ripgrep(rg)核对产物时,它默认遵守 .gitignore ,而 dist/ 通常是被忽略的目录------直接 rg "console\.log" dist/ 会"成功返回 0 结果",让人误以为清理干净了。必须加 --no-ignore。
反证自检 (排除工具静默失败的假阴性):随便搜一个确定存在的标识,确认它能命中------示例中的 addRoutes 可按需替换为你项目里必定出现的标识。
bash
rg --no-ignore -l "addRoutes" -g "*.js" dist/assets/ | wc -l
若此命令有命中、而 console\. 统计为 0,结论才可信。第 2 步的 --files | wc -l 也是同样的自检思路:先证明"搜索范围非空",再相信"结果为 0"。
定位残留来源的技巧 :生产构建通常关闭 sourcemap(build.sourcemap: false),产物里无法直接回溯某个残留来自哪个模块。可以取残留调用附近的特征字符串(相邻的函数名、字符串字面量)在源码中反查:
bash
# 把 addRoutes 换成残留调用旁边的实际特征串
rg -n "addRoutes" src/
反过来,确认某段业务代码是否已随本次改动清除,也可以用同一思路:先在源码里找到调用点,再用其特征串去产物里搜------搜不到,才算真的删净。
七、仍未覆盖的边界
补上 terserOptions 后,仍有两类残留不属于本次配置能管控的范围:
polyfills-legacy产物 。它由plugin-legacy单独发起一次构建生成,不读项目的vite.config.js,压缩也用自己的默认参数。因此项目侧的terserOptions传不进去,其中 SystemJS 自身的模块加载失败上报(console.error/console.warn)会保留。建议保持原样------老浏览器加载失败时这是唯一的诊断线索。- 静态拷贝的第三方文件 (如 pdf.js 的
pdf.worker.min.js)。这类文件由插件在构建时原样复制,不参与打包与压缩管线,两套产物共用同一份,任何drop配置对它都无效。
另外提醒一个容易被忽略的行为变更:legacy 产物面向旧浏览器,配置生效后该侧不再有任何 console 输出。这是预期的对齐行为,但会影响现场问题定位------必要时可临时回退该配置重新构建,或改用 vconsole 之类的显式调试入口。
八、小结
| 结论 | 说明 |
|---|---|
| 删除 console 的配置要写两处 | 现代产物用 esbuild.drop,legacy 产物用 build.terserOptions.compress.drop_console |
| 根因不是方法白名单 | esbuild 删除所有 console.xxx() 直接调用,无例外 |
| 根因是压缩管线的分叉 | plugin-legacy 让 legacy 产物跳过 esbuild 处理、改由 Babel 转译 + terser 压缩 |
| 验证要注意工具的静默忽略 | rg 默认跳过 .gitignore 里的 dist/,必须 --no-ignore |
排查同类问题的决策顺序:
- 分别统计两套产物的残留,确认问题在"哪一套"------若只有 legacy 侧残留,直接跳到第 4 步。
- 确认写法本身可被删除(
window.console/ 别名引用不会被任何压缩器处理)。 - 确认配置生效位置(顶层
esbuild只管现代产物,且搭的是"降级 + 压缩"共用的处理环节)。 - 为 legacy 侧补
build.terserOptions.compress.drop_console,重新构建并核对。 - 核对时注意工具的忽略规则与扫描范围自检;顺带解压
.gz产物抽查。 - 明确剩余的不可控边界(
polyfills-legacy、静态拷贝文件),避免误判为配置失效。
参考链接
- esbuild
drop选项:https://esbuild.github.io/api/#drop - Vite
esbuild共享选项:https://vite.dev/config/shared-options.html#esbuild - Vite
build.terserOptions:https://vite.dev/config/build-options.html#build-terseroptions - terser compress 选项(
drop_console):https://terser.org/docs/options/#compress-options - @vitejs/plugin-legacy:https://github.com/vitejs/vite/tree/main/packages/plugin-legacy