Menu

CommonJS và ES Modules: require hay import trong JavaScript

Hai hệ thống module có sẵn trong JavaScript, vì sao cả hai cùng tồn tại, và cách chọn giữa require và import trong dự án Node.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Hai hệ thống module, một ngôn ngữ

Ban đầu JavaScript hoàn toàn không có hệ thống module. Năm 2009, Node lấp chỗ trống đó bằng CommonJS (require, module.exports), và suốt nhiều năm code Node đều trông như vậy. Rồi đến năm 2015, bản thân ngôn ngữ có thêm một hệ thống module chuẩn, ES Modules (import, export), mà giờ cả trình duyệt lẫn Node đều hỗ trợ.

Vì thế bạn sẽ gặp cả hai trong thực tế. Đây là cùng một module nhỏ được viết theo từng cách:

Cùng một hàm, hai lớp vỏ khác nhau. Phần còn lại của trang này nói về khi nào lớp vỏ nào quan trọng và nên chọn cái nào.

Khác biệt về cú pháp

Những khác biệt hằng ngày gói gọn trong một tấm bưu thiếp:

// CommonJS
const fs = require("fs");
const { readFile } = require("fs/promises");

module.exports = something;
module.exports.name = value;
exports.name = value;
// ES Modules
import fs from "fs";
import { readFile } from "fs/promises";

export default something;
export const name = value;
export { name };

require là một lời gọi hàm bình thường. import là một câu lệnh: nó chỉ được xuất hiện ở cấp ngoài cùng của module, và đường dẫn phải là một chuỗi literal. Giới hạn đó không phải tùy tiện; chính nó giúp ESM làm được những việc CommonJS không làm được.

Khác biệt thật sự: tĩnh và động

CommonJS thực thi require() khi dòng đó chạy. Bạn có thể đặt nó trong một câu lệnh if, tính đường dẫn lúc chạy, tải module theo điều kiện:

ES Modules thì tĩnh. Engine phân tích mọi câu lệnh import trước khi chạy bất kỳ dòng code nào, dựng một đồ thị phụ thuộc và giải quyết mọi thứ từ trước. Đó là lý do đường dẫn phải là chuỗi literal và import chỉ xuất hiện ở cấp ngoài cùng.

Lợi ích là: công cụ có thể nhìn thấy toàn bộ đồ thị module mà không cần thực thi gì. Đó là cách bundler làm tree-shaking (loại bỏ các export không dùng), cách trình soạn thảo cho bạn gợi ý tự động hoàn thành chính xác, và cách trình duyệt tải các module song song.

Khi thật sự cần tải động trong ESM, hãy dùng import(), một biểu thức trông như hàm và trả về một Promise:

Node quyết định một tệp dùng hệ thống nào ra sao

Một dự án Node có thể chứa cả hai loại tệp. Node xác định mỗi tệp dùng hệ thống nào bằng cách xem hai thứ:

  • Phần mở rộng của tệp: .mjs luôn là ESM, .cjs luôn là CommonJS.
  • Trường "type" trong package.json gần nhất: "module" nghĩa là các tệp .js là ESM, "commonjs" (mặc định nếu không có) nghĩa là chúng là CJS.
// package.json
{
    "name": "my-app",
    "type": "module"
}

Với "type": "module", một tệp hello.js bình thường trong cùng package sẽ dùng import/export. Đặt thêm một tệp hello.cjs bên cạnh, riêng tệp đó sẽ dùng require. Đây là cách một dự án có thể chuyển đổi dần dần, hoặc cách một thư viện phát hành song song cả hai phiên bản.

Cái bẫy cho người mới: trong tệp ESM, require và module.exports đơn giản là không tồn tại. Nếu bạn quen tay dùng chúng, bạn sẽ gặp ReferenceError.

Tương thích: dùng chung hai hệ thống

Bạn sẽ thường cần một tệp ESM dùng một package CommonJS, hoặc ngược lại. Quy tắc cho hai chiều không giống nhau.

ESM import CommonJS chạy được trực tiếp. Đối tượng module.exports của CJS trở thành default export:

// app.mjs
import greet from "./greet.cjs";
console.log(greet("Rosa"));

Named import từ CommonJS đôi khi chạy được, vì Node cố phát hiện các named export một cách tĩnh, nhưng để chắc chắn, hãy lấy default export rồi destructuring:

import pkg from "./utils.cjs";
const { parse, stringify } = pkg;

CommonJS import ESM là chiều gây khó chịu. Bạn không thể require() một ES module: nó ném lỗi ERR_REQUIRE_ESM. Lối thoát là import() động, cách này chạy được cả trong CJS và trả về một Promise:

Node hiện đại (22 trở lên) đã thêm require() đồng bộ cho ESM trong một số điều kiện, nhưng import() động mới là lời giải chạy được ở mọi nơi.

Những khác biệt hành vi khác nên biết

Ngoài cú pháp, hai hệ thống còn khác nhau ở vài chi tiết thỉnh thoảng gây rắc rối:

Thêm vài điểm nữa:

  • this ở cấp ngoài cùng. Trong CJS, this là module.exports. Trong ESM, nó là undefined. ESM luôn chạy ở strict mode.
  • __dirname và __filename. CJS cho bạn sẵn hai biến này. Trong ESM, bạn suy ra chúng từ import.meta.url:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
  • Phần mở rộng tệp trong import. ESM bắt buộc ghi phần mở rộng ("./utils.js", không phải "./utils") với đường dẫn tương đối. CJS thì dễ dãi hơn.
  • Ràng buộc sống và ảnh chụp. Import trong ESM là tham chiếu sống tới các biến của module export. CJS cho bạn một bản sao của những gì được gán cho module.exports lúc tải. Phần lớn code không bao giờ nhận ra điều này, nhưng nó quan trọng với phụ thuộc vòng.

Nên dùng cái nào?

Với dự án mới: ES Modules. Đặt "type": "module" trong package.json và đừng ngoảnh lại. ESM là chuẩn của ngôn ngữ, hoạt động giống nhau trên trình duyệt và Node, hỗ trợ top-level await, và các công cụ được xây dựng với nó làm trọng tâm.

Giữ CommonJS khi:

  • Bạn đang bảo trì một codebase CJS có sẵn và việc chuyển đổi chưa đáng công.
  • Bạn phát hành một thư viện cần hỗ trợ các phiên bản Node rất cũ hoặc những nơi dùng không thể dùng ESM.
  • Một phụ thuộc quan trọng chỉ phát hành CJS và khả năng tương thích của nó lộn xộn. (Giờ hiếm gặp, nhưng vẫn xảy ra.)

Kể cả khi đó, bạn vẫn sẽ đọc code ESM liên tục: mọi thứ được phát hành trên npm vài năm gần đây đều đi theo hướng đó. Đọc thạo cả hai là điều bắt buộc; thành thạo cách viết chuẩn của hệ thống bạn đang thật sự dùng cũng vậy.

Danh sách kiểm tra nhanh trong đầu

Khi mở một tệp mới, hãy tự hỏi:

  • Tệp này dùng import/export hay require/module.exports? Đừng trộn lẫn chúng.
  • package.json gần nhất ghi gì ở "type"?
  • Nếu đang import một package, hãy xem package.json của nó: nó phát hành ESM, CJS hay cả hai?
  • Nếu gặp ERR_REQUIRE_ESM, bạn đang ở CJS và cố tải ESM. Chuyển sang import() động hoặc chuyển nơi gọi sang ESM.

Chín mươi phần trăm sự nhầm lẫn về module trong Node nằm ở một trong bốn điều đó.

Tiếp theo: npm cơ bản

Module là cách bạn chia code của chính mình ra nhiều tệp. Bước tiếp theo là dùng code người khác viết, và đó là việc của npm. Chúng ta sẽ tìm hiểu cách cài package, dải phiên bản semver, và những phần trong quy trình làm việc với npm mà bạn thật sự dùng hằng ngày.

Câu hỏi thường gặp

require và import trong JavaScript khác nhau thế nào?

require là cách tải module của CommonJS: đồng bộ, chạy đúng tại chỗ được gọi, và trả về những gì module đã gán cho module.exports. import là cú pháp của ES Modules: tĩnh, được đưa lên đầu tệp và được phân tích trước khi code chạy. Hai hệ thống còn khác nhau ở giá trị của this, cách xử lý phụ thuộc vòng, và việc có cho phép top-level await hay không.

Dự án Node mới nên dùng CommonJS hay ES Modules?

Dùng ES Modules. Đặt "type": "module" trong package.json và viết import/export. ESM là chuẩn chính thức, chạy được trên cả trình duyệt lẫn Node, và hỗ trợ top-level await. CommonJS vẫn xuất hiện trong các package và công cụ cũ, nên bạn vẫn sẽ phải đọc nó dù không viết.

Có thể dùng chung require và import trong một dự án không?

Được, nhưng có quy tắc. Tệp .mjs hoặc package có "type": "module" dùng ESM; .cjs hoặc "type": "commonjs" dùng CJS. ESM có thể import một module CommonJS (module.exports trở thành default export). CommonJS không thể require() trực tiếp một module ESM: bạn phải dùng import() động, hàm này trả về một Promise.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu