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

Tìm hiểu cách các mô-đun CommonJS ảnh hưởng đến việc loại bỏ mã không dùng đến trong ứng dụng

Trong bài đăng này, chúng ta sẽ tìm hiểu về CommonJS và lý do 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 tạo 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, nó được dùng bên ngoài trình duyệt web, chủ yếu là 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ừ các mô-đun đó và nhập các mô-đun đó 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ẽ xuất ra số 3 trong bảng điều khiển.

Do thiếu hệ thống mô-đun chuẩn hoá trong trình duyệt vào đầu những năm 2010, CommonJS cũng trở thành đị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ủ không quan trọng bằng trong trình duyệt, đó là lý do CommonJS không được thiết kế để giảm kích thước gói phát hành công khai. Đồng thời, phân tích cho thấy kích thước gói JavaScript vẫn là lý do hàng đầu khiến ứng dụng trình duyệt chậm hơn.

Trình đóng gói và rút gọn 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. Bằng cách phân tích ứng dụng của bạn tại thời điểm tạo bản dựng, các trình này 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ỉ được chứa hàm add vì đây là biểu tượng duy nhất từ utils.js mà bạn nhập trong 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ế độ phát hành chính thức 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 kết quả, chúng ta sẽ thấy nội dung như sau:

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

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

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));

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. Giờ đây, kích thước là 40 byte với kết quả sau:

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

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 và không có dấu vết nào từ lodash! Hơn nữa, terser (trình rút gọn JavaScript mà webpack sử dụng) đã nội tuyến hàm add trong console.log.

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

Mô-đun CommonJS khó tối ưu hoá hơn trong trường hợp chung vì chúng linh động hơn nhiều so với mô-đun ES. Để đảm bảo trình tạo gói và trình rút gọn 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.

Xin 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ô-đ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ả các mô-đun vào một hàm đóng và cho phé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 trong index.js. Chúng ta cũng xác định một hàm subtract. Chúng ta có thể tạo dự án bằng cách sử dụng cùng một cấu hình webpack như trên, nhưng lần này, chúng ta sẽ tắt tính năng rút gọn:

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 xem kết quả được tạo:

/******/ (() => { // 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 kết quả trên, tất cả các hàm đều nằm trong cùng một không gian tên. Để tránh xung đột, webpack đã đổ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, thì trình rút gọn sẽ:

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

Thường thì các nhà phát triển gọi việc xoá các lệnh nhập không dùng đến là "lắc cây". Việc loại bỏ cây chỉ có thể thực hiện được vì webpack có thể hiểu tĩnh (tại thời điểm tạo bản 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à webpack xuất.

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

Hãy xem xét 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ì video này quá dài nên không thể nhúng trên 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 "thời gian chạy": 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ả cá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 __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())] = () => { … };

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

Theo cách này, trình rút gọn không thể hiểu chính xác index.js sử dụng gì từ các phần phụ thuộc của nó, vì vậy, trình rút gọn không thể loại bỏ phần phụ thuộc đó. Chúng ta cũng sẽ quan sát thấy hành vi tương tự đối với các mô-đun 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.

Loại bỏ mã không dùng đến bằng CommonJS

Rất khó để phân tích các mô-đun CommonJS vì theo định nghĩa, các mô-đun này là động. Ví dụ: vị trí nhập trong các mô-đun ES luôn là một chuỗi cố định, so với CommonJS, nơi đó 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 mục xuất không dùng đến tại thời điểm tạo bản 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ợ tính năng loại bỏ mã thừa, nhưng không bao gồm tất cả các cách mà các phần phụ thuộc có thể sử dụng CommonJS. Điều này có nghĩa là bạn không nhận được các đảm bảo giống như với các mô-đun ES. Ngoài ra, tính năng này còn 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 tạo 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.

Sau đây là một số mẹo hữu ích để xác minh rằng bạn đang đi theo lộ trình 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 phụ thuộc vào các mô-đun không thể loại bỏ cây.