0
0
Lập trình
Sơn Tùng Lê
Sơn Tùng Lê103931498422911686980

📦 Cách Hoạt Động Thực Sự Của JavaScript Imports

Đăng vào 7 tháng trước

• 6 phút đọc

📦 Cách Hoạt Động Thực Sự Của JavaScript Imports (Và Tại Sao Điều Này Quan Trọng Cho Mã Tối Ưu)

Khi bạn mới bắt đầu học JavaScript, câu lệnh import dường như như một phép thuật. Bạn viết:

javascript Copy
import { readFile } from "fs";

Và ngay lập tức, readFile tồn tại. Nhưng thực ra, động cơ JavaScript của bạn (V8, SpiderMonkey, JavaScriptCore, v.v.) đang thực hiện rất nhiều công việc. Đây không chỉ là việc “sao chép-dán mã”. Nó đang xây dựng đồ thị module, phân tích AST, lưu trữ các bản ghi module, và kết nối các liên kết trước khi mã của bạn bắt đầu chạy.

Đây là một phân tích sâu về cách hệ thống nhập khẩu của JavaScript thực sự hoạt động, những gì mà động cơ làm, tại sao việc lưu trữ lại quan trọng, và cách bạn có thể sử dụng kiến thức này để viết mã sạch, nhanh và có thể mở rộng.


🧩 Module Thực Sự Là Gì?

Một module chỉ là một tệp tin, nhưng với một chút khác biệt:

  • Nó có phạm vi riêng không rò rỉ vào biến toàn cục.
  • Nó chạy trong chế độ nghiêm ngặt theo mặc định.
  • Nó xuất ra một tập hợp các liên kết trực tiếp (không phải bản sao!) mà các module khác có thể nhập.

Hãy nghĩ về một module như một hộp tự chứa với đầu vào (import) và đầu ra (export).


🕵️ Điều Gì Xảy Ra Khi Bạn Nhập Một Thứ Gì Đó?

Giả sử bạn có đoạn mã sau:

javascript Copy
import { hello } from "./greetings.js";
console.log(hello("Anik"));

Dưới đây là những gì động cơ JavaScript thực sự làm từng bước:

1. Phân Tích & Xây Dựng Đồ Thị Module

  • Tệp của bạn được phân tích thành một Cây Cú Pháp Trừu Tượng (AST).
  • Tất cả các câu lệnh importexport được thu thập tĩnh trước khi chạy bất kỳ điều gì.
  • Động cơ xây dựng một đồ thị phụ thuộc của tất cả các module.

Đây là lý do tại sao import phải nằm ở cấp độ cao nhất - động cơ cần biết tất cả các phụ thuộc trước khi bắt đầu thực thi.


2. Giải Quyết Các Chỉ Dẫn Module

Động cơ giải quyết "./greetings.js":

  • Nếu nó là tương đối, nó sẽ được giải quyết dựa trên URL tệp hiện tại.
  • Nếu nó là bare (ví dụ react), Node.js sẽ sử dụng thuật toán giải quyết của nó:
    1. Tìm trong node_modules
    2. Kiểm tra package.json cho "exports" hoặc "main"
    3. Quay lại index.js

Nếu không thành công, bạn sẽ nhận được lỗi Cannot find module trước khi mã nào chạy.


3. Lấy & Phân Tích Module

  • Tệp được lấy (từ đĩa trong Node, từ mạng trong trình duyệt).
  • Được phân tích thành một AST.
  • Được lưu trữ trong bộ nhớ dưới dạng một Bản Ghi Module - một cấu trúc dữ liệu chứa:
    • Các liên kết xuất khẩu
    • Các liên kết nhập khẩu (tham chiếu đến các bản ghi module khác)
    • Mã để thực thi sau

4. Khởi Tạo & Kết Nối

  • Động cơ kết nối các tên nhập khẩu với các giá trị xuất khẩu.
  • Quan trọng: đây là các liên kết trực tiếp - nếu một module cập nhật một biến xuất khẩu, mọi importer sẽ thấy giá trị mới.
javascript Copy
// counter.js
export let count = 0;
export function increment() { count++; }
javascript Copy
import { count, increment } from "./counter.js";

console.log(count); // 0
increment();
console.log(count); // 1 (đã cập nhật!)

5. Thực Thi

  • Cuối cùng, động cơ thực thi module từ trên xuống dưới.
  • Các tác động phụ (console.log, kết nối DB, v.v.) xảy ra ngay bây giờ nhưng chỉ một lần.
  • Kết quả được lưu trữ trong Bản Đồ Module (cache).

🧠 Nó Lưu Trữ Ở Đâu?

Các động cơ JavaScript giữ một Bản Đồ Module trong bộ nhớ - về cơ bản là một bản đồ băm từ URL module → bản ghi module.

  • Khi bạn nhập cùng một module lần nữa, động cơ chỉ trả về bản ghi đã được tải trước đó.
  • không được thực thi lại trừ khi bạn xóa bộ nhớ cache một cách thủ công (trong Node thông qua delete require.cache[...]).

Điều này làm cho các import trở nên nhanh chóng trong các lần gọi tiếp theo và cũng ngăn việc thực thi logic thiết lập nhiều lần một cách ngẫu nhiên.


🗄️ Bản Đồ Module Toàn Cục

Mỗi runtime (tab trình duyệt hoặc tiến trình Node) duy trì một bản đồ module toàn cục duy nhất. Đây là lý do:

  • Tải lại trang sẽ xóa tất cả các module (thực thi mới).
  • Trong Node REPL, bạn có thể xóa và tải lại các module bằng tay để xem các thay đổi.

Trong Node:

javascript Copy
delete require.cache[require.resolve("./greetings.js")];
const fresh = require("./greetings.js");

📜 import.meta và Siêu Dữ Liệu Module

Mỗi module có một đối tượng đặc biệt import.meta:

javascript Copy
console.log(import.meta.url); // file:///path/to/greetings.js

Bạn có thể sử dụng điều này để tìm các đường dẫn tương đối với module hiện tại - rất hữu ích trong ESM.


🏗 Cấu Trúc Dự Án & Khả Năng Bảo Trì

Một dự án tốt sử dụng các module để giữ mọi thứ được tổ chức:

plaintext Copy
src/
  utils/
    format.js
    validate.js
  services/
    api.js
  index.js
  • index.js đóng vai trò là điểm vào.
  • Mỗi thư mục nhóm mã liên quan.
  • Bạn có thể có một index.js bên trong utils/ mà tái xuất mọi thứ:
javascript Copy
// utils/index.js
export * from "./format.js";
export * from "./validate.js";

Sau đó:

javascript Copy
import { formatResult, validateInput } from "./utils/index.js";

Điều này giúp bạn có một bề mặt nhập sạch sẽ.


🎭 Import Tĩnh So Với Import Động

Import Tĩnh

  • Được giải quyết và tải trước khi thực thi.
  • Cho phép tree-shaking (các xuất khẩu không sử dụng bị loại bỏ trong các bản dựng sản xuất).
  • Ví dụ:
javascript Copy
import { sqrt } from "./math.js";

Import Động

  • Được tải theo nhu cầu.
  • Trả về một promise.
  • Tuyệt vời cho việc tải lười:
javascript Copy
if (userWantsMath) {
  const math = await import("./math.js");
  console.log(math.sqrt(49));
}

Điều này là cách mà chia nhỏ mã hoạt động trong các bundler hiện đại như Webpack hoặc Vite.


🌀 Nhập Vòng Tròn - Trường Hợp Khó Khăn

Điều gì sẽ xảy ra nếu hai module nhập lẫn nhau? JavaScript có thể xử lý điều này, nhưng với một số quirk:

javascript Copy
// a.js
import { b } from "./b.js";
console.log("a thấy b:", b);
export const a = "A";

// b.js
import { a } from "./a.js";
console.log("b thấy a:", a);
export const b = "B";

Kết quả:

plaintext Copy
b thấy a: undefined
a thấy b: B

Tại sao? Bởi vì các module được kết nối trước, thực thi sau. Khi b.js cố gắng đọc a, a.js vẫn chưa hoàn thành việc chạy, vì vậy nó thấy giá trị hiện tại (chưa được khởi tạo).

Giải pháp: Tránh các phụ thuộc vòng tròn hoặc cấu trúc lại mã để phá vỡ chu kỳ.


🏎️ Hiệu Suất & Tối Ưu Hóa Động Cơ

Các động cơ JS hiện đại (như V8 trong Chrome/Node) thực hiện rất nhiều để làm cho các import nhanh:

  • Phân tích một lần và lưu trữ các AST.
  • Lưu trữ bytecode - lưu trữ bytecode đã biên dịch để lần tải tiếp theo bỏ qua phân tích.
  • Biên dịch suy đoán - biên dịch các hàm nóng sớm.
  • Tree-shaking - loại bỏ mã chết trong các bản dựng (được xử lý bởi các bundler).

Bạn có thể kiểm tra bytecode trong Node với:

javascript Copy
node --print-bytecode app.js

(Cảnh báo: Kết quả rất kỹ thuật, nhưng thú vị khi thấy!)


🎯 Những Điểm Chính

  • Các import được giải quyết, kết nối và lưu trữ trước khi thực thi bắt đầu.
  • Các module có phạm vi riêng, chạy trong chế độ nghiêm ngặt, và chỉ thực thi một lần.
  • Động cơ lưu trữ các module trong một Bản Đồ Module, tái sử dụng chúng trong các lần nhập sau.
  • import.meta cung cấp siêu dữ liệu cụ thể cho module.
  • Sử dụng import tĩnh cho các phụ thuộc cốt lõi, import động cho việc tải lười.
  • Tránh các import vòng tròn nếu không bạn sẽ gặp phải các vấn đề khởi tạo một phần.
  • Tổ chức mã thành các cấu trúc mô-đun sạch sẽ để dễ bảo trì.

Hiểu cách hoạt động của các import không chỉ là lý thuyết - nó giúp bạn khắc phục sự cố nhanh hơn, ngăn ngừa các lỗi kỳ lạ (như khởi tạo một phần), và viết mã có thể mở rộng khi dự án của bạn phát triển.

Gợi ý câu hỏi phỏng vấn
Không có dữ liệu

Không có dữ liệu

Bài viết được đề xuất
Bài viết cùng tác giả

Bình luận

Chưa có bình luận nào

Chưa có bình luận nào