Skip to content

Module: CommonJS & ESM ​

Node.js có hai hệ module: CommonJS (require, module.exports), có từ những ngày đầu, và ES module (import, export), là chuẩn của JavaScript. Hai hệ này khác nhau ở cách nạp, cách chia sẻ giá trị, và cách xử lý vòng phụ thuộc. Code backend hiện nay thường trộn cả hai qua thư viện, nên cần hiểu chúng để đọc được những lỗi như ERR_REQUIRE_ESM hay một named import ra undefined. Trang này giải thích từng điểm khác nhau, cách Node quyết định một file thuộc hệ nào, và module cache.

Hai hệ module khác nhau thế nào ​

CommonJSES module
Cú phápconst x = require('./x'), module.exports = …import x from './x.js', export …
NạpĐồng bộ: require là một lời gọi hàm, chạy file ngay lúc gặpHai pha: đọc mọi import để dựng đồ thị module trước, rồi mới chạy
import/require đặt ở đâuBất kỳ đâu, kể cả trong if hay trong hàmimport tĩnh chỉ ở top level; import() động thì ở đâu cũng được và trả về Promise
Giá trị exportmodule.exports là một object bình thườngLive binding: biến import luôn thấy giá trị mới nhất của biến export, và không gán lại được
Top-level awaitKhôngCó
__dirname, __filename, requireCó sẵnKhông; dùng import.meta.dirname, import.meta.filename (Node 20.11+), createRequire
Đuôi file trong đường dẫnCó thể bỏ (require('./x'))Bắt buộc (import './x.js')

Vì ES module biết toàn bộ đồ thị phụ thuộc trước khi chạy, công cụ build mới loại được code không dùng tới (tree shaking), và lỗi tên import sai được báo trước khi chạy dòng nào.

Live binding ​

Với ES module, biến import là một tham chiếu tới biến export, không phải bản chép. Một module export let count = 0 và hàm inc(). Module khác import { count, inc }, gọi inc(), rồi đọc count thì thấy giá trị mới. Gán count = 5 từ phía import thì báo TypeError: Assignment to constant variable, vì chỉ module sở hữu biến mới được sửa nó. Nhưng nếu destructure ra một biến thường (const { count } = ns) thì biến đó chỉ là bản chép lúc gán, không đổi theo nữa.

Với CommonJS, module.exports là một object bình thường: ai require cũng nhận cùng object đó, và giá trị nguyên thuỷ được chép ra lúc destructure.

Node quyết định một file thuộc hệ nào ​

  • .mjs luôn là ES module, .cjs luôn là CommonJS.
  • .js theo trường "type" của package.json gần nhất: "module" thì là ES module, "commonjs" hoặc không có thì là CommonJS. Từ Node 22.7, file .js không có "type" mà chứa cú pháp import/export cũng được nhận ra là ES module.

Thư viện viết cho cả hai hệ thì khai báo trường "exports" trong package.json với hai nhánh "import" và "require", mỗi nhánh trỏ tới một bản build riêng.

Dùng chéo hai hệ ​

Import CommonJS từ ES module luôn được. Default import là module.exports. Named import (import { inc } from './counter.cjs') chỉ được khi Node đoán ra tên export bằng cách đọc tĩnh mã nguồn, mà việc đọc này chỉ là đoán theo một số mẫu quen thuộc. Trên lab, file viết exports.inc = () => … cho named export inc đúng là một hàm, còn file viết module.exports = { inc: () => … } thì named import inc ra undefined. Khi gặp named import ra undefined từ một thư viện CommonJS, dùng default import rồi lấy thuộc tính: import counter from './counter.cjs'; counter.inc().

require() một ES module trước đây luôn lỗi ERR_REQUIRE_ESM, nên thư viện chuyển sang thuần ES module (như node-fetch 3, chalk 5) làm khổ nhiều project CommonJS. Từ Node 22.12 và 20.19, require() nạp được ES module đồng bộ. Nếu module đó, hoặc bất kỳ module nào nó import, có top-level await thì vẫn lỗi ERR_REQUIRE_ASYNC_MODULE. Với Node cũ hơn, cách dùng ES module từ CommonJS là await import('…').

Module cache ​

Lần đầu require hay import một file, Node chạy file đó và lưu kết quả vào cache. Những lần sau chỉ lấy lại từ cache, không chạy lại. Hệ quả:

  • Module là singleton tự nhiên. Một file tạo connection pool rồi export nó thì mọi nơi import đều dùng chung một pool. Đây là cách thường dùng để chia sẻ pool database, logger, cấu hình.
  • Trạng thái trong module dùng chung cho cả process. Một biến let cache = {} trong module là bộ nhớ toàn cục: có thể phình mãi (Bộ nhớ & GC), và với test thì trạng thái của test trước lọt sang test sau.
  • Cache theo đường dẫn tuyệt đối đã chuẩn hoá. Hai đường dẫn khác nhau tới cùng một file vẫn dùng chung một bản. Nhưng hai bản cài khác nhau của cùng một package (node_modules/a/node_modules/lib và node_modules/lib) là hai file khác nhau, nên thành hai singleton. Đây là nguồn gốc của lỗi kiểu instanceof sai giữa hai bản của cùng một thư viện.
  • CommonJS cho xoá cache qua require.cache, thường chỉ dùng trong test. ES module không có cách tương đương.

Nạp module tốn thời gian ở lần đầu (đọc file, biên dịch): trên lab, require 100 file nhỏ chỉ thêm khoảng 10 ms vào thời gian khởi động. Thư viện lớn thì tốn hơn nhiều, nên với CLI hay serverless, người ta nạp chậm bằng import() ở chỗ thật sự cần.

Vòng phụ thuộc ​

a cần b, b lại cần a. Hai hệ module xử lý khác nhau:

  • CommonJS trả về exports đang dở của module chưa chạy xong. Không có lỗi, chỉ là giá trị sai: b đọc a.loaded và thấy false.
  • ES module tạo sẵn binding cho mọi export rồi mới chạy. Đọc một binding chưa khởi tạo ở top level báo ReferenceError: Cannot access 'a' before initialization, giống let trong temporal dead zone (Scope). Lỗi rõ ràng, dễ tìm hơn.
a.cjs: đang chạy
exports.loaded = falseconst b = require('./b.cjs')console.log('a: b.loaded =', b.loaded)exports.loaded = true
exports: {}
b.cjs: —
exports.loaded = falseconst a = require('./a.cjs')console.log('b: a.loaded =', a.loaded)exports.loaded = true
exports: —
(chưa có output)

node a.cjs: Node đưa a.cjs vào cache với exports rỗng rồi chạy từ dòng đầu.

Output khớp với lab/module (Node 24). Bước 1 / 7.

Vòng phụ thuộc thường là dấu hiệu tách module chưa đúng chỗ. Cách sửa hay dùng: đưa phần dùng chung ra một module thứ ba mà cả hai cùng import, chỉ đọc giá trị của module kia bên trong hàm (lúc được gọi thì module đã nạp xong), hoặc truyền phụ thuộc vào qua tham số. Công cụ như madge tìm được các vòng trong project.

Ghi nhớ

  1. CommonJS nạp đồng bộ, module.exports là object thường; ES module dựng đồ thị trước rồi mới chạy, có live binding và top-level await.
  2. .mjs/.cjs quyết định hệ module; .js theo "type" trong package.json.
  3. Import CommonJS từ ESM luôn được, nhưng named import chỉ là Node đoán; require() ES module được từ Node 22.12/20.19 nếu không có top-level await.
  4. Module được cache theo đường dẫn tuyệt đối nên là singleton tự nhiên; hai bản cài của cùng package là hai singleton.
  5. Vòng phụ thuộc: CommonJS trả exports dở dang (sai âm thầm), ESM báo ReferenceError. Sửa bằng cách tách phần dùng chung ra module thứ ba.

Nguồn tham khảo ​

Đã đối chiếu (đọc nguồn ngày 2026-10-26):

NguồnDùng cho phần
Node.js docs, Modules: CommonJSModule được cache sau lần nạp đầu; vòng phụ thuộc trả về bản chưa xong; require() nạp ES module đồng bộ, ERR_REQUIRE_ASYNC_MODULE khi có top-level await
Node.js docs, Modules: ECMAScript modulesNamed export từ CommonJS do phân tích tĩnh, chỉ là cố gắng tốt nhất

Kiểm bằng lab: mọi hành vi trong trang (Node 24). Từ kiến thức chung, chưa đối chiếu lần này: các mốc phiên bản (22.12, 20.19 cho require(esm); 22.7 cho nhận diện cú pháp; 20.11 cho import.meta.dirname); trường "exports" có điều kiện; madge.

Số đo chi tiết ​

Kết quả chạy lab/module/modules.mjs (Node 24.14)
Phép thửKết quả
require cùng file hai lần, hay qua đường dẫn khác tới cùng fileCùng một object
Xoá require.cache rồi require lạiObject mới, trạng thái về 0
Vòng phụ thuộc CommonJSb: a.loaded = false, a: b.loaded = true
Vòng phụ thuộc ES moduleb: ReferenceError: Cannot access 'a' before initialization, a: b = b
ESM: inc() hai lần rồi đọc qua namespace / qua biến đã destructure2 / 0
ESM: gán lại biến importTypeError: Assignment to constant variable.
Named import inc từ module.exports = { inc: () => … } / từ exports.inc = …undefined / hàm
require() ES module không có / có top-level awaitđược / ERR_REQUIRE_ASYNC_MODULE
__dirname, require trong ES moduleđều undefined; có import.meta.dirname
Khởi động node -e "" / thêm require 100 file nhỏ24 ms / 35 ms

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- module, chỉ cần Node.js. Các file mẫu nằm trong lab/module/demo/. Xem Lab.

lab/module/modules.mjs
js
// Trang nodejs/module: CommonJS và ES module trên Node 24. Các file mẫu nằm trong module/demo/.
import { createRequire } from 'node:module'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'

const require = createRequire(import.meta.url)
const here = (p) => fileURLToPath(new URL(p, import.meta.url))
const log = (label, v) => console.log(`${label.padEnd(60)} ${v}`)
const node = (args) => spawnSync(process.execPath, args, { encoding: 'utf8' })

console.log('\n=== Module cache: mỗi file chạy một lần, mọi nơi require dùng chung một object ===')
const c1 = require('./demo/cjs/counter.cjs')
const c2 = require('./demo/cjs/counter.cjs')
c1.inc(); c1.inc()
log('require hai lần cùng file', `cùng object: ${c1 === c2}, c2.get() = ${c2.get()}`)
const viaOtherPath = require(here('./demo/cjs/../cjs/counter.cjs'))
log('require qua đường dẫn khác nhưng cùng file', `cùng object: ${viaOtherPath === c1} (cache theo đường dẫn tuyệt đối đã chuẩn hoá)`)
delete require.cache[require.resolve('./demo/cjs/counter.cjs')]
const c3 = require('./demo/cjs/counter.cjs')
log('Xoá require.cache rồi require lại', `object mới: ${c3 !== c1}, c3.get() = ${c3.get()}`)

console.log('\n=== Vòng phụ thuộc ===')
let r = node([here('./demo/cjs/a.cjs')])
log('CommonJS: a.cjs require b.cjs, b.cjs require lại a.cjs', '')
process.stdout.write(r.stdout)
r = node([here('./demo/esm/a.mjs')])
log('ES module: a.mjs import b.mjs, b.mjs import lại a.mjs', '')
process.stdout.write(r.stdout + r.stderr.split('\n').filter((l) => /Error/.test(l)).slice(0, 1).join('\n'))

console.log('\n=== Live binding (ESM) và bản chép giá trị (CommonJS) ===')
const live = await import('./demo/esm/live.mjs')
const { count: copied } = live
live.inc(); live.inc()
log('import { count } từ ESM, gọi inc() hai lần', `live.count = ${live.count} (namespace luôn thấy giá trị mới), biến đã destructure = ${copied}`)
r = node(['--input-type=module', '-e', `import { count, inc } from ${JSON.stringify(here('./demo/esm/live.mjs'))}; inc(); console.log(count); try { count = 5 } catch (e) { console.log(e.constructor.name + ': ' + e.message) }`])
log('import { count } trực tiếp rồi inc()', r.stdout.trim().replace('\n', '; gán lại biến import: '))
const cjsCounter = require('./demo/cjs/counter.cjs')
const { get } = cjsCounter
log('CommonJS: module.exports là object bình thường', `destructure hàm vẫn được vì hàm đọc biến n trong closure: get() = ${get()}`)

console.log('\n=== Dùng chéo hai loại ===')
const fromCjs = await import('./demo/cjs/counter.cjs')
log('import counter.cjs (module.exports = { inc: () => … })', `default = module.exports: ${fromCjs.default === c3}; named export inc: ${typeof fromCjs.inc}`)
const fromCjs2 = await import('./demo/cjs/counter2.cjs')
log('import counter2.cjs (exports.inc = () => …)', `named export inc: ${typeof fromCjs2.inc}`)
r = node(['-e', `const m = require(${JSON.stringify(here('./demo/esm/live.mjs'))}); console.log(typeof m.inc, Object.keys(m).join(','))`])
log('require() một file ESM không có top-level await (Node 22.12+)', `${r.status === 0 ? 'được: ' + r.stdout.trim() : 'lỗi: ' + r.stderr.split('\n').find((l) => /Error|ERR_/.test(l))}`)
r = node(['-e', `require(${JSON.stringify(here('./demo/esm/tla.mjs'))})`])
log('require() một file ESM có top-level await', `lỗi: ${(r.stderr.match(/ERR_\w+/) || ['?'])[0]}`)
r = node(['--input-type=module', '-e', `console.log(typeof __dirname, typeof require, import.meta.dirname ? 'import.meta.dirname có' : 'không có')`])
log('Trong ESM: __dirname, require, import.meta.dirname', r.stdout.trim())
const t = performance.now()
const tla = await import('./demo/esm/tla.mjs')
log('import() file có top-level await 50 ms', `${tla.ready}, chờ ${Math.round(performance.now() - t)} ms`)

console.log('\n=== Thời gian nạp module ===')
const time = (args) => { const s = performance.now(); node(args); return Math.round(performance.now() - s) }
log('node -e "" (khởi động trống)', `${time(['-e', ''])} ms`)
log('require 100 file nhỏ khác nhau', `${time(['-e', `const p=require('path');const fs=require('fs');const d=require('os').tmpdir()+'/modtest';fs.mkdirSync(d,{recursive:true});for(let i=0;i<100;i++)fs.writeFileSync(d+'/m'+i+'.cjs','module.exports='+i);for(let i=0;i<100;i++)require(d+'/m'+i+'.cjs')`])} ms`)