Module Javascript phần 2: Đóng gói module

Bài viết được dịch từ bài gốc: Javascript Modules Part 2: Module Bundling của tác giả Preethi Kasireddy

Trong phần 1 của bài viết này, tôi đã giới thiệu về các module, tại sao các lập trình viên sử dụng chúng, và rất nhiều cách để tích hợp chúng vào chương trình của bạn.

Trong phần 2 này, tôi sẽ giải thích ý nghĩa của việc đóng gói module: tại sao chúng ta đóng gói các module, các cách khác nhau để làm việc đó, và tương lai của module trong phát triển web.

Đóng gói module là gì?

Tổng quát, đóng gói module đơn giản là quá trình ghép một tập các module (và các phụ thuộc của chúng) vào thành một file duy nhất (hay một tập các file) theo đúng thứ tự.

Tại sao lại đóng gói các module?

Khi bạn chia chương trình của mình thành các module, thông thường bạn sẽ tổ chức các module này thành các file và thư mục khác nhau. Và bạn cũng có thể có một tập các module dành cho các thư viện bạn sử dụng, như Underscore hay React.

Kết quả là, mỗi file đó phải được tham chiếu đến trong file HTML chính của bạn trong những thẻ <script>, sau đó được trình duyệt tải về khi một người dùng ghé thăm trang của bạn. Với những thẻ <script> riêng cho mỗi file như vậy, trình duyệt sẽ phải tải từng...file...một.

...một tin xấu đối với thời gian chờ tải trang.

Để giải quyết vấn đề này, chúng ta đóng gói, hay "ghép" tất cả các file của mình thành một file lớn (hay một vài file nếu cần) để giảm số lượng request. Khi bạn nghe các lập trình viên nói về "build step" hay "build process", thì đây là điều mà họ nói đến.

Một phương pháp thông thường khác giúp tăng tốc quá trình đóng gói là "làm nhỏ" code được đóng gói. Làm nhỏ là quá trình loại bỏ các ký tự không cần thiết ra khỏi mã nguồn (ví dụ như khoảng trắng, comment, ký tự xuống dòng, v..v..) để giảm kích thước của file mà không làm thay đổi chức năng của mã nguồn.

Dữ liệu ít hơn nghĩa là thời gian xử lý của trình duyệt cũng ít hơn, tức là làm giảm thời gian tải file. Nếu bạn từng thấy một file có phần mở rộng ở tên file là "min" như "underscore-min.js", bạn có thể nhận thấy rằng phiên bản được làm nhỏ đó rất nhỏ (và không thể đọc được) so với phiên bản đầy đủ.

Các chương trình thực thi tác vụ như Gulp và Grunt giúp ghép và làm nhỏ file cho lập trình viên, đảm bảo rằng code có thể đọc được được cung cấp cho lập trình viên trong khi code tối ưu cho máy được đóng gói cho trình duyệt.

Có những cách đóng gói module nào?

Việc nối và làm nhỏ các file của bạn sẽ hoạt động tốt khi bạn sử dụng một trong những mẫu module chuẩn (được thảo luận ở bài viết trước) để định nghĩa các module của mình. Tất cả những việc bạn thực sự làm là trộn lẫn một đống code Javascript thuần với nhau.

Tuy nhiên, nếu bạn tuân theo các hệ thống module không tự nhiên mà trình duyệt không thể thông dịch như CommonJS hay AMD (hay thậm chí là dạng module tự nhiên ES6), bạn sẽ cần đến một công cụ chuyên dùng để chuyển đổi các module của mình thành những đoạn code đúng thứ tự và hoạt động được với trình duyệt. Đó là lúc mà Browserify, RequireJS, Webpack và các "chương trình đóng gói module" hay "chương trình nạp module" được dùng đến.

Ngoài việc đóng gói và/hoặc nạp các module của bạn, các chương trình đóng gói module còn cung cấp một đống các tính năng đi kèm như tự biên dịch ngược code khi bạn thay đổi hay sinh source map để debug.

Hãy cùng xem một vài phương pháp đóng gói module thông thường:

Đóng gói CommonJS

Như bạn biết ở Phần 1, CommonJS nạp các module một cách đồng bộ, điều này ổn ngoại trừ việc nó không tốt đối với trình duyệt. Tôi đã nói rằng có một cách giải quyết - đó là việc sử dụng một chương trình đóng gói tên là Browserify. Browserify là một công cụ biên dịch các module CommonJS cho trình duyệt.

Ví dụ, giả sử bạn có file main.js import một module để tính trung bình của một mảng các số:

var myDependency = require(‘myDependency’);

var myGrades = [93, 95, 88, 0, 91];

var myAverageGrade = myDependency.average(myGrades);

Trong trường hợp này, chúng ta có một phụ thuộc (myDependency). Sự dụng lệnh bên dưới, Browserify đóng gói một cách đệ quy tất cả các module cần thiết bắt đầu từ main.js thành một file duy nhất tên là bundle.js:

browserify main.js -o bundle.js

Browserify thực hiện việc này bằng cách phân tích AST với mỗi lời gọi require để đi qua toàn bộ đồ thị phụ thuộc trong dự án của bạn. Một khi nó tìm ra cấu trúc của các phụ thuộc, nó sẽ đóng gói chúng theo đúng thứ tự vào một file duy nhất. Đến lúc này, tất cả những việc bạn cần làm là thêm một thẻ <script> dành cho file "bundle.js" của bạn vào file html để đảm bảo rằng tất cả mã nguồn của bạn được tải trong một HTTP request.

Tương tự, nếu bạn có nhiều file với nhiều phụ thuộc, bạn đơn giảm chỉ cần cho Browserify biết danh sách các file và ngồi chờ trong khi nó thực hiện ma thuật của mình.

Sản phẩm cuối cùng: các file được đống gói đã được chuẩn bị và sẵn sàng cho các công cụ như Minify-JS để làm nhỏ code đã được đóng gói.

Đóng gói AMD

Nếu bạn dùng AMD, bạn sẽ muốn sử dụng một chương trình nạp AMD như RequireJS hay Curl. Một chương trình nạp module (khác với một chương trình đóng gói) nạp các module mà chương trình của bạn cần để chạy một cách động.

Nhắc lại rằng, một trong những điểm khác biệt chính của AMD so với CommonJS là nó nạp các module một cách bất đồng bộ. Trong trường hợp này, với AMD, bạn không thực sự cần bước đóng gói các module của mình thành một file do bạn nạp các module một cách bất đồng bộ - nghĩa là bạn dần dần tải các file cần thiết để chạy chương trình thay vì tải toàn bộ các file cùng một lúc khi người dùng lần đầu tiên ghé thăm trang của bạn.

Tuy nhiên trên thực tế, chi phí của những request khối lượng lớn theo thời gian với mỗi hành động của người dùng không có nhiều ý nghĩa ở production. Hầu hết các lập trình viên web vẫn sử dụng công cụ để đóng gói và làm nhỏ các module AMD của họ để tối ưu hiệu năng với các công cụ như chương trình tối ưu hóa RequireJS, r.js.

Tóm lại, sự khác biệt giữa AMD và CommonJS khi đóng gói là: trong quá trình phát triển, các ứng dụng AMD có thể tránh được build step. Khi bạn cho code chạy thật, các công cụ tối ưu hóa như r.js có thể xử lý việc đó.

Bạn có thể xem bài viết ở blog của Tom Dale để xem một thảo luận thú vị về CommonJS và AMD.

Webpack

Các chương trình đóng gói đã đi một chặng đường dài, còn Webpack thì chỉ là lính mới. Nó được thiết kế để không phân biệt hệ thống module mà bạn sử dụng, cho phép lập trình viên dùng CommonJS, AMD hay ES6 theo nhu cầu.

Bạn có thể tự hỏi rằng tại sao chúng ta cần Webpack khi mà đã có các chương trình đóng gói như Browserify và RequireJS làm được việc và làm rất tốt. Chà, Webpack cung cấp một số tính năng hữu ích như "phân tách code" - một cách chia code của bạn thành nhiều "mảnh" được tải theo nhu cầu.

Ví dụ, nếu bạn có một ứng dụng web với nhiều khối code chỉ cần ở những hoàn cảnh nhất định, việc đưa toàn bộ code vào một file được đóng gói lớn sẽ trở nên không hiệu quả. Trong trường hợp này, bạn có thể sử dụng tính năng phân tách code để trích xuất code thành nhiều mảnh đóng gói được tải khi cần, tránh rắc rối với việc tải một lượng lớn code trong khi hầu hết người dùng chỉ cần phần lõi của ứng dụng.

Việc chia tách code chỉ là một trong rất nhiều tính năng hấp dẫn mà Webpack cung cấp, và trên mạng có rất nhiều quan điểm mạnh mẽ về việc đánh giá xem Webpack hay Browserify tốt hơn. Dưới đây chỉ là một ít trong số những thảo luận ở trình độ cao mà tôi thấy hữu ích khi xem xét vấn đề này:

Module ES6

Bạn đã quay lại rồi à? Tốt! Vì tiếp theo tôi sẽ nói về module ES6, một cách có thể giúp giảm nhu cầu cần đến các chương trình đóng gói trong tương lai (bạn sẽ hiểu ý của tôi trong giây lát thôi). Đầu tiên, hãy cùng tìm hiểu cách mà module ES6 được nạp.

Sự khác biệt quan trọng nhất giữa các định dạng module JS hiện tại (CommonJS, AMD) và module ES6 là module ES6 được thiết kế với ý tưởng phân tích tĩnh. Nghĩa là khi bạn import các module, việc import được thực hiện lúc biên dịch - trước khi script bắt đầu chạy. Điều này cho phép chúng ta loại bỏ các phần export không được dùng bởi các module khác trước khi chúng ta chạy chương trình. Loại bỏ các phần export không cần thiết giúp giảm rất nhiều không gian cần để lưu trữ và giảm tải ở phía trình duyệt.

Một câu hỏi thường gặp là: việc này khác gì so với việc loại bỏ các đoạn code dư thừa được thực hiện bở UglifyJS khi làm nhỏ code? Câu trả lời như thường lệ là, "còn tùy".

(Chú ý: việc loại bỏ code dư thừa là một bước tối ưu hóa nhằm loại bỏ các biến và code không được sử dụng - ví dụ như loại bỏ các phần thừa mà chương trình đóng gói không cần thiết phải thực thi, *sau khi* đóng gói)

Thỉnh thoảng việc loại bỏ code dư thừa có thể hoạt động hoàn toàn giống nhau đối với UglifyJS và module ES6, có lúc lại không. Bạn có thể xem một ví dụ rất tuyệt ở wiki của Rollup.

Điều khiến module ES6 khác là sự khác biệt ở cách loại bỏ code dư thừa của nó, được gọi là "tree shaking". Tree shaking cơ bản là ngược lại của sự loại bỏ code dư thừa. Module ES6 chỉ include code mà phiên bản code đóng gói cần để chạy thay vì loại bỏ code mà phiên bản đóng gói không cần đến. Hãy xem một ví dụ về tree shaking:

Giả sử chúng ta có một fiel utils.js chứa các hàm sau, mỗi hàm này được export theo cú pháp của ES6:

export function each(collection, iterator) {
  if (Array.isArray(collection)) {
    for (var i = 0; i < collection.length; i++) {
      iterator(collection[i], i, collection);
    }
  } else {
    for (var key in collection) {
      iterator(collection[key], key, collection);
    }
  }
 }

export function filter(collection, test) {
  var filtered = [];
  each(collection, function(item) {
    if (test(item)) {
      filtered.push(item);
    }
  });
  return filtered;
}

export function map(collection, iterator) {
  var mapped = [];
  each(collection, function(value, key, collection) {
    mapped.push(iterator(value));
  });
  return mapped;
}

export function reduce(collection, iterator, accumulator) {
    var startingValueMissing = accumulator === undefined;

    each(collection, function(item) {
      if(startingValueMissing) {
        accumulator = item;
        startingValueMissing = false;
      } else {
        accumulator = iterator(accumulator, item);
      }
    });

	return accumulator;
}

Tiếp theo, giả sử chúng ta không biết hàm tiện ích nào chúng ta muốn dùng trong chương trình của mình, nên chúng ta tiếp tục import tất cả các thứ trong module vào file main.js như sau:

import * as Utils from './utils.js'

Và sau đó chúng ta sử dụng hàm each:

import * as Utils from './utils.js'
Utils.each([1, 2, 3], function(x) { console.log(x) });

Phiên bản được "tree shaking" của file main.js sẽ trông giống như sau một khi các module được nạp:

function each(collection, iterator) {
  if (Array.isArray(collection)) {
    for (var i = 0; i < collection.length; i++) {
      iterator(collection[i], i, collection);
    }
  } else {
    for (var key in collection) {
      iterator(collection[key], key, collection);
    }
  }
 };

each([1, 2, 3], function(x) { console.log(x) });

Chúng ta thấy rằng đoạn code export được include là cái mà chúng ta dùng: each.

Trong khi đó, nếu chúng ta dùng hàm filter thay vì dùng hàm each, chúng ta sẽ có đoạn code như sau:

import * as Utils from './utils.js'
Utils.filter([1, 2, 3], function(x) { return x === 2 });

Phiên bản được "tree shaking" sẽ trông như sau:

function each(collection, iterator) {
  if (Array.isArray(collection)) {
    for (var i = 0; i < collection.length; i++) {
      iterator(collection[i], i, collection);
    }
  } else {
    for (var key in collection) {
      iterator(collection[key], key, collection);
    }
  }
 };

function filter(collection, test) {
  var filtered = [];
  each(collection, function(item) {
    if (test(item)) {
      filtered.push(item);
    }
  });
  return filtered;
};

filter([1, 2, 3], function(x) { return x === 2 });

Chú ý là lần này cả 2 hàm eachfilter đều được include. Do hàm filter sử dụng hàm each nên chúng ta cần cả 2 export đó để module có thể hoạt động.

Rất tuyệt, phải không?

Tôi thử thách bạn nghịch chơi và khám phá tree shaking ở trang demo của Rollup.js.

Build module ES6

Được rồi, giờ chúng ta đx biết module ES6 được nạp khác so với các định dạng module khác, nhưng chúng ta vẫn chưa nói về build step khi bạn dùng module ES6.

Không may là module ES6 vẫn cần phải được xử lý thêm, do vẫn chưa có triển khai chính thức của cách nạp module ES6 trên trình duyệt

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import

Dưới đây là một số tùy chọn cho việc build/chuyển đổi module ES6 để có thể hoạt động trên trình duyệt, cách #1 là cách phổ biến nhất ở thời điểm hiện tại:

  1. Dùng một chương trình dịch (như Babel hay Traceur) để dịch code ES6 thành ES5 dưới định dạng CommonJS, AMD hay UMD. Sau đó đưa code được dịch vào một chương trình đóng gói như Browserify hay Webpack để tạo một hay nhiều file đóng gói.

  2. Dùng Rollup.js, thứ rất tương đồng với tùy chọn #1 ở trên ngoại trừ việc Rollup mang theo sức mạnh của module ES6 trong việc phân tích tĩnh code ES6 và các phụ thuộc trước khi đóng gói. Nó sử dụng "tree shaking" để include lượng code ít nhất vào code đóng gói. Tóm lại, lợi ích chính của Rollup.js so với Browserify hay Webpack khi bạn dùng module ES6 là tree shaking sẽ giúp code đóng gói của bạn nhỏ hơn. Cảnh báo rằng Rollup cung cấp một vài định dạng để đóng gói code, bao gồm ES6, CommonJS, AMD, UMD hay IIFE. Code đóng gói dưới dạng IIFE và UMD cso thể hoạt động trên trình duyệt, nhưng nếu bạn chọn đóng gói theo định dạng AMD, CommonJS hay ES6 thì bạn cần thêm một bước chuyển đổi code sang một định dạng mà trình duyệt có thể hiểu được (ví dụ như sử dụng Browserify, Webpack, RequireJS, v..v..).

Làm mọi thứ để đạt đến kết quả

Là lập trình viên web, chúng ta phải làm rất nhiều thứ để đạt đến kết quả cuối. Không phải lúc nào cũng có thể dễ dàng chuyển đổi những module ES6 đẹp đẽ sang thứ gì đó mà trình duyệt có thể thông dịch được.

Câu hỏi là, khi nào thì module ES6 sẽ chạy được trên trình duyệt mà không cần đến những công việc biên dịch ở trên?

Câu trả lời, may mắn thay, là "sẽ sớm thôi."

Hiện tại ECMAScript đã có đặc tả cho một giải pháp được gọi là ECMAScript 6 module loader API. Nói ngắn gọn, đó là một API lập trình dựa trên Promise được cho là sẽ nạp các module một cách động và lưu đệm chúng để các lệnh import sau đó không nạp lại một phien bản mới của module đó.

Nó trông như sau:

myModule.js

export class myModule {
  constructor() {
    console.log('Hello, I am a module');
  }

  hello() {
    console.log('hello!');
  }

  goodbye() {
    console.log('goodbye!');
  }
}

main.js

System.import(‘myModule’).then(function(myModule) {
  new myModule.hello();
});

// ‘Hello!, I am a module!’

Ngoài ra, bạn cũng có thể định nghĩa các module bằng cách chỉ định "type=module" trực tiếp vào thẻ script, như sau:

<script type="module">
  // loads the 'myModule' export from 'mymodule.js'
  import { hello } from 'mymodule';
  new Hello(); // 'Hello, I am a module!'
</script>

Nễu bạn chưa xem repo của bản triển khai module loader API, tôi rất khuyến khích bạn ít nhất hãy xem qua.

Hơn nữa, nếu bạn muốn kiểm thử phương pháp này, hãy xem System.js, thứ được xây dựng dựa trên bản triển khai ES6 Module Loader. SystemJS nạp động bất cứ định dạng module nào (module ES6, AMD, CommonJS và/hoặc script toàn cục) trên trình duyệt và Node. Nó theo dõi tất cả các module được nạp bằng một "module registry" để tránh nạp lại các module đã được nạp trước đó. Chưa kể đến việc nó còn tự động dịch module ES6 (nếu bạn thiết lập tùy chọn) và có thể nạp bất cứ kiểu module nào khác! Rất tuyệt.

Tại sao chúng ta vẫn cần các chương trình đóng gói cho dù chúng ta đã có module ES6 tự nhiên rồi?

Sự phổ biến ngày càng tăng của module ES6 dẫn đến một số kết quả thú vị:

Liệu HTTP/2 có khiến cho các chương trình đóng gói module trở nên lạc hậu?

Với HTTP/1, chúng ta chỉ cho phép một request trên một kết nối TCP. Đó là lý do tại sao tải nhiều tài nguyên cần đến nhiều request. Với HTTP/2, mọi thứ sẽ thay đổi. HTTP/2 hoàn toàn đa công, nghĩa là nhiều request và response có thể xảy ra song song. Kết quả là chúng ta có thể phục vụ nhiều request đồng thời trong cùng một kết nối.

Do chi phí của một request HTTP thấp hơn nhiều so với HTTP/1, việc tải một nhóm các module sẽ không gây nên vấn đề hiệu năng lớn về lâu dài. Một số tranh luận cho rằng điều này khiến cho việc đóng gói các module sẽ trở nên không cần thiết nữa. Điều đó chắc chắn là có thể xảy ra, nhưng sẽ tùy vào hoàn cảnh.

Ví dụ đóng gói module sẽ cung cấp những lợi ích mà HTTP/2 không có, như loại bỏ những export không dùng đến để tiết kiệm không gian lưu trữ. Nếu bạn xây dựng một website mà hiệu năng là một vấn đề cần xem xét kỹ, đóng gói sẽ giúp bạn gia tăng lợi thế về lâu dài. Tuy nhiên, nếu hiệu năng không phải vấn đề nghiêm trọng, bạn có thể tiết kiệm thời gian bằng cách bỏ qua build step.

Tóm lại, vẫn còn khá lâu mới có việc đa số các website truyền tải code thông qua HTTP/2. Tôi thiên về dự đoán rằng build process sẽ vẫn còn tồn tại ít nhất trong thời gian gần.

Tái bút: có nhiều thứ khác biệt giữa HTTP/1 với HTTP/2 nữa, và nếu bạn tò mò, đây là một tài nguyên tuyệt vời để bạn tìm hiểu.

Liệu CommonJS, AMD và UMD có lạc hậu?

Một khi ES6 trở thành chuẩn module, liệu chúng ta có cần đến các định dạng module không tự nhiên khác?

Tôi nghi ngờ điều đó.

Việc phát triển web có thể được hưởng lợi lớn từ việc tuân theo một chuẩn import và export module Javascript chung đã được chuẩn hóa, không có các bước trung gian. Tuy nhiên sẽ mất bao lâu để ES6 trở thành chuẩn module?

Rất có thể là, một thời gian khá lâu 😉

Hơn nữa, có nhiều người thích được lựa chọn, vì vậy một "phương pháp đúng đắn" này có thể không thể trở thành hiện thực.

Kết luận

Tôi hi vọng bài viết 2 phần này đã giúp làm sáng tỏ một số thuật ngữ mà các lập trình viên sử dụng khi nói về module và việc đóng gói module. Hãy xem phần 1 nếu bạn thấy thuật ngữ nào khó hiểu ở phía trên.

Happy bundling 😃