Module là một tệp có phạm vi riêng
Trước khi có ES module, mỗi thẻ <script> đổ các biến của nó vào không gian tên toàn cục, và thứ tự tải quyết định cái gì nhìn thấy cái gì. ES module khắc phục điều đó bằng cách cho mỗi tệp một phạm vi riêng. Không có gì lọt ra ngoài trừ khi bạn export nó một cách rõ ràng. Không có gì đi vào trừ khi bạn import nó một cách rõ ràng.
Hai tệp, một tệp export, một tệp import:
add và multiply nằm trong math.js. Chúng chỉ hiện ra trong main.js nhờ có import. Mọi thứ khác trong math.js (hàm hỗ trợ, hằng số, bất cứ thứ gì) đều không truy cập được từ bên ngoài.
Từ đây có hai quy tắc nên nắm sớm:
- Module tự động chạy ở strict mode. Không cần
'use strict'. thisở cấp cao nhất làundefined, không phải đối tượng toàn cục.
Export có tên: export ngay khi khai báo
Đây là dạng phổ biến nhất. Đặt export trước bất kỳ function, class, const hay let nào và nó sẽ trở thành một phần giao diện công khai của module:
Các tên trong dấu ngoặc nhọn phải khớp chính xác với tên đã export: import { circlearea } sẽ thất bại. Nếu một tên trùng với thứ bạn đã có, hãy đổi tên khi import bằng as:
Bạn cũng có thể liệt kê các export ở cuối tệp thay vì viết ngay tại chỗ; một số người thích cách này vì có một phần "API công khai" rõ ràng:
Cả hai cách cho cùng một kết quả. Hãy chọn một và dùng nhất quán trong dự án.
Export mặc định: một cái cho mỗi module
Một module cũng có thể có một export mặc định duy nhất. Export mặc định dành cho các tệp thật sự chỉ có một thứ chính: một component, một lớp, một đối tượng cấu hình:
Ba điều cần để ý:
- Không có dấu ngoặc nhọn quanh
logở phía import. - Tên được import là tùy bạn.
import shout from './logger.js'cũng chạy y hệt. - Mỗi tệp chỉ có một export mặc định. Thêm cái thứ hai và tệp sẽ không phân tích được.
Export có tên và export mặc định có thể cùng tồn tại:
Export mặc định đứng trước, rồi đến các export có tên trong dấu ngoặc nhọn. Thứ tự này là cố định.
Nên dùng loại nào? Export có tên dễ refactor hơn: đổi tên trong cả codebase chỉ cần một lần tìm và thay thế, vì mọi chỗ import đều dùng cùng một tên. Export mặc định linh hoạt nhưng cho phép mỗi nơi gọi chọn một tên khác nhau, khiến việc tìm kiếm khó hơn. Hầu hết hướng dẫn phong cách hiện đại nghiêng về export có tên và chỉ dành export mặc định cho các module thật sự chỉ có một mục đích.
Import tất cả, export lại, hiệu ứng phụ
Vài dạng import khác bạn sẽ gặp.
Gom mọi export có tên vào một đối tượng namespace:
Export lại từ module khác mà không import vào phạm vi hiện tại:
Mẫu này là cách các tệp khởi đầu của thư viện gom các phần từ tệp nội bộ thành một giao diện công khai.
Và cuối cùng, một câu lệnh import không gán gì cả, dành cho các module chỉ có nhiệm vụ tạo hiệu ứng phụ (polyfill, CSS-in-JS, đăng ký trình xử lý):
Tệp chạy một lần; không có gì được import theo tên.
Import là tĩnh và sống
Hai đặc tính của import đôi khi khiến người ta bất ngờ.
Tĩnh. Các khai báo import được phân giải trước khi bất kỳ dòng code nào của bạn chạy. Bạn không thể đặt chúng trong if, trong hàm hay trong try. Đường dẫn phải là một chuỗi literal, không phải biến. Đó là điều cho phép các công cụ phân tích import mà không cần chạy code: bundler, trình kiểm tra kiểu, công cụ tree shaking đều dựa vào nó.
// Not allowed - SyntaxError.
if (userWantsFancy) {
import { fancy } from './fancy.js';
}
Nếu cần tải theo điều kiện, hãy dùng import() (sẽ nói ngay sau đây).
Sống. Một binding được import là tham chiếu chỉ đọc trỏ về export, không phải một bản chụp. Nếu module export gán lại giá trị, nơi import sẽ thấy giá trị mới:
Bạn cũng không thể gán lại một import ở phía sử dụng: count = 5 trong main.js sẽ ném lỗi. Import là các góc nhìn chỉ đọc.
import() động để tải khi cần
Khi bạn cần quyết định lúc chạy có tải một module hay không (tính năng nặng, code splitting theo route, polyfill theo điều kiện), hãy dùng import() như một hàm. Nó trả về một promise resolve thành các export của module:
Vì đây là một lời gọi hàm bình thường, bạn có thể:
- Await nó bên trong một hàm
async. - Truyền một biến làm đường dẫn.
- Dùng nó bên trong
ifhoặctry/catch.
Destructuring đối tượng nhận được cũng giống như với import tĩnh:
default là khóa của export mặc định khi bạn destructuring. Đổi tên nó thành gì tùy bạn.
Các trường hợp dùng thực tế là code splitting (chỉ gửi thư viện biểu đồ khi người dùng nhấp "hiện biểu đồ"), polyfill theo kết quả kiểm tra tính năng, và các plugin được phát hiện lúc chạy.
Chạy ES module: trình duyệt và Node
Cú pháp ở đâu cũng giống nhau; thứ thay đổi là cách môi trường chạy tìm và tải tệp.
Trên trình duyệt, đánh dấu script khởi đầu là module:
<script type="module" src="./main.js"></script>
Với type="module", trình duyệt hiểu import/export, chạy code ở strict mode và hoãn việc thực thi cho đến khi HTML được phân tích xong. Đường dẫn phải là tương đối (./, ../) hoặc URL tuyệt đối: các tên trần như import 'lodash' không chạy được nếu không có import map hoặc bundler.
Trong Node, có hai cách để bật:
- Đặt tên tệp là
.mjs, hoặc - Đặt
"type": "module"trongpackage.jsongần nhất, khi đó mọi tệp.jsđều là module.
Node cũng yêu cầu đường dẫn đầy đủ kèm đuôi tệp: import './utils.js', không phải import './utils'.
// package.json
{
"type": "module",
"main": "./index.js"
}
Cả hai môi trường đều yêu cầu ghi rõ đuôi tệp trong ESM gốc. Các bundler (Vite, webpack, esbuild) sẽ tự phân giải đường dẫn không có đuôi trong lúc phát triển: tiện lợi, nhưng dựa vào đó nghĩa là mã nguồn của bạn sẽ không chạy được nếu thiếu bước build.
Những lỗi thường gặp
Vài thứ hay khiến người ta vấp:
- Quên
type="module"trên trình duyệt. Thiếu nó,<script>chạy như script thường vàimportlà lỗi cú pháp. - Thiếu đuôi tệp trong Node.
import './utils'thất bại;import './utils.js'chạy được. Bundler che giấu điều này, môi trường chạy gốc thì không. - Mong có
__dirnamehoặcrequiretrong ES module. Những thứ đó chỉ có trong CommonJS. Trong ESM, hãy dùngimport.meta.urlvà chuyển đổi nó khi cần đường dẫn. - Import vòng tròn chạm vào giá trị trước khi chúng sẵn sàng. Hai module import lẫn nhau là hợp lệ, nhưng đọc một export chưa được gán sẽ cho bạn
undefined. Hãy tổ chức code để vòng tròn không bị chạm tới trong lúc khởi tạo, hoặc tách nó ra. - Cố
importtheo điều kiện. Câu lệnhimporttĩnh không cho phép điều đó. Hãy dùngimport()động cho mọi thứ phụ thuộc vào lúc chạy.
Tiếp theo: CommonJS và ESM
ES module là chuẩn, nhưng rất nhiều code Node ngoài thực tế vẫn dùng CommonJS: require, module.exports và một bộ quy tắc khác về thời điểm code chạy. Hiểu cả hai và cách chúng phối hợp với nhau là nội dung của trang tiếp theo.
Câu hỏi thường gặp
Làm sao dùng ES module trong JavaScript?
Export giá trị từ một tệp bằng export hoặc export default, rồi đưa chúng vào tệp khác bằng import. Trên trình duyệt, tải tệp khởi đầu bằng <script type="module" src="main.js"></script>. Trong Node, dùng đuôi .mjs hoặc đặt "type": "module" trong package.json.
Export mặc định và export có tên khác nhau thế nào?
Một module có thể có nhiều export có tên (export function foo() {}) nhưng chỉ một export mặc định (export default ...). Export có tên phải được import đúng tên đó trong dấu ngoặc nhọn: import { foo } from './x.js'. Export mặc định có thể được import với bất kỳ tên nào: import whatever from './x.js'.
import() động trong JavaScript là gì?
import() khi được gọi như một hàm sẽ trả về một promise resolve thành các export của module. Khác với câu lệnh import tĩnh, nó chạy lúc được gọi, nên bạn có thể tải code theo điều kiện hoặc khi cần. Đây là cách triển khai code splitting và lazy loading.
Có cần ghi đuôi tệp trong đường dẫn import không?
Với ES module gốc (trình duyệt và trình tải ESM của Node) thì có. Bạn phải viết ./utils.js, không phải ./utils. Các bundler như Vite và webpack dễ dãi hơn và sẽ tự phân giải đường dẫn không có đuôi, nhưng dựa vào điều đó khiến code của bạn khó mang sang môi trường khác.