Cách CommonJS tăng quy mô nhóm

Tìm hiểu cách các mô-đun CommonJS đang tác động đến việc rung chuyển cây của ứng dụng của bạn

Trong bài đăng này, chúng ta sẽ tìm hiểu CommonJS là gì và lý do tại sao điều này khiến các gói JavaScript của bạn lớn hơn mức cần thiết.

Tóm tắt: Để đảm bảo trình đóng gói có thể tối ưu hoá thành công ứng dụng của bạn, hãy tránh phụ thuộc vào các mô-đun CommonJS và sử dụng cú pháp mô-đun ECMAScript trong toàn bộ ứng dụng.

CommonJS là gì?

CommonJS là một tiêu chuẩn từ năm 2009 thiết lập các quy ước cho các mô-đun JavaScript. Ban đầu, tính năng này được dự định sử dụng bên ngoài trình duyệt web, chủ yếu cho các ứng dụng phía máy chủ.

Với CommonJS, bạn có thể xác định các mô-đun, xuất chức năng từ đó và nhập vào các mô-đun khác. Ví dụ: đoạn mã dưới đây xác định một mô-đun xuất 5 hàm: add, subtract, multiply, dividemax:

// utils.js
const { maxBy } = require('lodash-es');
const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

Sau đó, một mô-đun khác có thể nhập và sử dụng một số hoặc tất cả các hàm này:

// index.js
const { add } = require('./utils.js');
console.log(add(1, 2));

Việc gọi index.js bằng node sẽ cho ra số 3 trong bảng điều khiển.

Do thiếu hệ thống mô-đun chuẩn trong trình duyệt vào đầu những năm 2010, CommonJS cũng đã trở thành một định dạng mô-đun phổ biến cho các thư viện phía máy khách JavaScript.

CommonJS ảnh hưởng như thế nào đến kích thước gói cuối cùng của bạn?

Kích thước của ứng dụng JavaScript phía máy chủ của bạn không quan trọng như trong trình duyệt, đó là lý do tại sao CommonJS không được thiết kế để giảm kích thước gói sản xuất. Đồng thời, phân tích cho thấy rằng kích thước gói JavaScript vẫn là lý do số một khiến các ứng dụng trình duyệt chạy chậm hơn.

Các trình đóng gói và trình thu nhỏ JavaScript, chẳng hạn như webpackterser, thực hiện nhiều hoạt động tối ưu hoá để giảm kích thước ứng dụng. Khi phân tích ứng dụng của bạn tại thời điểm xây dựng, chúng sẽ cố gắng xoá nhiều nhất có thể khỏi mã nguồn mà bạn không sử dụng.

Ví dụ: trong đoạn mã trên, gói cuối cùng của bạn chỉ nên bao gồm hàm add vì đây là biểu tượng duy nhất từ utils.js mà bạn nhập vào index.js.

Hãy tạo ứng dụng bằng cấu hình webpack sau:

const path = require('path');
module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  mode: 'production',
};

Ở đây, chúng ta chỉ định rằng chúng ta muốn sử dụng tính năng tối ưu hoá chế độ sản xuất và sử dụng index.js làm điểm truy cập. Sau khi gọi webpack, nếu khám phá kích thước đầu ra, chúng ta sẽ thấy như sau:

$ cd dist && ls -lah
625K Apr 13 13:04 out.js

Lưu ý rằng gói này có kích thước 625KB. Nếu xem xét kết quả, chúng ta sẽ tìm thấy mọi hàm từ utils.js cùng với rất nhiều mô-đun trong lodash. Mặc dù chúng ta không sử dụng lodash trong index.js, nhưng đây lại là một phần của kết quả, điều này làm tăng thêm rất nhiều trọng số cho tài sản sản xuất.

Bây giờ, hãy thay đổi định dạng mô-đun thành mô-đun ECMAScript rồi thử lại. Lần này, utils.js sẽ có dạng như sau:

export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => a / b;

import { maxBy } from 'lodash-es';

export const max = arr => maxBy(arr);

index.js sẽ nhập từ utils.js bằng cú pháp mô-đun ECMAScript:

import { add } from './utils.js';

console.log(add(1, 2));

Khi sử dụng cùng một cấu hình webpack, chúng ta có thể tạo ứng dụng và mở tệp đầu ra. Hiện có kích thước 40 byte với kết quả sau:

(()=>{"use strict";console.log(1+2)})();

Xin lưu ý rằng gói cuối cùng không chứa bất kỳ hàm nào từ utils.js mà chúng ta không sử dụng, cũng như không có dấu vết nào của lodash! Hơn nữa, terser (trình giảm thiểu JavaScript mà webpack sử dụng) cùng dòng hàm add trong console.log.

Một câu hỏi công bằng mà bạn có thể đặt ra là tại sao việc sử dụng CommonJS khiến gói đầu ra lớn hơn gần 16.000 lần? Tất nhiên, đây là một ví dụ về đồ chơi. Trong thực tế, sự khác biệt về kích thước có thể không lớn, nhưng có khả năng CommonJS sẽ tăng thêm trọng lượng đáng kể cho bản dựng sản xuất của bạn.

Trong trường hợp chung, các mô-đun CommonJS khó tối ưu hoá hơn vì chúng linh động hơn nhiều so với mô-đun ES. Để đảm bảo trình đóng gói và trình giảm thiểu có thể tối ưu hoá ứng dụng thành công, hãy tránh phụ thuộc vào các mô-đun CommonJS và sử dụng cú pháp mô-đun ECMAScript trong toàn bộ ứng dụng.

Lưu ý rằng ngay cả khi bạn đang sử dụng các mô-đun ECMAScript trong index.js, nếu mô-đun bạn đang sử dụng là một mô-đun CommonJS, thì kích thước gói của ứng dụng sẽ bị ảnh hưởng.

Tại sao CommonJS làm cho ứng dụng của bạn lớn hơn?

Để trả lời câu hỏi này, chúng ta sẽ xem xét hành vi của ModuleConcatenationPlugin trong webpack, sau đó thảo luận về khả năng phân tích tĩnh. Trình bổ trợ này nối phạm vi của tất cả mô-đun thành một phạm vi đóng, giúp mã của bạn có thời gian thực thi nhanh hơn trong trình duyệt. Hãy xem ví dụ:

// utils.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// index.js
import { add } from './utils.js';
const subtract = (a, b) => a - b;

console.log(add(1, 2));

Ở trên, chúng ta có một mô-đun ECMAScript mà chúng ta nhập vào index.js. Chúng ta cũng xác định hàm subtract. Chúng ta có thể xây dựng dự án bằng cách sử dụng cấu hình webpack như trên, nhưng lần này chúng ta sẽ tắt tính năng giảm thiểu:

const path = require('path');

module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  optimization: {
    minimize: false
  },
  mode: 'production',
};

Hãy cùng xem kết quả đầu ra:

/******/ (() => { // webpackBootstrap
/******/    "use strict";

// CONCATENATED MODULE: ./utils.js**
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

// CONCATENATED MODULE: ./index.js**
const index_subtract = (a, b) => a - b;**
console.log(add(1, 2));**

/******/ })();

Trong dữ liệu đầu ra ở trên, tất cả hàm đều nằm trong cùng một không gian tên. Để tránh xung đột, gói web đã đổi tên hàm subtract trong index.js thành index_subtract.

Nếu trình rút gọn xử lý mã nguồn ở trên, trình rút gọn sẽ:

  • Xoá các hàm không dùng đến subtractindex_subtract
  • Xoá tất cả nhận xét và khoảng trắng thừa
  • Cùng dòng với phần nội dung của hàm add trong lệnh gọi console.log

Thông thường, các nhà phát triển gọi việc xoá các dữ liệu nhập không dùng đến là hành động lắc cây. Chỉ có thể thực hiện được tính năng lắc cây vì gói web có thể hiểu theo cách tĩnh (tại thời điểm xây dựng) những biểu tượng mà chúng ta đang nhập từ utils.js và những biểu tượng mà gói web đó xuất.

Hành vi này được bật theo mặc định cho các mô-đun ES vì các mô-đun này có thể phân tích tĩnh hơn so với CommonJS.

Chúng ta hãy xem chính xác cùng một ví dụ, nhưng lần này thay đổi utils.js để sử dụng CommonJS thay vì các mô-đun ES:

// utils.js
const { maxBy } = require('lodash-es');

const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

Bản cập nhật nhỏ này sẽ thay đổi đáng kể kết quả. Vì quá dài để nhúng vào trang này nên tôi chỉ chia sẻ một phần nhỏ:

...
(() => {

"use strict";
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(288);
const subtract = (a, b) => a - b;
console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__/* .add */ .IH)(1, 2));

})();

Lưu ý rằng gói cuối cùng chứa một số webpack "runtime": mã được chèn chịu trách nhiệm nhập/xuất chức năng từ các mô-đun đi kèm. Lần này, thay vì đặt tất cả ký hiệu từ utils.jsindex.js trong cùng một không gian tên, chúng ta yêu cầu hàm add một cách linh động trong thời gian chạy bằng cách sử dụng __webpack_require__.

Điều này là cần thiết vì với CommonJS, chúng ta có thể lấy tên xuất từ một biểu thức tuỳ ý. Ví dụ: mã dưới đây là một cấu trúc hoàn toàn hợp lệ:

module.exports[localStorage.getItem(Math.random())] = () => { … };

Không có cách nào để trình đóng gói biết được tên của biểu tượng được xuất tại thời điểm tạo bản dựng vì điều này yêu cầu thông tin chỉ có sẵn trong thời gian chạy, trong ngữ cảnh trình duyệt của người dùng.

Bằng cách này, trình rút gọn không thể hiểu chính xác index.js sử dụng những gì từ các phần phụ thuộc nên không thể bỏ qua cây. Chúng tôi cũng sẽ ghi nhận chính xác hành vi tương tự đối với các mô-đun của bên thứ ba. Nếu chúng ta nhập mô-đun CommonJS từ node_modules, thì chuỗi công cụ bản dựng của bạn sẽ không thể tối ưu hoá mô-đun đó đúng cách.

Lắc lư cây cùng CommonJS

Sẽ khó hơn nhiều để phân tích các mô-đun CommonJS vì đây là các mô-đun linh động theo định nghĩa. Ví dụ: vị trí nhập trong các mô-đun ES luôn là giá trị cố định kiểu chuỗi, so với CommonJS, trong đó vị trí là một biểu thức.

Trong một số trường hợp, nếu thư viện bạn đang sử dụng tuân theo các quy ước cụ thể về cách sử dụng CommonJS, thì bạn có thể xoá các tệp xuất không dùng đến tại thời điểm xây dựng bằng cách sử dụng plugin webpack của bên thứ ba. Mặc dù trình bổ trợ này hỗ trợ thêm tính năng rung cây, nhưng không bao gồm tất cả các cách mà phần phụ thuộc của bạn có thể sử dụng CommonJS. Điều này có nghĩa là bạn không nhận được những đảm bảo giống như với các mô-đun ES. Ngoài ra, việc này sẽ làm tăng thêm chi phí trong quá trình xây dựng ngoài hành vi webpack mặc định.

Kết luận

Để đảm bảo trình đóng gói có thể tối ưu hoá thành công ứng dụng của bạn, hãy tránh phụ thuộc vào các mô-đun CommonJS và hãy sử dụng cú pháp mô-đun ECMAScript trong toàn bộ ứng dụng.

Dưới đây là một số mẹo có thể thực hiện để xác minh bạn đang đi đúng hướng tối ưu:

  • Sử dụng trình bổ trợ node-resolve của Rollup.js và đặt cờ modulesOnly để chỉ định rằng bạn chỉ muốn phụ thuộc vào các mô-đun ECMAScript.
  • Sử dụng gói is-esm để xác minh rằng gói npm sử dụng các mô-đun ECMAScript.
  • Nếu đang sử dụng Angular, theo mặc định, bạn sẽ nhận được cảnh báo nếu bạn phụ thuộc vào các mô-đun không thể chuyển đổi bằng cây.