This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.

Khi các ứng dụng web ngày càng lớn và phức tạp, việc viết toàn bộ code JavaScript trong một file duy nhất hoặc nhúng hàng chục thẻ <script> vào trang HTML sẽ dẫn tới thảm họa Global Scope Pollution (Ô nhiễm phạm vi toàn cầu) và xung đột tên biến. Hệ thống Module ra đời để cô lập phạm vi (Scope Isolation), tổ chức mã nguồn và tối ưu hóa hiệu năng ứng dụng thông qua việc phân tích cây phụ thuộc.

1. Tại sao Module hóa lại quan trọng?

Mỗi file JavaScript khi khai báo dạng module sẽ có một Module Scope (Phạm vi mô-đun) hoàn toàn độc lập:

  • Scope Isolation: Các biến, hàm, lớp khai báo trong module đều là biến cục bộ ẩn, không thể bị đọc hoặc can thiệp từ bên ngoài trừ khi bạn hiển thị chúng ra bằng từ khóa export.
  • Không ô nhiễm toàn cục: Hạn chế hoàn toàn xung đột tên biến toàn cục. Trình duyệt không cần gắn biến vào đối tượng window.
  • Dependency Graph: Dễ dàng quản lý cấu trúc dự án dưới dạng sơ đồ phụ thuộc giúp các công cụ đóng gói (bundlers) hiểu rõ mối liên kết giữa các phần của mã nguồn.

2. Chu kỳ nạp Module 3 pha của ES Modules (V8 Engine)

Khác với CommonJS tải module đồng bộ, ES Modules được thiết kế bất đồng bộ để tối ưu cho môi trường web. V8 Engine (và các JS Engine khác) xử lý ES Modules qua 3 pha tách biệt:

  [ Pha 1: Construction ]       [ Pha 2: Instantiation ]        [ Pha 3: Evaluation ]
    Tải file & Phân tích          Liên kết các ô nhớ              Chạy code & Điền giá trị
       (Static Parsing)            (Live Bindings)               (DFS Post-Order Execution)
 
      main.js                        main.js                       main.js (Thực thi cuối)
      /     \                        /     \                       /     \
   lib.js  utils.js               lib.js  utils.js              lib.js  utils.js
                                 (Khớp các import->export)      (Thực thi lib & utils trước)
  • Pha 1: Construction (Xây dựng đồ thị phụ thuộc - Static Parsing): Trình duyệt hoặc Node.js tìm kiếm, tải về toàn bộ các file JS, phân tích cú pháp tĩnh (Static Parsing) để tạo ra các Module Record. Trong pha này, engine chỉ quét qua từ khóa importexport tĩnh để xây dựng sơ đồ cây phụ thuộc (Dependency Graph) mà chưa thực thi bất kỳ dòng code nào. Điều này giải thích tại sao câu lệnh import chuẩn không được phép nằm trong câu lệnh điều kiện if hay vòng lặp.
  • Pha 2: Instantiation (Liên kết bộ nhớ - Linking): Engine cấp phát các vùng nhớ (Module Environment Records) cho các biến được xuất/nhập. V8 sẽ ánh xạ các import của module này trỏ trực tiếp vào ô nhớ chứa export của module kia thông qua cơ chế Live Bindings (Liên kết động). Điểm khác biệt tối quan trọng: ESM truyền tham chiếu động, còn CommonJS thì copy giá trị (Copy-on-require).
  • Pha 3: Evaluation (Thực thi mã nguồn): V8 Engine thực thi mã nguồn của từng file module để điền giá trị cụ thể vào các ô nhớ đã được liên kết ở Pha 2. Quy trình này thực hiện theo thứ tự duyệt cây DFS (Depth-First Post-Order) từ dưới lên trên (các module lá, độc lập sẽ chạy trước, module chính main.js chạy sau cùng). Mỗi module chỉ được evaluate đúng một lần duy nhất và kết quả được lưu vào cache.

3. So sánh chi tiết ES Modules (ESM) và CommonJS (CJS)

Hiểu rõ sự khác biệt giữa hai hệ thống module giúp bạn thiết kế ứng dụng chạy mượt mà trên cả trình duyệt và Node.js:

Đặc điểm CommonJS (CJS) ES Modules (ESM)
Môi trường chính Node.js (Server-side) truyền thống Trình duyệt hiện đại & Node.js mới
Thời điểm nạp Động khi chạy code (Runtime / Synchronous) Tĩnh trước khi chạy (Static Analysis / Asynchronous)
Liên kết xuất/nhập Copy-by-value / Copy-on-require (Giá trị được sao chép khi require, không cập nhật nếu module nguồn thay đổi biến nguyên thủy) Live Bindings (Liên kết tham chiếu trực tiếp, cập nhật tự động khi module nguồn đổi giá trị; ở phía nhận là Read-only)
Cú pháp import/export const math = require('./math')
module.exports = { add }
import { add } from './math.js'
export { add }
Tối ưu Tree Shaking Hầu như không thể tối ưu tự động do tính chất nạp động của require Hỗ trợ cực tốt, bundler dễ dàng nhận biết code không dùng

> [!NOTE] > Live Bindings có ý nghĩa rất lớn. Nếu module counter.js export biến số let count = 0 và hàm increment(), khi bạn gọi increment(), giá trị biến count ở file import cũng lập tức tăng lên. Trong CommonJS, biến count đã được copy thành bản sao cục bộ ngay từ lệnh require, nên nó sẽ mãi là 0 trừ khi bạn bọc nó trong một object tham chiếu.

4. Tối ưu hóa Tree Shaking và Side Effects

Tree Shaking là thuật ngữ mô tả quá trình loại bỏ các đoạn code thừa không bao giờ được gọi (Dead Code Elimination) trong quá trình đóng gói ứng dụng (bundling):

  • Cơ chế: Nhờ ES Modules phân tích cấu trúc tĩnh từ Pha 1, bundler (như Vite, Rollup, Webpack) biết chắc chắn hàm nào được import và sử dụng. Những hàm nào được export nhưng không ai dùng sẽ bị loại bỏ khỏi file output cuối cùng.
  • Cấu hình "sideEffects": Một số module khi import sẽ thực thi code thay đổi môi trường toàn cục (ví dụ thêm polyfill vào Array.prototype hoặc đăng ký sự kiện window). Hành vi này gọi là Side Effect. Nếu bạn khai báo "sideEffects": false trong file package.json của dự án, bạn đang cam đoan với bundler rằng: *"Các module của tôi không có tác dụng phụ ngầm, nếu tôi không import cụ thể cái gì thì hãy cứ thoải mái xóa bỏ nó khỏi bundle."*

5. Named Exports, Default Export và Dynamic Imports

ES Modules hỗ trợ cấu trúc linh hoạt để xuất bản thư viện:

  • Named Exports: Cho phép export nhiều biến/hàm/lớp từ một file. Khi import bắt buộc phải ghi đúng tên trong ngoặc nhọn: import { add, subtract } from './math.js'.
  • Default Export: Cho phép xuất một thực thể đại diện duy nhất (thường là Class chính). Khi import không cần dấu ngoặc nhọn và có thể tự đổi tên: import Database from './db.js'. Trong hậu trường, default export thực chất là một named export với khóa tên là default.
  • Dynamic Imports (import động): Trong trường hợp bạn muốn tải file JS lười (Lazy load) chỉ khi cần thiết (ví dụ: người dùng bấm vào nút mở biểu đồ phân tích lớn), hãy sử dụng hàm import('./chart.js'). Hàm này trả về một Promise chứa module exports, kết hợp độ động của CommonJS và tính năng bất đồng bộ của ESM.
module_system.js
// Minh họa Dynamic Import nạp module bất đồng bộ khi cần
async function loadAnalyticsOnDemand() {
  try {
    console.log("Đang kích hoạt nạp code phân tích...");
    const analytics = await import('./analytics.js');
    analytics.trackEvent("UserCheckoutSuccess");
  } catch (err) {
    console.error("Lỗi nạp mã nguồn động:", err.message);
  }
}

Module Scope & Từ Khóa this

Giá trị của từ khóa this ở cấp độ cao nhất (Top-level) trong JavaScript phụ thuộc hoàn toàn vào chế độ nạp module:

  • Trong ES Modules (ESM): ESM luôn tự động chạy ở chế độ nghiêm ngặt (Strict Mode). Do đó, this ở top-level mang giá trị undefined.
  • Trong CommonJS (CJS): this ở top-level của một file Node.js thực chất trỏ tới đối tượng module.exports (khởi đầu là một object rỗng {}).
  • globalThis: Là đối tượng toàn cục chuẩn hóa (ES2020) giúp bạn truy cập đối tượng global (như window trên trình duyệt, hoặc global trên Node.js) một cách thống nhất và an toàn mà không cần kiểm tra môi trường.
▶ Thử chạy: Module Scope & this Lab

6. Câu hỏi trắc nghiệm ôn tập

Trắc nghiệm 1: Live Bindings vs Copy-on-require

Phát biểu nào sau đây về cơ chế Live Bindings của ES Modules là ĐÚNG?

7. Playground: Giả Lập Live Bindings vs Copy-on-require

Playground dưới đây mô phỏng sự khác biệt quan trọng giữa ESM Live Bindings và CJS copy-on-require:

▶ Thử chạy: Live Bindings vs Copy-on-require Simulation

8. IIFE Pattern — Lịch Sử Trước Khi Có Modules

Trước khi ES6 Modules ra đời, các lập trình viên dùng IIFE (Immediately Invoked Function Expression) để tạo private scope và tránh ô nhiễm global. Đây là pattern nền tảng của nhiều thư viện cổ điển (jQuery, Lodash):

IIFE Pattern — Module trước ES6
// IIFE: tạo scope riêng, expose public API
const CounterModule = (function() {
  // Private (không thể truy cập từ ngoài)
  let _count = 0;
  const _history = [];

  function _log(action) {
    _history.push({ action, count: _count, time: Date.now() });
  }

  // Public API
  return {
    increment(by = 1) {
      _count += by;
      _log('increment');
      return this;
    },
    decrement(by = 1) {
      _count -= by;
      _log('decrement');
      return this;
    },
    get value() { return _count; },
    get history() { return [..._history]; },
    reset() { _count = 0; _history.length = 0; return this; }
  };
})();

// Dùng như module:
CounterModule.increment().increment(5).decrement(2);
console.log(CounterModule.value);    // 5 (1 + 5 - 2 = 4? No: 0 + 1 + 5 - 2 = 4)
console.log(CounterModule.history.length);  // 3 actions

// Modern equivalent: ES6 Module
// export const counter = { ... }  (không cần IIFE)

// Circular Dependency: ESM handle được, CJS thì không
// a.mjs imports b.mjs, b.mjs imports a.mjs
// ESM: Live Bindings cho phép resolve vì tất cả import bindings đã được cấp phát ở Pha 2
// CJS: circular require thường trả về {} hoặc partial exports → bug!

Trắc nghiệm 2: Dynamic Import

Câu lệnh const mod = await import('./heavy.js') khác gì so với import { fn } from './heavy.js'?

Tải file code thực hành minh họa bài học

Mã nguồn giả lập Module Scope isolation, so sánh CJS copy-on-require vs ESM Live Bindings, IIFE pattern và Dynamic Import lazy-loading:

Tải về module_system.js

Related Articles

Bài viết liên quan trong series

Lesson 11: DOM Events & Reactive State Stores Bài 11: Sự kiện Trình duyệt & Lập trình Phản ứng — Xây dựng Reactive Store Lesson 9: Metaprogramming in JS: Symbols, Proxy & Reflect Bài 9: Metaprogramming trong JS: Làm chủ Symbol, Proxy & Reflect Back to JavaScript Series Overview Quay lại Lộ trình JavaScript Series