+3

Node.js: Đi sâu vào những bí mật của mô-đun CommonJS

1729682070849.jpg Trước khi Node.js ra đời, JavaScript phía máy chủ hầu như chưa được phát triển, và không có thông số kỹ thuật module ES6. Do đó, Node.js đã áp dụng một thông số kỹ thuật module tiên tiến hơn vào thời điểm đó, đó là CommonJS, thường được viết tắt là CJS.

Thông số kỹ thuật CommonJS

Vấn đề trước CommonJS

Trước khi áp dụng thông số kỹ thuật CommonJS, Node.js đã gặp phải một số vấn đề:

  • Thiếu hệ thống module
  • Thư viện chuẩn hạn chế
  • Không có giao diện chuẩn
  • Thiếu hệ thống quản lý gói Những vấn đề này đã khiến việc xây dựng các dự án lớn trên Node.js trở nên khó khăn và dẫn đến một hệ sinh thái kém cần cải thiện. CommonJS đã được đề xuất để giải quyết vấn đề thiếu tiêu chuẩn module trong JavaScript, cho phép nó xây dựng các ứng dụng lớn như Java, Python và Ruby, thay vì chỉ là một ngôn ngữ kịch bản. Hệ sinh thái phát triển mạnh mẽ của Node.js ngày nay có nhiều công lao từ CommonJS.

1.1 Thông số kỹ thuật module CommonJS

CommonJS định nghĩa các module một cách đơn giản, tập trung vào tham chiếu, định nghĩa và xác định module.

1.1.1 Tham chiếu module

Ví dụ:

const fs = require('fs');

Trong CommonJS, có một hàm require toàn cục nhận một định danh và nhập API tương ứng của module vào phạm vi của module hiện tại.

1.1.2 Định nghĩa module

Trong Node.js, một đối tượng module và một đối tượng exports được cung cấp. module đại diện cho module hiện tại, và exports là một thuộc tính của module hiện tại đại diện cho các API sẽ được xuất. Một tệp là một module, và các phương thức hoặc biến có thể được gắn vào đối tượng exports để làm cho chúng trở thành một phần của module.

// add.js
exports.add = function(a, b) {
    return a + b;
};

Trong một tệp khác, bạn có thể nhập module này bằng cách sử dụng require:

const { add } = require('./add.js');
add(1, 2); // Xuất 3

1.1.3 Xác định module

Một định danh module là tham số được truyền vào hàm require, đó là ID của module trong Node.js. Nó phải là một chuỗi theo kiểu camelCase, hoặc một đường dẫn tương đối bắt đầu bằng . hoặc .., hoặc một đường dẫn tuyệt đối, và có thể không có phần mở rộng tệp. Định nghĩa rất đơn giản, và giao diện rõ ràng. Ý nghĩa của nó nằm ở việc giới hạn các phương thức và biến liên quan trong một phạm vi riêng tư trong khi hỗ trợ chức năng nhập và xuất để kết nối liền mạch các phụ thuộc phía trên và phía dưới. Cơ chế xuất và nhập module của CommonJS đảm bảo rằng người dùng không phải lo lắng về ô nhiễm biến.

2. Triển khai Module Node.js

Node.js không tuân theo nghiêm ngặt thông số kỹ thuật CommonJS mà đã thực hiện một số điều chỉnh và thêm các tính năng khi cần thiết. Hãy cùng khám phá cách Node.js triển khai thông số kỹ thuật CommonJS.

Khi nhập một module trong Node.js, nó trải qua ba bước:

  • Phân tích đường dẫn
  • Định vị tệp
  • Biên dịch và thực thi

Khái niệm cốt lõi

  • Module cốt lõi: Các module tích hợp sẵn được cung cấp bởi Node.js, chẳng hạn như fs, url, http, v.v.
  • Module tệp: Các module do người dùng tạo, chẳng hạn như Koa, Express, v.v. Các module cốt lõi được biên dịch thành các tệp nhị phân trong quá trình biên dịch mã nguồn Node.js và được tải trực tiếp vào bộ nhớ khi khởi động. Do đó, khi bạn nhập các module này, bạn bỏ qua các bước định vị tệp và biên dịch, khiến chúng tải nhanh hơn nhiều so với các module tệp.

Các module tệp được tải động tại thời gian chạy và yêu cầu một quy trình đầy đủ: phân tích đường dẫn, định vị tệp, biên dịch và thực thi. Do đó, chúng tải chậm hơn so với các module cốt lõi.

2.1 Tải từ bộ nhớ cache trước

Trước khi đi sâu vào các bước tải, điều quan trọng là lưu ý rằng Node.js lưu trữ các module đã được tải một lần vào bộ nhớ cache. Nội dung của module được lưu trữ trong bộ nhớ, vì vậy nếu cùng một module được tải lại, nó sẽ được lấy trực tiếp từ bộ nhớ, bỏ qua phân tích đường dẫn, định vị tệp và thực thi, làm tăng tốc độ đáng kể. Cả module cốt lõi và module tệp đều sử dụng tải từ bộ nhớ cache trước, nhưng các module cốt lõi được kiểm tra trước các module tệp.

Hàm require trong các tệp Node.js được định nghĩa trong lib/internal/modules/cjs/loader.js như là Module.prototype.require, với một lớp bao bọc bổ sung trong hàm makeRequireFunction. Dưới đây là mã nguồn của Module.prototype.require:

Module.prototype.require = function(id) {
    validateString(id, 'id');
    if (id === '') {
        throw new ERR_INVALID_ARG_VALUE('id', id, 'must be a non-empty string');
    }
    requireDepth++;
    try {
        return Module._load(id, this, /* isMain */ false);
    } finally {
        requireDepth--;
    }
};

Cuối cùng, nó sử dụng phương thức Module._load để tải module được chỉ định bởi định danh. Đây là Module._load:

Module._cache = Object.create(null);

// Kiểm tra bộ nhớ cache cho tệp được yêu cầu.
Module._load = function(request, parent, isMain) {
    let relResolveCacheIdentifier;
    if (parent) {
        const filename = relativeResolveCache[relResolveCacheIdentifier];
        if (filename !== undefined) {
            const cachedModule = Module._cache[filename];
            if (cachedModule !== undefined) {
                updateChildren(parent, cachedModule, true);
                return cachedModule.exports;
            }
            delete relativeResolveCache[relResolveCacheIdentifier];
        }
    }
    const filename = Module._resolveFilename(request, parent, isMain);
    const cachedModule = Module._cache[filename];
    if (cachedModule !== undefined) {
        updateChildren(parent, cachedModule, true);
        return cachedModule.exports;
    }
    const mod = loadNativeModule(filename, request, experimentalModules);
    if (mod && mod.canBeRequiredByUsers) return mod.exports;
    const module = new Module(filename, parent);
    if (isMain) {
        process.mainModule = module;
        module.id = '.';
    }
    Module._cache[filename] = module;
    if (parent !== undefined) {
        relativeResolveCache[relResolveCacheIdentifier] = filename;
    }
    let threw = true;
    try {
        module.load(filename);
        threw = false;
    } finally {
        if (threw) {
            delete Module._cache[filename];
            if (parent !== undefined) {
                delete relativeResolveCache[relResolveCacheIdentifier];
            }
        }
    }
    return module.exports;
};

Node.js đầu tiên xác định đường dẫn và tên tệp từ thông tin module, sau đó kiểm tra xem tệp đã được lưu vào bộ nhớ cache hay chưa bằng cách sử dụng tên tệp làm khóa trong đối tượng Module._cache. Nếu đã được lưu, nó trả về thuộc tính exports của đối tượng đã lưu. Nếu không, nó lại xác định tên tệp bằng cách sử dụng Module._resolveFilename và kiểm tra bộ nhớ cache một lần nữa. Nếu vẫn không tìm thấy, nó cố gắng tải nó như một module cốt lõi bằng cách sử dụng phương thức loadNativeModule.

Nếu không có bước nào tìm thấy module, một đối tượng module mới được xây dựng bằng cách sử dụng constructor Module. Khi đã được tải, nó được lưu vào đối tượng Module._cache cho các yêu cầu trong tương lai.

2.2 Phân tích đường dẫn và định vị tệp

Trong Node.js, phân tích đường dẫn và định vị tệp được xử lý bởi phương thức Module._resolveFilename. Phương thức này xác định đường dẫn đầy đủ của tệp module dựa trên định danh module đã cung cấp và đường dẫn module hiện tại.

Phân tích đường dẫn

  • Module cốt lõi: Nếu định danh module là tên của một module cốt lõi (ví dụ: fs, http), Module._resolveFilename sẽ trả về trực tiếp tên module cốt lõi mà không cần phân tích thêm. Định vị tệp Đối với các module tệp, Node.js thực hiện các bước sau để định vị tệp:

  • Đường dẫn tương đối: Nếu định danh bắt đầu bằng ./ hoặc ../, Node.js coi đó là một đường dẫn tương đối, giải quyết từ thư mục của module hiện tại.

  • Đường dẫn tuyệt đối: Nếu định danh bắt đầu bằng /, Node.js coi đó là một đường dẫn tuyệt đối.

  • Đường dẫn module: Nếu định danh không bắt đầu bằng . hoặc /, Node.js coi đó là một đường dẫn module và tìm kiếm trong các thư mục node_modules. Node.js cố gắng thêm các phần mở rộng .js, .json và .node để khớp với một tệp tồn tại.

2.3 Biên dịch và thực thi

Khi việc định vị tệp hoàn tất, Node.js chọn một chiến lược biên dịch và thực thi dựa trên phần mở rộng tệp:

  • Tệp JavaScript: Được đọc bằng cách sử dụng module fs và được thực thi bằng cách bọc nội dung trong một hàm sử dụng module vm.
  • Tệp JSON: Được đọc bằng cách sử dụng module fs và phân tích bằng JSON.parse.
  • Mở rộng C/C++: Được tải và thực thi bằng cách sử dụng process.dlopen. Node.js bọc nội dung module trong một hàm để cung cấp sự cách ly phạm vi module. Hàm này nhận các tham số exports, require, module, __filename, và __dirname, cho phép module sử dụng các biến này bên trong.

3. Tối ưu hóa và mở rộng tải module

3.1 Lưu trữ module

Như đã đề cập, Node.js sử dụng Module._cache để lưu trữ các module đã tải, cải thiện tốc độ tải. Cơ chế lưu trữ đảm bảo rằng mỗi tệp module, một khi đã được tải, có thể được truy xuất từ bộ nhớ cache cho các yêu cầu tải sau, tránh việc tải lại không cần thiết.

3.2 Mở rộng tải module

Node.js cho phép người dùng tùy chỉnh hành vi tải module thông qua require.extensions. Mặc dù không được khuyến nghị cho môi trường sản xuất, nó có thể được sử dụng trong các tình huống cụ thể để tải các định dạng tệp tùy chỉnh.

require.extensions['.txt'] = function(module, filename) {
    const content = fs.readFileSync(filename, 'utf8');
    module.exports = content;
};

Mã trên minh họa việc mở rộng tải các tệp .txt, cho phép các tệp văn bản được nhập bằng cách sử dụng require.

3.3 Bọc và phạm vi

Trong Node.js, mã của mỗi module được bọc trong một hàm, cung cấp sự cách ly phạm vi module và ngăn ngừa ô nhiễm phạm vi toàn cục. Bộ bọc module tương tự như:

(function(exports, require, module, __filename, __dirname) {
    // Mã module ở đây
});

Cơ chế này đảm bảo mỗi module có phạm vi riêng tư trong khi xuất giao diện module thông qua đối tượng exports.

4. Hỗ trợ nhập khẩu động và ES Module

4.1 Nhập khẩu động

Node.js hỗ trợ nhập khẩu động thông qua hàm import(), cho phép bạn tải module trong thời gian chạy. Điều này rất hữu ích trong các tình huống mà bạn cần tải module chỉ khi cần thiết, giúp tối ưu hóa hiệu suất và giảm thời gian khởi động.

async function loadModule() {
    const module = await import('./myModule.js');
    module.doSomething();
}

Hàm import() trả về một Promise, vì vậy bạn có thể sử dụng async/await để xử lý các module được nhập khẩu động.

4.2 Hỗ trợ ES Module

Node.js đã bổ sung hỗ trợ cho ES Module (ESM) từ phiên bản 12 trở đi. Điều này cho phép bạn sử dụng cú pháp import và export tương tự như trong trình duyệt.

Để sử dụng ES Module, bạn có thể đặt phần mở rộng tệp là .mjs hoặc thêm "type": "module" vào tệp package.json của bạn. Dưới đây là ví dụ về cách sử dụng ES Module:

Tệp module.mjs:

export const greeting = 'Hello, world!';

Tệp main.mjs:

import { greeting } from './module.mjs';
console.log(greeting);

4.3 Tương tác giữa CommonJS và ES Module

Mặc dù Node.js hỗ trợ cả CommonJS và ES Module, việc tương tác giữa hai loại module này có thể gặp một số vấn đề. Khi bạn cố gắng nhập khẩu một module CommonJS từ một module ES, bạn cần sử dụng cú pháp default:

// Trong tệp ES Module
import pkg from './commonjs-module.js';
const { method } = pkg.default;

Ngược lại, khi bạn nhập khẩu một module ES từ một module CommonJS, bạn có thể sử dụng require nhưng cần đảm bảo rằng module ES đã được biên dịch và có thể được sử dụng.

// Trong tệp CommonJS
const { greeting } = require('./esm-module.mjs').default;
console.log(greeting);

4.4 Kết luận

Node.js cung cấp một hệ thống module mạnh mẽ và linh hoạt, cho phép bạn tổ chức mã của mình một cách hiệu quả. Với việc hỗ trợ cả CommonJS và ES Module, bạn có thể chọn phương pháp phù hợp nhất cho dự án của mình. Sự phát triển liên tục và cải tiến trong Node.js đảm bảo rằng bạn luôn có thể tận dụng các tính năng mới nhất và tối ưu hóa hiệu suất ứng dụng của mình.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí