Hệ thống module JavaScript sau 20 năm: Hỗn loạn, chia rẽ và con đường phía trước
I. Mở đầu: Chúng ta đã thực sự giải quyết xong "mô-đun hóa"?
Bạn có thể nghĩ rằng hệ thống mô-đun JavaScript đã được chuẩn hóa từ lâu, với import/export
là câu trả lời cuối cùng.
Nhưng thực tế lại đầy rẫy lỗi build, xung đột phụ thuộc và lỗi tải.
Từ <script>
đến require()
và rồi import/export
, chúng ta luôn đang phải trả giá cho các quyết định kiến trúc trong quá khứ.
Mô-đun lẽ ra là cơ sở hạ tầng để xử lý các dự án phức tạp, nhưng nó lại trở thành bãi mìn cho lập trình viên.
Thực tế, hệ thống mô-đun không mang lại sự thống nhất mà còn trở thành căn nguyên gây ra "mệt mỏi cấu trúc" trong JavaScript. Và mọi chuyện phải bắt đầu từ lịch sử...
II. Lược sử hỗn loạn: Từ hỗn mang đến đa hệ cùng tồn tại
1. Thời kỳ không có mô-đun (đầu những năm 2000)
JavaScript ban đầu không hề tính đến mô-đun — tất cả đều dựa trên biến toàn cục.
Lập trình viên phải dựa vào thứ tự <script>
để nạp file, cực kỳ mong manh và khó bảo trì.
Mỗi lần thêm phụ thuộc là mỗi lần... cầu nguyện không trùng tên biến.
2. Cộng đồng tự cứu mình
Khi không có giải pháp chính thức, cộng đồng tự sáng tạo ra các "giả mô-đun" như IIFE, Revealing Module Pattern, hay Namespace Object. Thông minh đấy, nhưng không tương thích với nhau, không thể cộng tác xuyên dự án, cũng không có hỗ trợ hệ thống. Dự án JavaScript trong thời gian dài giống như mảnh ghép tự lắp ráp bằng băng keo.
3. Node.js giới thiệu CommonJS
Node.js mang lại khái niệm mô-đun "chính thức" — dùng require()
để tải, module.exports
để xuất.
Phát triển phía server rõ ràng hơn nhiều, nhưng trình duyệt thì không hiểu gì hết.
Để "dịch" được CommonJS lên trình duyệt, chúng ta phải phát minh Browserify, Webpack và cả một hệ sinh thái build tool phức tạp.
4. Sự xuất hiện của ES Modules
ES6 mang theo import
và export
, tưởng là liều thuốc giải.
Nhưng đã quá muộn — CommonJS đã ăn sâu, công cụ build đã khổng lồ, định dạng mô-đun trở nên phân mảnh.
Từ đó, mô-đun không còn là cách viết, mà là... thỏa thuận giữa các tool build.
III. Cái giá thực sự của hệ thống mô-đun
Bạn từng gặp những lỗi như "Cannot use import outside a module"
hay "Unexpected token 'export'"
chứ?
Chúng không phải lỗi cú pháp, mà là hệ quả của sự không tương thích định dạng mô-đun và môi trường cấu hình.
Mỗi lỗi import
là một vết nứt trong lịch sử JavaScript suốt 20 năm.
Tình trạng này khiến tree shaking không hiệu quả, gói build phình to, hiệu suất tải giảm. Lập trình viên khi xuất bản package phải build ra CJS, ESM, UMD... và làm rõ từng điểm tương thích. Kết quả: Mô-đun – lẽ ra giúp cộng tác dễ hơn – lại trở thành phần rối rắm nhất trong quá trình xây dựng.
IV. CommonJS vs ESM: Sự khác biệt cốt lõi và vấn đề tương thích
CommonJS (CJS) và ECMAScript Modules (ESM) cùng tồn tại lâu dài trong Node.js, tạo thành món nợ kỹ thuật cứng đầu. Từ cú pháp, cách load, đến thời điểm chạy — mọi thứ đều khác. Nhiều lỗi không do bạn viết sai, mà do chọn sai hệ thống mô-đun.
Cú pháp: require()
vs import/export
CommonJS dùng require()
đồng bộ, module.exports
để export — đơn giản, là tiêu chuẩn thực tế của Node.js.
// CommonJS
const { addTwo } = require('./addTwo.js');
console.log(addTwo(2));
ESM dùng import/export
— cú pháp tĩnh, hỗ trợ tree shaking và phân tích tĩnh.
// ESM
import { addTwo } from './addTwo.mjs';
console.log(addTwo(2));
Hai hệ thống không thể trộn trực tiếp.
Cách tải: Đồng bộ vs Bất đồng bộ
- CommonJS: tải đồng bộ → phù hợp server, không hợp trình duyệt.
- ESM: tải bất đồng bộ → cần ở top-level, thích hợp với môi trường mạng hiện đại.
Tree shaking và phân tích tĩnh
- ESM: hỗ trợ tree shaking, loại bỏ code không dùng.
- CJS: tải động → không phân tích được → file build to.
Đường dẫn: __dirname
vs import.meta.url
- CJS: dùng
__dirname
,__filename
. - ESM: phải dùng
import.meta.url
.
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Khả năng tương tác
- Trong ESM dùng CJS:
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const lodash = require('lodash');
- Trong CJS dùng ESM:
async function loadESM() {
const { addTwo } = await import('./addTwo.mjs');
console.log(addTwo(3));
}
loadESM();
// Node 23+ cho phép:
const esm = require('./esm-file.mjs');
console.log(esm);
V. Giải pháp chuyển đổi trong thực tế
Dự án mới nên dùng ESM ngay từ đầu. Nhưng chuyển đổi dự án cũ thì không dễ dàng: CJS và ESM khác biệt về cách load, cache, dynamic import...
Một số chiến lược đề xuất:
- Chuyển đổi từng bước: giữ lại CJS chính, chuyển dần các module sang ESM.
- Kiểm thử theo tầng: mỗi lần chuyển module đều có test đảm bảo.
- Tận dụng Node 23+: hỗ trợ require() với ESM, giảm phụ thuộc transpiler.
- Dùng ServBay:
Giải pháp local hỗ trợ nhiều mô-đun, mặc định hỗ trợ
.mjs
,"type": "module"
, dễ test mix CJS/ESM mà không đụng CI/CD.
VI. Không phải lỗi của bạn: Gửi đến mỗi lập trình viên JavaScript
Hệ thống mô-đun JavaScript chưa bao giờ được thiết kế đúng đắn — nó là sản phẩm của vô số miếng vá.
- Không có mô-đun → tự nghĩ ra.
- Có CommonJS → trình duyệt không hiểu.
- Có ESM → thì đã quá muộn. Hệ quả là: mô-đun không còn là kỹ thuật, mà là gánh nặng lịch sử.
Bạn bị lỗi import/export
không vì bạn dốt, mà vì hệ sinh thái vốn không thống nhất.
Maxime từng nói trong Modules in JavaScript: A 20-Year Mistake:
"Chúng ta không xây dựng hệ thống mô-đun, chúng ta chỉ dựng lớp tương thích để che giấu 20 năm hỗn loạn."
Dù vậy, quá trình chuyển đổi là đáng làm. Nó mang lại:
- hiệu suất tốt hơn,
- hỗ trợ trình duyệt hiện đại,
- hướng đi tương lai rõ ràng hơn.
Bạn không cần chuyển ngay lập tức. Hãy “đổi mới trong cái cũ”, dần dần giới thiệu ESM. Cuối cùng, hãy nhớ: mô-đun là để tổ chức code, không phải hành hạ lập trình viên. Đừng tiếp tục trả giá cho quá khứ — hãy dùng công cụ và hiểu biết để mở ra con đường sáng sủa hơn.
All rights reserved