删了一个没用的 console.log,生产环境直接崩了!

0 阅读1分钟

删了一个没用的 console.log,生产环境直接崩了!

大家好😃。

最近我们准备发了一个新版本,趁着这次发版,顺手清理了一波项目里散落的 console.log。这在大多数团队里都属于日常卫生工作——生产环境不应该有调试日志,这谁都知道。

负责清理的同事很利索,全局搜索 console.log,逐条确认、逐条删除,半小时搞定。CI 跑过,测试环境验过,没任何异常。上线。

上线后 20 分钟,告警群开始响。

一个核心的商品详情页,部分用户打开后商品价格显示为 undefined。不是所有用户,也不是所有商品——只有带阶梯定价规则的商品才会出问题。

我们回滚之后开始排查。git diff 拉出来,这次发版改动了 47 个文件,其中 38 个是删 console.log。最终定位到的那行,是这样的:

// 被删掉的那行
console.log('price config:', product.priceConfig);

就这么一行。删掉之后,product.priceConfig 在后续的渲染逻辑里变成了 undefined。

一个 console.log,居然是让某段业务逻辑跑通的前置条件!!!

这个听起来很荒谬,但当我拆开 product 对象的实现之后,一切都说得通了。


为什么 console.log 不是纯读的 ?

大多数人对 console.log(obj) 的心智模型是:它读一下这个值,打印到控制台,然后什么都不影响。一个纯粹的只读操作,删了它不应该对程序行为有任何影响。

这个理解是错的!

在 JavaScript 里,读取一个属性这件事,本身就可能触发代码执行。console.log(obj.foo) 在引擎层面做的是👇:

screenshot-20261027-155247.png

这条链路上的每一步,都可能产生副作用。而 console.log 正好是那个触发整条链路的扳机。


我们项目里到底发生了什么

回到那个事故。product 对象不是一个普通的对象,它是从一个状态管理层构造出来的响应式对象。简化后的核心逻辑长这样:

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。第一次访问它的时候,才会去解析原始数据并缓存结果。后续再访问,直接返回缓存。

screenshot-20261027-161111.png

那行被删掉的 console.log('price config:', product.priceConfig) 写在组件的 setup 函数里,位置在渲染逻辑之前。它看起来只是在打印调试信息,但实际上它是第一次触发 priceConfig getter 的地方——它完成了惰性初始化。

删掉之后,priceConfig 的 getter 在渲染阶段之前从未被触发过。而恰好在渲染逻辑里,有一段代码是这样写的:

// 渲染逻辑里的问题代码
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 意味着依赖收集。

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 在序列化输出时,对某些对象会触发隐式的类型转换:

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 可能让一个本该被回收的对象一直存活:

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 在源码里保留(开发时还能用),但打包产物里不包含:

// 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,而是:

  1. 惰性初始化的 getter 被隐式触发,没有显式的初始化调用
  2. 业务代码绕过了 getter,直接访问了私有属性

修复方案不是别删 console.log:

// 惰性 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;

改造后👇:

// 显式初始化 + 禁止直接访问私有属性
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() 方法代替隐式的首次访问触发:

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 里做赋值操作(副作用):

// .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,而是那些把副作用藏在属性访问里的代码设计。

你们觉得呢?

Suggestion (3).gif


喜欢我的文章,也欢迎关注我的微信公众号:【前端技术官】。

主要分享:前端架构 · AI 编程 · 职场认知 · 开发者成长

前端技术官-ErpanOmer

微信扫码关注 👆

不定期更新,不刷屏,聊点真正有用的干货。