Vite 打包时丢掉 console 调用,页面上的合计金额跟着成了 undefined

很多 Vite 项目的配置里都躺着一行 drop: ['console'],升到 8.x 以后则是 dropConsole: true,图的是线上控制台清净、包小一点。可写调试日志的时候,手很容易把正经逻辑塞进括号:

console.log('合计', (order.total = sumLines(order.lines)))
console.info('剩余库存', stock.shift())

这两行在生产包里会被整句删掉。头一句带走了那次赋值,order.total 停在初始值,模板渲染出来就是「¥undefined」;后面那句的 shift() 也没执行,库存数组比预期多出一项。开发环境通常看不出异样,因为 Vite 8 的 dropConsole 挂在构建阶段的压缩选项上,vite dev 根本不经过它。

参数为什么也一起没了

esbuild 文档的 Drop 一节里写着:console 这一项会删掉整个调用表达式,所有参数都算在内。这么设计是故意的,线上能省掉那些参数的计算开销。代价是其中带副作用的表达式也会跟着消失,代码行为随之改变。原文还专门警告 “Using this flag can introduce bugs into your code!”。Oxc 压缩器的 dropConsole 说明也是同一个意思,并注明它的行为和 Terser 的 drop_console 类似。

换到语法树上看就清楚了。console.log(a = f()) 是一个调用表达式,赋值只是挂在它下面的一个参数子节点。压缩器按被调用者的名字匹配到 console.*,把整棵子树摘掉,里面的参数自然也就没了。

getter 同理。console.log(cfg.rate) 会触发一次属性访问;如果 rate 的读取器里顺手做了惰性初始化,日志一删,那段准备工作就跟着跳过了,后面用到这份数据的地方就可能拿到空值。

pure 走的是另一条路

同一份 esbuild 文档的 Pure 一节讲的是另一种语义:被标记的调用,只要返回值没人用,就可以删。注意它只作用于调用本身,不覆盖参数,里面带副作用的部分压缩后照样保留。文档里的例子,document.createElement(elemName()) 压完只剩里层那个函数调用。

放到开头那行代码,换成 pure 以后大致会压成 order.total = sumLines(order.lines):打印没了,赋值还在。前提是返回值确实没人接,写成 const r = console.log(x) 这种,整个调用会原样留下。

Vite 8 的配置位置变了

10 月的 Vite 8.3 里,build.minify 默认值已经是 'oxc',顶层 esbuild 选项也标成了弃用。迁移指南里写明,原来的 esbuild.drop 要改用 build.rolldownOptions.output.minify.compress 下面的 dropConsole 这类选项。旧配置不改也不会报错。用 8.3.5 打一次包就能看到,console 调用原样留在产物里,默认的 Oxc 压缩器并不读这项配置。

想要「去掉打印、保留副作用」,Oxc 文档给的办法是 compress.treeshake.manualPureFunctions:

// vite.config.js,Vite 8,默认 Oxc 压缩
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    rolldownOptions: {
      output: {
        minify: {
          compress: {
            treeshake: { manualPureFunctions: ['console'] },
          },
        },
      },
    },
  },
})

写成 'console',log、info 这些方法都会被覆盖;只想处理其中几个,就把全名逐个列出来。

还停在 Vite 7 或更早版本的项目,默认压缩器是 esbuild。对应写法放在顶层配置里:

// vite.config.js,Vite 7,默认 esbuild 压缩
export default defineConfig({
  esbuild: { pure: ['console.log', 'console.info'] },
})

改用 Terser 的话,对应项是 terserOptions.compress.pure_funcs。它的选项说明里,drop_console 写的是直接丢弃 console.* 调用,放进 Vite 里实际打一次包,参数同样会被连带删掉。

代码层面也该收一收

  • 日志括号里只放读取,赋值、push、shift、自增这类改状态的操作挪到单独一行。
  • getter 保持纯读取,惰性初始化放进显式的 init(),或者挪到第一次真正用到数据的业务代码里。
  • 老项目里可以用 rg "console\.\w+\(.*(=|\+\+|\.push|\.shift|\.pop)" 粗筛一遍,误报会有,但能很快圈出要细看的几行。
  • 上线前对生产产物搜一下关键赋值,或者让端到端用例跑一遍构建后的包,比肉眼审配置靠谱。

你们项目里是直接 drop,还是已经换成 pure 了?