Sự phát triển của Module Frontend: Từ Hàm Global đến ES Modules

Trong phát triển frontend hiện đại, việc sử dụng module là không thể thiếu, với sự góp mặt của các chuẩn như CommonJS, ES Modules và các công cụ đóng gói như Webpack. Ngày nay, mọi thứ từ JavaScript, CSS, hình ảnh đến các tài nguyên khác đều có thể được xem như một module. Vậy, quá trình phát triển của module hóa đã diễn ra như thế nào?

Kiểu hàm Global

Những nỗ lực đầu tiên trong việc chia tách mã nguồn frontend thường dựa vào các hàm toàn cục. Ví dụ, trong file util.js, ta có thể định nghĩa một biến count và một hàm tiện ích formatNumberWithCommas để định dạng số:


var count = 1;
function formatNumberWithCommas(number) {
  if (typeof number !== "number") {
    throw new TypeError("Input must be a number.");
  }
  return number.toLocaleString("en-US");
}

Trong file index.html, file này sẽ được tải bằng thẻ <script>:


<script src="../src/util.js"></script>

Lúc này, các biến và hàm trong util.js sẽ được gắn vào đối tượng toàn cục window. Bạn có thể truy cập hàm formatNumberWithCommas bằng cách nhập window.formatNumberWithCommas vào bảng điều khiển của trình duyệt.

Tuy nhiên, phương pháp này gặp phải một vấn đề nghiêm trọng: nếu có nhiều file JavaScript cùng định nghĩa biến hoặc hàm có tên giống nhau, chúng sẽ ghi đè lên nhau, dẫn đến việc một số biến hoặc hàm trở nên không khả dụng.

Không gian tên Global

Để giải quyết vấn đề xung đột tên hàm toàn cục, người ta bắt đầu đóng gói các module vào bên trong các đối tượng. Ví dụ, trong util.js, ta có thể tạo một đối tượng toàn cục __Util:


window.__Util = {
  count: 1,
  formatNumberWithCommas(number) {
    if (typeof number !== "number") {
      throw new TypeError("Input must be a number.");
    }
    return number.toLocaleString("en-US");
  },
};

Việc đặt tên cho đối tượng toàn cục dài và phức tạp hơn giúp giảm thiểu rủi ro xung đột. Tuy nhiên, thuộc tính bên trong đối tượng vẫn có thể bị thay đổi từ bên ngoài một cách dễ dàng. Ví dụ, nếu bạn gán window.__Util cho một biến d và thay đổi d.count, thì window.__Util.count cũng sẽ bị thay đổi.

IIFE (Hàm tự thực thi)

Để ngăn chặn việc các biến trong module bị tùy ý sửa đổi từ bên ngoài, chúng ta có thể sử dụng IIFE (Immediately Invoked Function Expression) để tạo ra các closure, từ đó thực hiện module hóa. Ví dụ:


(function () {
  var count = 1;
  function formatNumberWithCommas(number) {
    if (typeof number !== "number") {
      throw new TypeError("Input must be a number.");
    }
    return number.toLocaleString("en-US");
  }
  function getCount() {
    return count;
  }
  function setCount(num) {
    count = num;
  }
  window.__Util = {
    formatNumberWithCommas,
    getCount,
    setCount,
  };
})();

Trong trường hợp này, biến count không được xuất trực tiếp. Thay vào đó, chúng ta sử dụng getCount để lấy giá trị và setCount để thay đổi.

Cách này giúp bảo vệ các biến nội bộ khỏi sự can thiệp từ bên ngoài. Tuy nhiên, nếu có nhiều module và chúng có mối quan hệ phụ thuộc lẫn nhau, phương pháp này không hỗ trợ tốt.

IIFE với tùy chỉnh phụ thuộc

Để giải quyết vấn đề IIFE không thể liên kết các module, ta có thể truyền tham số vào IIFE để kết nối chúng. Ví dụ, tạo file verify.js và khai báo nó trong index.html:


(function (global) {
  function isNumber(num) {
    return typeof num === "number";
  }
  global.__Verify = {
    isNumber,
  };
})(window);

Sau đó, sửa đổi file util.js để nhận thuộc tính __Verify được gắn vào global từ verify.js và gọi phương thức isNumber:


(function (global, verifyModule) {
  var count = 1;
  function formatNumberWithCommas(number) {
    if (!verifyModule.isNumber(number)) {
      throw new TypeError("Input must be a number.");
    }
    return number.toLocaleString("en-US");
  }
  function getCount() {
    return count;
  }
  function setCount(num) {
    count = num;
  }
  global.__Util = {
    formatNumberWithCommas,
    getCount,
    setCount,
  };
})(window, window.__Verify);

Mặc dù phương pháp này có thể hỗ trợ module hóa ở một mức độ nhất định, nhưng với số lượng module lớn trong các dự án hiện đại (vài chục đến hàng trăm), cách tiếp cận này trở nên không hiệu quả, đồng thời ảnh hưởng đến khả năng đọc và bảo trì mã nguồn.

CommonJS

Các phương pháp trên đều dựa trên việc triển khai thủ công chức năng module. Tuy nhiên, với sự ra đời của CommonJS, một quy chuẩn chính thức cho module hóa đã hình thành. CommonJS sử dụng module.exports để xuất module và require để tải các module khác, cho phép tương tác giữa các module.

Hãy chuyển đổi các file verify.js và util.js trước đó để tuân thủ quy chuẩn CommonJS:


// verify.js
function isNumber(num) {
  return typeof num === "number";
}
module.exports = {
  isNumber,
};

// util.js
const { isNumber } = require("./verify");
function formatNumberWithCommas(number) {
  if (!isNumber(number)) {
    throw new TypeError("Input must be a number.");
  }
  return number.toLocaleString("en-US");
}
console.log("formatNumberWithCommas", formatNumberWithCommas(123456));

Chạy file util.js bằng dòng lệnh node ./src/util.js sẽ hiển thị kết quả từ console.log.

Quy chuẩn CommonJS được thiết kế cho môi trường server-side, giả định rằng việc tải module luôn diễn ra đồng bộ. Tuy nhiên, trong môi trường trình duyệt, việc tải module đồng bộ này có thể làm chặn giao diện người dùng do độ trễ mạng, ảnh hưởng đến trải nghiệm người dùng.

AMD

AMD (Asynchronous Module Definition) được thiết kế để giải quyết nhu cầu tải module bất đồng bộ trong trình duyệt. Chuẩn AMD sử dụng define để định nghĩa module, return để xuất nội dung module và require để tải các module khác.

Dưới đây là cách chuyển đổi verify.js và util.js sang chuẩn AMD:


// verify.js
define(function () {
  function isNumber(num) {
    return typeof num === "number";
  }
  return {
    isNumber: isNumber,
  };
});

// util.js
define(['verify'], function(verify) {
  function formatNumberWithCommas(number) {
    if (!verify.isNumber(number)) {
      throw new TypeError("Input must be a number.");
    }
    return number.toLocaleString("en-US");
  }
  return {
    formatNumberWithCommas: formatNumberWithCommas
  };
});

Ngoài ra, ta có thể định nghĩa một file index.js để sử dụng các module này:


define(function (require) {
  var util = require("util");
  console.log("formatNumberWithCommas", util.formatNumberWithCommas(123456));
});

Trong trang HTML, có thể sử dụng RequireJS để phân tích cú pháp mã AMD và chỉ định file đầu vào bằng thuộc tính data-main:


<script data-main="../src/index.js" src="https://cdn.bootcdn.net/ajax/libs/require.js/2.3.6/require.js"></script>

Khi mở trang HTML, bạn sẽ thấy kết quả đầu ra trong bảng điều khiển của trình duyệt.

CMD

CMD (Common Module Definition) cải tiến dựa trên AMD, đặc biệt là về cơ chế tải bất đồng bộ và thực thi theo yêu cầu. CMD cũng sử dụng define để định nghĩa module, nhưng xuất module bằng exports thay vì return.

Dưới đây là ví dụ về mã verify.js và util.js theo chuẩn CMD:


// verify.js
define(function (require, exports, module) {
  function isNumber(num) {
    return typeof num === "number";
  }
  exports.isNumber = isNumber;
});

// util.js
define(function (require, exports, module) {
  var verify = require("verify");
  function formatNumberWithCommas(number) {
    if (!verify.isNumber(number)) {
      throw new TypeError("Input must be a number.");
    }
    return number.toLocaleString("en-US");
  }
  exports.formatNumberWithCommas = formatNumberWithCommas;
});

Để chạy mã CMD trong trình duyệt, có thể sử dụng Sea.js. Thêm đoạn mã sau vào file HTML:


<script src="https://cdn.bootcdn.net/ajax/libs/seajs/3.0.3/sea.js"></script>
<script>
  seajs.config({
    alias: {
      verify: "../src/verify",
      util: "../src/util",
    },
  });
  seajs.use(["util"], function (util) {
    console.log(
      "formatNumberWithCommas",
      util.formatNumberWithCommas(123456)
    );
  });
</script>

ES Modules

ES Modules (ESM), một phần của tiêu chuẩn ECMAScript, cung cấp cú pháp gọn gàng hơn cho việc nhập (import) và xuất (export) module, đồng thời hỗ trợ tải động, giúp tăng hiệu quả và linh hoạt trong tương tác module.

Dưới đây là cách viết lại verify.js và util.js bằng ESM:


// verify.js
export function isNumber(num) {
  return typeof num === "number";
}

// util.js
import { isNumber } from "./verify.js";
export function formatNumberWithCommas(number) {
  if (!isNumber(number)) {
    throw new TypeError("Input must be a number.");
  }
  return number.toLocaleString("en-US");
}

Để kiểm thử hàm formatNumberWithCommas, ta định nghĩa file index.js:


// index.js
import { formatNumberWithCommas } from "./util.js";
console.log("formatNumberWithCommas", formatNumberWithCommas(123456));

Trong file index.html, chỉ cần khai báo file index.js với thuộc tính type="module" vì trình duyệt hiện đại đã hỗ trợ ES Module:


<script type="module" src="../src/index.js"></script>

Mặc dù trình duyệt hỗ trợ ES Modules, chúng lại thiếu cơ chế quản lý module hiệu quả. Mỗi module được tải như một file JS riêng biệt, dẫn đến việc phân tán tài nguyên và tăng số lượng yêu cầu HTTP, làm giảm hiệu suất tải trang, đặc biệt đối với các dự án lớn.

Để khắc phục vấn đề này, cộng đồng phát triển đã giới thiệu các công cụ như npm và webpack. npm, một trong những trình quản lý gói phổ biến nhất cho JavaScript, giúp quản lý và tổ chức các module phụ thuộc một cách hiệu quả. Webpack là một công cụ đóng gói module, có khả năng hợp nhất nhiều module và các phần phụ thuộc của chúng thành một hoặc một nhóm các file tối ưu hóa, đồng thời thực hiện các thao tác nén, giảm kích thước file đầu ra, tăng tốc độ tải và cải thiện hiệu suất tổng thể của ứng dụng.

Thẻ: module JavaScript commonjs AMD CMD

Đăng vào ngày 12 tháng 11 lúc 13:29