Dọn console.log trước khi release là việc mà team nào cũng từng làm. Trên Juejin vừa có một bài kể lại sự cố đáng nhớ: một team frontend thương mại điện tử xoá hàng loạt console.log trong một đợt release (38 trên 47 file thay đổi chỉ là xoá log), CI xanh, staging ổn, lên production 20 phút sau thì trang chi tiết sản phẩm bắt đầu hiển thị giá là undefined. Không phải mọi sản phẩm, chỉ những sản phẩm có bảng giá bậc thang.

Thủ phạm là đúng một dòng:

console.log('price config:', product.priceConfig);

Bài này phân tích lại vì sao một câu lệnh "chỉ đọc" lại có thể là điều kiện để logic nghiệp vụ chạy đúng, và rút ra cách thiết kế để không bao giờ phải sợ xoá log nữa.

Đọc một property trong JavaScript không phải lúc nào cũng "chỉ đọc"

Mô hình tư duy phổ biến là: console.log(obj.foo) đọc giá trị rồi in ra, không ảnh hưởng gì. Nhưng trong JavaScript, biểu thức obj.foo có thể là một lời gọi hàm trá hình. Getter, Proxy, valueOf, toString, Symbol.toPrimitive đều cho phép chạy code tuỳ ý khi bạn chỉ "đọc". Điều quan trọng cần nhớ: argument của console.log luôn được evaluate trước khi console.log chạy. Vì vậy nếu obj.foo có side effect, side effect đó xảy ra bất kể log có hiển thị hay không.

Chuyện gì đã xảy ra trong sự cố

Đối tượng product không phải object thường mà là một model class với lazy getter:

class ProductModel {
  private _priceConfig: PriceConfig | null = null;
  constructor(private _raw: RawProduct) {}

  get priceConfig(): PriceConfig {
    if (this._priceConfig === null) {
      this._priceConfig = parseRules(this._raw.priceRules); // parse nặng, cache lại
    }
    return this._priceConfig;
  }
}

Dòng console.log nằm trong hàm setup của component, chạy trước phần render. Nó là nơi đầu tiên chạm vào getter, tức là nó vô tình làm nhiệm vụ khởi tạo cache.

Phần render thì lại có một đoạn code "lịch sử", truy cập thẳng field private bằng ngoặc vuông để né kiểm tra của TypeScript:

const displayPrice = product['_priceConfig']?.tiers[0]?.unitPrice;

Chuỗi nhân quả: xoá log, không ai gọi getter, _priceConfig vẫn là null, optional chaining trả về undefined, UI hiển thị undefined. Không có exception nào, không có lỗi trong console, chỉ có dữ liệu sai một cách im lặng. Đó là lý do test không bắt được: optional chaining nuốt mất tín hiệu lỗi.

Lưu ý là có hai lỗi cộng hưởng: lazy init ngầm định qua lần truy cập đầu tiên, và code nghiệp vụ đi vòng qua getter. Thiếu một trong hai thì sự cố đã không xảy ra.

Các dạng side effect ẩn khác cần biết

Tác giả gốc liệt kê thêm vài mẫu. Mình viết lại kèm vài lưu ý cho chính xác.

Proxy và hệ thống reactive

Vue 3, MobX và các thư viện reactive dựa trên Proxy thực hiện dependency tracking ngay trong get trap. Nếu console.log(state.count) nằm trong một computed hoặc watchEffect, lần đọc đó đăng ký count làm dependency. Xoá log đi, effect có thể không còn theo dõi count, và UI không cập nhật khi count đổi. Thực tế đây thường là dấu hiệu code đang dựa vào một dependency mà logic thật không đọc, nên cách sửa là đọc giá trị một cách tường minh ở nơi cần, không phải giữ log.

toString và ép kiểu ngầm

Nếu một class có toString() hoặc Symbol.toPrimitive thay đổi state (ví dụ đánh dấu token đã được dùng cho mục đích tracking), thì mọi thao tác ép sang chuỗi đều kích hoạt nó. Một điểm cần chính xác: console.log('token:', obj) trong Chrome và Node thường không gọi toString, vì console tự inspect object. Nhưng console.log('token: ' + obj) hay template string `token: ${obj}` thì có. Dạng sau rất phổ biến trong code log thực tế.

Giữ reference làm thay đổi hành vi GC

Khi DevTools mở, console giữ reference tới object đã log để bạn còn bấm vào xem. Điều đó có thể khiến object không bị garbage collect, và code dùng WeakRef hay FinalizationRegistry sẽ chạy khác giữa lúc debug và production. Đây là lý do không bao giờ được viết logic phụ thuộc vào thời điểm GC: spec không đảm bảo khi nào object bị thu hồi.

Strip log bằng build tool có an toàn hơn?

Phản xạ tự nhiên là đừng xoá tay mà để bundler làm:

// vite.config.ts
export default defineConfig(({ mode }) => ({
  esbuild: {
    drop: mode === 'production' ? ['console', 'debugger'] : [],
  },
}));

Cách này tiện, nhưng như tác giả gốc chỉ ra, drop xoá cả câu lệnh lẫn argument, nên side effect của getter cũng biến mất y như khi xoá tay. Nó chỉ chuyển rủi ro từ lúc review sang lúc build, và tệ hơn là dev và prod chạy khác nhau. Có các lựa chọn như đánh dấu console.log là pure (pure trong esbuild, pure_funcs trong Terser), nhưng hành vi với argument có side effect phụ thuộc vào cách mỗi tool phân tích, nên hãy đọc kỹ docs của tool bạn dùng thay vì giả định.

Kết luận: strip log tự động là tốt, nhưng chỉ an toàn khi code của bạn không dựa vào side effect trong lúc đọc. Thứ cần sửa là thiết kế, không phải cách xoá log.

Sửa tận gốc: biến khởi tạo ngầm thành tường minh

Nếu dữ liệu luôn cần, khởi tạo luôn trong constructor:

class ProductModel {
  readonly priceConfig: PriceConfig;
  constructor(raw: RawProduct) {
    this.priceConfig = parseRules(raw.priceRules);
  }
}

Nếu lazy thật sự cần cho hiệu năng, có hai cách lành mạnh:

class ProductModel {
  #priceConfig?: PriceConfig;           // private thật, không truy cập được bằng ['...']
  constructor(private raw: RawProduct) {}

  get priceConfig(): PriceConfig {
    return (this.#priceConfig ??= parseRules(this.raw.priceRules));
  }
}

Điểm mấu chốt ở đây là dùng ES private field #priceConfig thay cho private của TypeScript. Modifier private chỉ tồn tại lúc compile, obj['_priceConfig'] vẫn chạy bình thường ở runtime. Còn #field thì không thể truy cập từ ngoài class, nên không ai có thể vô tình bỏ qua getter. Lazy getter kiểu này cũng an toàn vì ai đọc thì người đó nhận giá trị đúng, không phụ thuộc thứ tự.

Cách thứ hai mà bài gốc đề xuất là tách hàm init() tường minh và để getter throw nếu chưa init. Fail to và sớm luôn tốt hơn trả undefined im lặng.

Về lint

Bài gốc gợi ý thêm rule ESLint, nhưng cần đính chính: no-setter-return chỉ cấm return giá trị trong setter, không liên quan tới side effect trong getter. ESLint core hiện không có rule nào phát hiện được "getter có gán state". Những thứ thực sự có ích hơn:

  • Bật @typescript-eslint/dot-notation hoặc review chặt các truy cập kiểu obj['_x'], vì đó là dấu hiệu đang né type system.
  • Chuyển field nội bộ sang #private để compiler và runtime cùng bảo vệ.
  • Hạn chế ?. trên dữ liệu bắt buộc phải có. Giá sản phẩm mà undefined thì nên throw hoặc log lỗi, không nên render.
  • Thêm test cho component mà không có log nào ở setup, hoặc đơn giản là test model độc lập với thứ tự truy cập.

Bài học mang về

Trong nhiều ngôn ngữ, đọc field chỉ là đọc ô nhớ. Trong JavaScript, mỗi lần đọc property có thể là một lời gọi hàm, và mỗi lời gọi hàm có thể thay đổi state. Trước khi xoá một dòng log trông có vẻ vô hại, hãy tự hỏi: argument có đi qua getter không, object có phải Proxy không, có ép chuỗi không. Nhưng quan trọng hơn, hãy viết code sao cho câu trả lời không bao giờ quan trọng: getter nên là phép tính thuần, khởi tạo nên tường minh, và state private nên thật sự private.


Bài viết được AI của DevViet viết lại bằng tiếng Việt từ một thảo luận/bài viết tiếng Trung trên Juejin, có thể chưa chính xác tuyệt đối. Nguồn: 删了一个没用的 console.log,生产环境直接崩了!