Vite-legacy产物console删除机制

为什么 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 实测为准。

目录

复现环境

排查所用的环境与配置如下,读者可对照复现:

组件 版本
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 声明两件事:

  1. 跳过 esbuild 处理------这套产物的语法降级由 Babel 负责,不需要 esbuild 再插手;
  2. 改用 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 配置 整体生效 被插件主动跳过

这三件事串起来就是:

  1. esbuild 处理环节被明确跳过 ,所以挂在项目 esbuild 配置上的 drop 对 legacy 产物完全无效;
  2. terser 压缩环节被明确指定,而它的默认参数里没有删除 console 的选项,所以 console 原样保留;
  3. 中间的 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 后,仍有两类残留不属于本次配置能管控的范围:

  1. polyfills-legacy 产物 。它由 plugin-legacy 单独发起一次构建生成,不读项目的 vite.config.js ,压缩也用自己的默认参数。因此项目侧的 terserOptions 传不进去,其中 SystemJS 自身的模块加载失败上报(console.error / console.warn)会保留。建议保持原样------老浏览器加载失败时这是唯一的诊断线索。
  2. 静态拷贝的第三方文件 (如 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

排查同类问题的决策顺序:

  1. 分别统计两套产物的残留,确认问题在"哪一套"------若只有 legacy 侧残留,直接跳到第 4 步。
  2. 确认写法本身可被删除(window.console / 别名引用不会被任何压缩器处理)。
  3. 确认配置生效位置(顶层 esbuild 只管现代产物,且搭的是"降级 + 压缩"共用的处理环节)。
  4. 为 legacy 侧补 build.terserOptions.compress.drop_console,重新构建并核对。
  5. 核对时注意工具的忽略规则与扫描范围自检;顺带解压 .gz 产物抽查。
  6. 明确剩余的不可控边界(polyfills-legacy、静态拷贝文件),避免误判为配置失效。

参考链接

相关推荐
程序员大阳8 小时前
副队长JavaScript教程(49)-主动抛出异常与错误类型
javascript·异常处理·error·throw·错误类型
hhhjhl9 小时前
Protobuf.js 跨端三方库仓颉适配全流程
javascript·网络·udp
康小汪9 小时前
AI 写 Vue 总写成同一个味道:给它装 Skills,装的不是知识是边界
前端·vue.js·人工智能·ai编程·最佳实践
智能运维指南9 小时前
研制过程“两张皮“怎么破:研制一体化支撑平台的定位、能力边界与落地前提
java·前端·人工智能·嘉为蓝鲸
白山编程大哥11 小时前
Java 后端如何优雅地把 List 集合返回给前端
java·前端·list
Flynt12 小时前
我把热榜那篇"禁用 axios"逐条实测了一遍:结论大体对,坑在它没讲的地方
前端·javascript·axios
Csvn13 小时前
bundle优化
前端
slongzhang_13 小时前
linux 递归删除源码文件|递归删除目录下 *.js,但排除 *.min.js
linux·开发语言·javascript
北冥有鱼13 小时前
发版后动态路由点击报 `Failed to fetch dynamically imported module`」问题上的完整排查与修复过程
前端·vue.js