DEV Community

Module System — CommonJS vs ESM

CommonJS vs ESM: hai hệ module, vì sao trộn chúng gây lỗi build

JavaScript có hai hệ module song song: CommonJS (CJS — require/module.exports, hệ cũ của Node) và ES Modules (ESM — import/export, chuẩn của ngôn ngữ). Chúng khác nhau ở bản chất chứ không chỉ cú pháp: CJS load đồng bộ tại runtime, ESM phân giải tĩnh trước khi chạy. Khác biệt đó là lý do tree-shaking chỉ làm được tốt với ESM, là lý do import một package CJS đôi khi lấy sai default, và là nguồn của hàng loạt lỗi build "Cannot use import statement outside a module" / "require is not defined" khi một dự án trộn cả hai. Đây là kiến thức bắt buộc khi migrate hoặc tích hợp package.

Cơ chế hoạt động

CJS: module được resolve và thực thi khi require chạy; export là một object có thể đổi lúc runtime. ESM: import/export là tĩnh — phân giải lúc parse, trước khi code chạy, nên bundler biết chính xác cái gì được import và loại bỏ phần không dùng (tree-shaking).

// CommonJS
const lodash = require('lodash')      // đồng bộ, lấy cả module
module.exports = { foo }

// ESM
import { debounce } from 'lodash-es'  // tĩnh, tree-shakeable -> chỉ debounce vào bundle
export const foo = () => {}
Enter fullscreen mode Exit fullscreen mode

Trong Node, hệ module được quyết định bởi "type" trong package.json ("module" = ESM, mặc định/"commonjs" = CJS) hoặc đuôi file (.mjs = ESM, .cjs = CJS). ESM hỗ trợ import() động (trả Promise) để load điều kiện/lười; CJS dùng require ngay tại chỗ.

Vấn đề gặp trong production

Failure mode: ESM không require được, CJS không import tĩnh dễ dàng. Một module ESM không thể require (nó async); và import một package CJS đôi khi cần lấy qua default thay vì named import, vì CJS không có named export tĩnh thật:

// package CJS, import từ ESM:
import pkg from 'some-cjs-lib'        // OK: lấy module.exports qua default
import { thing } from 'some-cjs-lib'  // có thể lỗi: 'thing' không phải named export tĩnh
Enter fullscreen mode Exit fullscreen mode

Lỗi "named export not found" khi import một package CJS từ ESM là tình huống cực phổ biến. Cách xử lý: import default rồi destructure, hoặc dùng bản ESM của package nếu có.

Failure mode: lỗi build/runtime khi trộn không nhất quán. "Cannot use import statement outside a module" (chạy ESM ở context CJS), "require is not defined in ES module scope", hay lỗi khi một file .js được hiểu nhầm hệ — gần như luôn do "type" trong package.json không khớp cú pháp dùng. Một dự án nên nhất quán một hệ; khi buộc trộn, dùng đuôi .mjs/.cjs rõ ràng cho phần khác hệ.

Failure mode: circular dependency hành xử khác nhau. Cả hai hệ xử lý import vòng nhưng khác: CJS trả về export một phần (những gì đã chạy tới thời điểm đó), dễ ra undefined ngầm; ESM dùng live binding nên thường an toàn hơn nhưng vẫn lỗi nếu dùng giá trị trước khi nó khởi tạo. Vòng phụ thuộc là dấu hiệu thiết kế cần tách, độc lập với hệ module.

Failure mode: tree-shaking không hiệu quả với CJS. Vì CJS động (export đổi được lúc runtime), bundler không loại an toàn được phần không dùng — import một package CJS lớn có thể kéo cả nó vào bundle dù chỉ dùng một hàm. Dùng bản ESM (lodash-es thay lodash) để tree-shaking ăn.

Cách debug và monitor

Khi gặp "Cannot use import statement outside a module" hoặc "require is not defined", kiểm tra "type" trong package.json và đuôi file có khớp cú pháp đang dùng không — đây là nguyên nhân số một. Lỗi "does not provide an export named X" khi import package: package đó là CJS, đổi sang default import. Để đo bundle phình do CJS, dùng bundle analyzer xem package nào vào trọn vẹn dù chỉ dùng một phần — đổi sang bản ESM nếu có. Khi migrate CJS→ESM, làm từng phần và chạy test sau mỗi bước; chú ý các thứ chỉ CJS có (__dirname, require.main) phải thay bằng tương đương ESM (import.meta.url).

Tradeoff

ESM là chuẩn của ngôn ngữ, hỗ trợ tree-shaking và phân tích tĩnh tốt hơn, là hướng đi tương lai — đổi lại ecosystem chưa đồng đều: nhiều package vẫn CJS, và trộn hai hệ gây lỗi tích hợp thật. CJS chín, tương thích rộng, đơn giản (đồng bộ) nhưng không tree-shake tốt và không phải chuẩn. Quy tắc thực tế cho dự án mới: dùng ESM nhất quán, ưu tiên package có bản ESM; khi buộc dùng package CJS, import default; giữ một hệ trong toàn dự án và chỉ dùng .mjs/.cjs khi thật sự cần trộn. Migrate khi lợi ích (tree-shaking, chuẩn hóa) vượt chi phí xử lý tương thích.

Câu hỏi phỏng vấn

CommonJS khác ESM ở đâu, và vì sao tree-shaking chỉ hiệu quả với ESM?

CommonJS dùng require/module.exports, load đồng bộ tại runtime, và export là object có thể thay đổi lúc chạy; ESM dùng import/export, phân giải tĩnh trước khi chạy (lúc parse), hỗ trợ import() động trả Promise. Trong Node, hệ được quyết bởi "type" trong package.json hoặc đuôi .mjs/.cjs. Tree-shaking chỉ hiệu quả với ESM vì cấu trúc import/export là tĩnh — bundler biết chắc lúc build cái gì được import và loại bỏ an toàn phần không dùng; còn CJS động nên export có thể đổi lúc runtime, bundler không thể loại an toàn và thường kéo cả package vào bundle. Điểm ăn điểm: nêu các lỗi thực tế khi trộn ("Cannot use import statement outside a module", "require is not defined", named export not found khi import package CJS — phải dùng default import), khác biệt xử lý circular dependency (CJS trả export một phần dễ ra undefined, ESM live binding), và lời khuyên giữ một hệ nhất quán, ưu tiên ESM cho dự án mới.

Hands-on

Lấy một dự án Node CJS thật và migrate sang ESM: đổi "type": "module" trong package.json, chuyển require/module.exports sang import/export, thay __dirname bằng import.meta.url, và chạy test sau mỗi bước để bắt lỗi tương thích. Cố tình import một named export từ một package CJS để tái hiện lỗi "does not provide an export named", rồi sửa bằng default import. Dùng bundle analyzer so sánh kích thước bundle khi import lodash (CJS) chỉ dùng một hàm so với lodash-es (ESM) để thấy tree-shaking hoạt động. Cuối cùng dựng một circular dependency và quan sát khác biệt hành vi giữa chạy ở CJS và ESM.

Top comments (0)