
大家好😃。
最近我们准备发了一个新版本,趁着这次发版,顺手清理了一波项目里散落的 console.log。这在大多数团队里都属于日常卫生工作------生产环境不应该有调试日志,这谁都知道。
负责清理的同事很利索,全局搜索 console.log,逐条确认、逐条删除,半小时搞定。CI 跑过,测试环境验过,没任何异常。上线。
上线后 20 分钟,告警群开始响。
一个核心的商品详情页,部分用户打开后商品价格显示为 undefined。不是所有用户,也不是所有商品------只有带阶梯定价规则的商品才会出问题。
我们回滚之后开始排查。git diff 拉出来,这次发版改动了 47 个文件,其中 38 个是删 console.log。最终定位到的那行,是这样的:
javascript
// 被删掉的那行
console.log('price config:', product.priceConfig);
就这么一行。删掉之后,product.priceConfig 在后续的渲染逻辑里变成了 undefined。
一个 console.log,居然是让某段业务逻辑跑通的前置条件!!!
这个听起来很荒谬,但当我拆开 product 对象的实现之后,一切都说得通了。
为什么 console.log 不是纯读的 ?
大多数人对 console.log(obj) 的心智模型是:它读一下这个值,打印到控制台,然后什么都不影响。一个纯粹的只读操作,删了它不应该对程序行为有任何影响。
这个理解是错的!
在 JavaScript 里,读取一个属性这件事,本身就可能触发代码执行。console.log(obj.foo) 在引擎层面做的是👇:

这条链路上的每一步,都可能产生副作用。而 console.log 正好是那个触发整条链路的扳机。
我们项目里到底发生了什么
回到那个事故。product 对象不是一个普通的对象,它是从一个状态管理层构造出来的响应式对象。简化后的核心逻辑长这样:
typescript
class ProductModel {
private _rawData: RawProductData;
private _priceConfig: PriceConfig | null = null;
constructor(raw: RawProductData) {
this._rawData = raw;
}
// 阶梯定价配置是懒加载的------第一次访问时才从原始数据里解析
get priceConfig(): PriceConfig {
if (this._priceConfig === null) {
this._priceConfig = this.parsePriceRules(this._rawData.priceRules);
}
return this._priceConfig;
}
private parsePriceRules(rules: RawPriceRule[]): PriceConfig {
// 一段比较重的解析逻辑:校验规则合法性、排序、合并区间...
return {
tiers: rules
.filter(r => r.minQty > 0 && r.price > 0)
.sort((a, b) => a.minQty - b.minQty)
.map(r => ({ minQuantity: r.minQty, unitPrice: r.price })),
currency: this._rawData.currency ?? 'CNY',
};
}
}
看到了吗? 上面的 priceConfig 是一个 getter ,而且是一个带 惰性初始化(lazy initialization) 的 getter。第一次访问它的时候,才会去解析原始数据并缓存结果。后续再访问,直接返回缓存。

那行被删掉的 console.log('price config:', product.priceConfig) 写在组件的 setup 函数里,位置在渲染逻辑之前。它看起来只是在打印调试信息,但实际上它是第一次触发 priceConfig getter 的地方------它完成了惰性初始化。
删掉之后,priceConfig 的 getter 在渲染阶段之前从未被触发过。而恰好在渲染逻辑里,有一段代码是这样写的:
typescript
// 渲染逻辑里的问题代码
const displayPrice = product['_priceConfig']?.tiers[0]?.unitPrice;
这段代码没有通过 getter 访问 priceConfig,而是直接访问了私有属性 _priceConfig------因为某个历史时期为了绕过 TypeScript 的访问限制,用了方括号语法。而 _priceConfig 的初始值是 null,它要等 getter 被触发后才会被赋值。
console.log 不在了 → getter 没人触发 → _priceConfig 保持 null → 页面显示 undefined。
一个没用的调试语句,其实是整条初始化链路的起点。
讲一讲 3 种常见的 console.log 副作用模式
翻完这次事故之后,我回头审视了项目里其他几处有意义的 console.log,发现这类隐形副作用远比我想象的多。
Proxy 拦截器里的副作用
如果你在用 Vue 3 或者任何基于 Proxy 的响应式系统,console.log 一个响应式对象时,会触发 Proxy 的 get 拦截器。而在 Vue 的响应式系统里,触发 get 意味着依赖收集。
typescript
const state = reactive({ count: 0 });
// 这行 console.log 看起来很正常
// 但它触发了 state.count 的 getter
// 如果这行代码在某个 watchEffect 或 computed 的上下文里执行
// 它会把 count 注册为这个 effect 的依赖
console.log('current state:', state.count);
删掉这行 console.log 之后,某个 computed 或者 watchEffect 可能就不再追踪 count 的变化了------因为它的依赖收集阶段少了一次对 count 的访问。结果:count 变了,但 UI 不更新。
这种 Bug 极其隐蔽,因为你完全不会怀疑是删 console.log 导致的🤷♂️。
toString 和 Symbol.toPrimitive 的隐式调用
console.log 在序列化输出时,对某些对象会触发隐式的类型转换:
typescript
class TrackingToken {
private _accessed = false;
// 当这个对象被转成字符串时(比如被 console.log 打印)
// 标记为已访问
toString(): string {
this._accessed = true;
return `[Token accessed=${this._accessed}]`;
}
get isAccessed(): boolean {
return this._accessed;
}
}
const token = new TrackingToken();
// 这行会触发 toString(),把 _accessed 设为 true
console.log('token:', token);
// 后续逻辑依赖 isAccessed 的值
if (token.isAccessed) {
// 走到这里是因为 console.log 触发了 toString
// 删掉 console.log 之后,这个分支永远不会进入
reportTokenUsage(token);
}
这种模式在埋点、链路追踪、权限校验等场景里真实存在。
引用保持导致 WeakRef 行为变化
这个更隐蔽。console.log 会保持对传入对象的引用(至少在控制台被清除之前)。如果你的代码依赖 WeakRef 或 FinalizationRegistry 来做资源清理,console.log 可能让一个本该被回收的对象一直存活:
typescript
let ref: WeakRef<HeavyResource>;
function initResource() {
const resource = new HeavyResource();
ref = new WeakRef(resource);
// 这行 console.log 持有了 resource 的强引用
// 导致 resource 不会被 GC 回收
// WeakRef.deref() 始终返回对象
console.log('resource created:', resource);
}
function checkResource() {
const resource = ref.deref();
if (resource) {
// console.log 在的时候,resource 一直活着,这里总能进来
// console.log 删了之后,resource 可能被 GC 了,这里就进不来了
resource.doWork();
}
}
生产环境里你不会打开控制台,所以 console.log 对引用的保持效果和开发环境不同。但如果你的代码在某个特定时序下恰好依赖了这个意外的引用保持,删掉它就会出问题。
不要全局搜索删除!
经过这次事故之后,我们团队调整了处理 console.log 的策略。
用构建工具自动剥离,而不是手动删
手动删除 console.log 的问题在于:你必须理解每一行 console.log 的上下文才能判断它是否安全。这在几百个文件的项目里根本不现实。
正确做法是在构建阶段自动剥离,让 console.log 在源码里保留(开发时还能用),但打包产物里不包含:
typescript
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
esbuild: {
// 生产构建时自动移除 console 和 debugger
drop: process.env.NODE_ENV === 'production'
? ['console', 'debugger']
: [],
},
});
但要注意 :esbuild 的 drop: ['console'] 做的是语法层面的移除,它会把 console.log(expr) 整个语句删掉。如果 expr 本身有副作用(就像我们遇到的 getter 触发),这个副作用也会一起消失。
所以这种自动剥离本质上和手动删除面临同样的问题------它假设 console.log 的参数表达式没有副作用。
怎么彻底解决这个问题?
事故的根本原因不是 console.log,而是:
- 惰性初始化的
getter被隐式触发,没有显式的初始化调用 - 业务代码绕过了
getter,直接访问了私有属性
修复方案不是别删 console.log:
typescript
// 惰性 getter + 直接访问私有属性
class ProductModel {
private _priceConfig: PriceConfig | null = null;
get priceConfig(): PriceConfig {
if (this._priceConfig === null) {
this._priceConfig = this.parsePriceRules(this._rawData.priceRules);
}
return this._priceConfig;
}
}
// 渲染时直接摸私有属性(绕过了 getter)
const price = product['_priceConfig']?.tiers[0]?.unitPrice;
改造后👇:
typescript
// 显式初始化 + 禁止直接访问私有属性
class ProductModel {
private _priceConfig: PriceConfig;
constructor(raw: RawProductData) {
this._rawData = raw;
// 构造时就完成初始化,不搞惰性加载
this._priceConfig = this.parsePriceRules(raw.priceRules);
}
get priceConfig(): PriceConfig {
return this._priceConfig;
}
}
// 渲染时通过公开的 getter 访问
const price = product.priceConfig.tiers[0]?.unitPrice;
如果惰性加载确实有性能必要(比如解析成本很高、且大部分场景不需要这个值),那就用显式的 init() 方法代替隐式的首次访问触发:
typescript
class ProductModel {
private _priceConfig: PriceConfig | null = null;
// 显式调用,语义清晰:谁调了 init,谁就负责初始化
initPriceConfig(): void {
if (this._priceConfig === null) {
this._priceConfig = this.parsePriceRules(this._rawData.priceRules);
}
}
get priceConfig(): PriceConfig {
if (this._priceConfig === null) {
throw new Error(
'priceConfig 未初始化,请先调用 initPriceConfig()'
);
}
return this._priceConfig;
}
}
未初始化就访问,直接抛异常。用显式的失败代替隐式的静默,让问题在开发阶段就暴露出来。
可以给项目加一条 ESLint 规则
如果你想从前端工程层面防止类似问题,可以在 ESLint 里配一条规则,禁止在 getter 里做赋值操作(副作用):
javascript
// .eslintrc.js
module.exports = {
rules: {
// 禁止在 getter 里产生副作用
'no-setter-return': 'error',
// 搭配 @typescript-eslint 的规则,限制直接访问私有属性
'@typescript-eslint/no-unsafe-member-access': 'warn',
},
};
规则不能覆盖所有场景,但至少能让团队养成一个意识:getter 应该是纯计算,不应该有赋值、网络请求、事件触发等副作用。如果你需要副作用,写成显式的方法调用。
经历这次事故之后,我对 JavaScript 里读取这个动作的理解变深了。
在很多语言里,读一个属性就是读一个内存地址,没有任何其他效果。但在 JavaScript 里,因为 getter、Proxy、Symbol.toPrimitive、valueOf、toString 这些机制的存在,每一次属性访问都可能是一次函数调用,每一次函数调用都可能改变程序状态。
所以下次你在代码里看到一个多余的 console.log,删之前先想三秒:它的参数表达式里有没有 getter?对象是不是 Proxy?有没有自定义的 toString? 如果你不确定,就别手动删------交给构建工具,同时去检查一下那段代码是否存在隐式的副作用依赖。
真正该修的不是 console.log,而是那些把副作用藏在属性访问里的代码设计。
你们觉得呢?

喜欢我的文章,也欢迎关注我的微信公众号:【前端技术官】。
主要分享:前端架构 · AI 编程 · 职场认知 · 开发者成长

微信扫码关注 👆
不定期更新,不刷屏,聊点真正有用的干货。