Bóc Mẽ Cú Pháp var module: NodeModule;: Bí Mật "Chiếc Hộp" Wrapper Và Cú Lừa Kinh Điển Giữa exports và module.exports
Chào anh em Viblo! 👋
Trong bài viết trước, chúng ta đã cùng nhau "lột trần" thân phận của var require: NodeRequire; và biết được những ma thuật quản lý cache đằng sau nó. Hôm nay, chúng ta sẽ tiếp tục đi tìm câu trả lời cho nửa mảnh ghép còn lại của hệ thống CommonJS. Đó chính là đối tượng giúp chúng ta mang code đi "phát tán" khắp nơi: module.
Nếu anh em mở file định nghĩa gốc của Node.js trong TypeScript (@types/node), anh em sẽ bắt gặp dòng khai báo này nằm ngay cạnh require:
declare var module: NodeModule;
Lại một lần nữa, các kỹ sư Core của Node.js dùng từ khóa var toàn cục. Và nếu lật xem interface NodeModule, bạn sẽ thấy nó chứa đầy những thuộc tính siêu ẩn dật như id, filename, loaded, children... chứ không chỉ có mỗi thuộc tính exports mà chúng ta vẫn gõ hàng ngày.
Hôm nay, hãy cùng mình mổ xẻ "chiếc hộp" NodeModule này để hiểu cách Node.js cô lập code của từng file, và đặc biệt là giải mã cú lừa kinh điển nhất mọi thời đại khiến bao thế hệ Junior phải thức đêm tìm bug: Sự khác nhau giữa exports và module.exports.
1. Bản chất của module và Trò ảo thuật "Module Wrapper"
Trong JavaScript nguyên thủy ở trình duyệt ngày xưa, nếu bạn khai báo một biến const a = 10; ở file A, thì sang file B bạn vẫn có thể gọi biến a đó một cách bình thường vì tất cả đều bị ném chung vào một cái "nồi lẩu thập cẩm" gọi là Global Scope.
Nhưng trong Node.js, mỗi file là một thế giới hoàn toàn cô lập. Biến bạn khai báo ở file này, file khác không cách nào sờ vào được trừ khi bạn chủ động export. Làm sao Node.js làm được điều kỳ diệu đó?
Thực chất, trước khi Node.js đem file code của bạn đi chạy, V8 Engine ngầm thực hiện một cú lừa: Nó bọc toàn bộ nội dung file của bạn vào bên trong một cái hàm ẩn danh gọi là Module Wrapper Function có cấu trúc như sau:
(function(exports, require, module, __filename, __dirname) {
// 📦 TOÀN BỘ CODE CỦA BẠN SẼ NẰM Ở ĐÂY!
const a = 10;
module.exports = { a };
});
Chính cái hàm bọc ngầm này là câu trả lời cho câu hỏi: "Biến module từ đâu chui ra mà em không cần import hay require vẫn xài được?". Bản chất module chính là một tham số (argument) được Node.js tiêm (inject) vào file của bạn thông qua cái hàm bọc này.
2. Mổ xẻ "Nội tạng" của interface NodeModule
Hãy cùng soi xem bên trong cái object module (kiểu NodeModule) mà Node.js tiêm vào file của bạn có những gì:
interface NodeModule {
exports: any;
id: string;
filename: string;
loaded: boolean;
paths: string[];
// ... và một số thuộc tính legacy khác như parent, children
}
- id và filename (Định danh): filename chứa đường dẫn tuyệt đối của file hiện tại trên ổ cứng. Mặc định, id cũng chính là cái đường dẫn tuyệt đối đó. Node.js dùng id này làm Key để lưu vào bộ đệm require.cache mà chúng ta đã học ở bài trước.
- loaded: boolean (Trạng thái): Trả về false nếu file đang trong quá trình đọc và chạy code, trả về true ngay sau khi file chạy xong dòng code cuối cùng.
Ứng dụng thực chiến: Thuộc tính này cực kỳ hữu ích để debug các pha Circular Dependency (Vòng lặp phụ thuộc). Nếu file A require file B, file B lại ngược ngạo require lại file A, Node.js sẽ không bị treo crash app, mà nó sẽ trả về một object exports chưa hoàn thiện của file A (lúc này module.loaded của file A vẫn là false).
- paths: string[] (Bản đồ săn tìm thư viện): Chứa danh sách các thư mục node_modules xếp theo thứ tự ưu tiên từ thư mục hiện tại ngược dần lên gốc ổ đĩa. Khi bạn require('lodash'), Node.js sẽ lần lượt lục lọi trong cái sớ danh sách paths này để tìm xem Lodash nằm ở đâu.
3. Vết sẹo thực chiến: Cú lừa kinh điển mang tên exports vs module.exports
Đây là cái bẫy phỏng vấn hạ gục nhiều ứng viên nhất, và cũng là nguồn cơn của những pha bug "lác mắt" ở môi trường thực tế.
Nhìn lại cái hàm bọc Module Wrapper, bạn sẽ thấy Node.js truyền vào cả tham số exports lẫn tham số module. Về cơ bản, lúc ban đầu, Node.js thiết lập:
// Cánh gà của Node.js thiết lập ngầm:
exports = module.exports = {};
Nghĩa là exports chỉ là một tham chiếu (shorthand pointer) trỏ chung vào một vùng nhớ với module.exports.
Con bug chết người: Gán lại giá trị cho exports Vì nghĩ exports và module.exports là như nhau, nhiều bạn Junior đã viết code như thế này:
// ❌ FILE: userService.js - CODE SAI LẦM
const login = () => console.log('Login...');
const logout = () => console.log('Logout...');
// Tiến hành gán lại object cho exports vì nghĩ cho nó ngắn gọn
exports = { login, logout };
Khi sang file khác require('./userService'), hệ thống lập tức bắn lỗi: TypeError: userService.login is not a function. Bạn check đi check lại file code rõ ràng là có viết hàm login cơ mà?
Tại sao lại toang? Hãy nhớ lại bản chất toán học về con trỏ vùng nhớ của JavaScript: Khi bạn viết exports = { ... }, bạn đã cắt đứt sợi dây liên kết của biến exports với vùng nhớ gốc, gán cho nó một vùng nhớ mới tinh. Trong khi đó, cái mà Node.js lôi ra để trả về cho hàm require ở file khác luôn luôn là module.exports. Lúc này, module.exports vẫn là một object rỗng {} ban đầu, toàn bộ hàm login, logout của bạn đã bị bỏ rơi hoàn toàn cùng biến exports!
Bốc thuốc chuẩn Senior Để không bao giờ dính phải con bug ngớ ngẩn này, hãy tuân thủ quy tắc sau:
- Cách 1: Chỉ trung thành với module.exports (An toàn nhất, khuyên dùng)
// Cứ gán thẳng vào module.exports, không sợ đứt tham chiếu
module.exports = {
login,
logout
};
- Cách 2: Nếu muốn dùng exports ngắn gọn, chỉ được đính kèm thuộc tính (Mutate), tuyệt đối không gán lại (Reassign)
// Cách này hợp lệ vì không làm mất tham chiếu gốc của module.exports
exports.login = login;
exports.logout = logout;
4. Góc nhìn thời đại: Sự thoái trào của module trong ES Modules (ESM)
Cũng giống như require, khi thế giới JavaScript dịch chuyển sang chuẩn ES Modules (import/export), đối tượng module và cơ chế Module Wrapper Function của CommonJS không còn được áp dụng nữa.
Trong môi trường ESM thuần chủng, nếu bạn cố tình gọi biến module hoặc module.exports, hệ thống sẽ ném lỗi ReferenceError: module is not defined. Thay vào đó, để lấy các thông tin meta dữ liệu của file (như filename hay id), ESM cung cấp một đối tượng mới tinh gọi là import.meta:
// Cú pháp thời đại mới (ESM) thay thế cho module.filename
console.log(import.meta.url);
// 👉 Trả về: file:///Users/project/src/app.js
Đúc Kết Lại
Khai báo declare var module: NodeModule; tuy ngắn ngủi nhưng lại chứa đựng toàn bộ triết lý cô lập module tinh tế của Node.js thời kỳ đầu. Việc hiểu rõ cơ chế Module Wrapper và bản chất tham chiếu của thuộc tính exports sẽ giúp bạn viết code một cách tự tin, không bao giờ phải mất hàng giờ đồng hồ đi tìm những con bug mất tích dữ liệu export một cách vô lý.
Hy vọng chuỗi bài viết mổ xẻ file core này mang lại cho anh em một nền tảng kiến thức vững chắc để tự tin làm chủ mọi hệ thống Node.js/TypeScript. Chúc anh em ứng dụng thành công! Happy Coding! 🚀💻
All rights reserved