+8

Javascript Module Loader - From the basic

Giới Thiệu

Thế giới web đã chuyển mình mạnh mẽ, trong khoảng 2 năm trở lại đây đánh dấu sự lên ngôi của các ứng dụng web (web-app). Tất nhiên không thể nhắc đến sự phát triển mạnh mẽ của Javascript, ngôn ngữ đứng đằng sau những công nghệ web tiên tiến nhất hiện nay.

Đối lập với những lợi ích mang lại, Javascript cũng thường được coi là 1 "bad programing language", với sự rườm rà về cú pháp, các khái niệm về Object propotype-based, scope,... dễ gây hiểu nhầm, Javascript trong suốt thời gian dài từ khi ra đời đến khi ra mắt ECMAscript 2015 (ECMA6) cũng không cung cấp cho developer đầy đủ những công cụ để phân chia app code của mình thành các phần nhỏ như các ngôn ngữ lập trình thông dụng khác.

Việc này cũng dễ hiểu bởi khi khai sinh Javascript được nhắm đến phục vụ chủ yếu cho việc thao tác với DOM và Web API, ở thời điểm web sơ khai thì rất ít trang web yêu cầu 1 khối lượng code khổng lồ như web-app ngày nay. Khi số lượng code tăng dần, developer đối mặt với việc phân chia code sao cho dễ tái sử dụng, linh hoạt, dễ thay đổi, dễ bảo trì. Thật may là không chờ đến khi chuẩn ECMA 2015 ra đời thì cũng đã có rất nhiều công cụ và library hỗ trợ cho việc này. Những công cụ, thư viện này còn được gọi là module loader system.

Nhưng cũng vì thế mà dẫn tới 1 hệ quả là chưa có 1 chuẩn chung nhất định cho các công cụ này. Việc ra đời quá nhiều các thư viên loaderJS: requirejs, webpack, systemjs, dojo,... khiến cho ngay cả các dev có kinh nghiệm cũng phải bối rối. Bài viết này mình muốn điểm lại sự phát triển của Module Loader System của Javascript trong khoảng 5 năm trở lại đây, cũng như cơ chế hoạt động cơ bản giúp cho mọi người có cái nhìn nền tảng qua đó dễ dàng nắm bắt mớ loằng ngoằng này hơn.

Vấn Đề Đặt Ra

Nếu đã từng code web, những dòng code thế này hẳn không xa lạ với bất cứ dev nào.

<head>
 <title>A demo website</title>
 <!-- Libraries -->
 <script src=“js/libs/library_a.js”></script>
 <script src=“js/libs/library_b.js”></script> 
 <!-- Denpendencies -->
 <script src=“js/dependency_a.js”></script>
 <script src=“js/dependency_b.js”></script>
 <!-- Core -->
  <script src=“js/main.js”></script>
</head>

Vấn đề phát sinh ở đây khi thẻ <script> được trình duyệt xử lý syncronously (đồng bộ). Điều này có nghĩa là mọi đoạn code bên dưới nó sẽ bị block cho đến khi nhận được response từ request của thẻ <script>. Kết quả của việc code bị block là trình duyệt bị lỗi, người dùng không thao tác được với web hoặc lỗi "Flash Of Unstyled Content".

Sẽ có nhiều người nói rằng sự ra đời của HTML5 và HTTP2 đã khắc phục được vấn đề này. Với HTML5 đó là sự ra đời của attribute async của thẻ <script>. Các trình duyệt hiện nay hầu hết đã hỗ trợ điều này. Bình thường thì các thẻ <script> sẽ load như hình sau: Khi có thuộc tính async trong thẻ

<script async src="2.js" />

Script được load như sau : Có thể thấy là ta đã tiết kiệm được thời gian do 2.js được load song song với 3.js. Trong tương lai với sự xuất hiện của HTTP2 thì việc gửi nhiều request để lấy các file nhỏ cũng sẽ không lãng phí hơn so với việc gửi 1 request để lấy 1 file lớn. Coi như ta giải quyết được vấn đề code bị block bằng load async, đảm bảo được tốc độ và phản hồi của web nhưng liệu điều đó đã tốt chưa? Liệu có thể đảm bảo các dependencies đã được load hết khi code trong file main.js được thực thi?

Những đoạn code trong file main.js sẽ có khả năng không thực hiện được do thiếu dependencies và thư viện cần thiết. Bên cạnh đó, việc bảo trì, tái sử dụng code cũng rất khó khăn khi số lượng dependencies lên tới 50+ files thì viêc đảm bảo code chạy cũng đã là cơn ác mộng chứ đừng nói đến tổ chức code.

Modular Programing Chia tách webapp thành các modulars là cách tuyệt với để giải quyết êm thấm trọn vẹn các vấn đề chúng ta mắc phải. Mặc dù làm việc với các modules trong Javascript không phải lúc nào cũng dễ chịu nhưng nó đáng để đánh đổi lấy trải nghiệm người dùng tốt nhất.

AMD & CommonJS

Đây là 2 bộ tiêu chuẩn (set of specification) nổi tiếng nhất do cộng đồng JS phát triển nhằm tạo ra nền tảng chung cho cách triển khai Javascript modules. Nói 1 cách dễ hiểu là các tổ chức này đưa ra 1 số định nghĩa các function với các tiêu chuẩn về cách hoạt động, còn việc triển khai như thế nào thì là do 3rd party. AMD & CommonJS không phải là thư viện cụ thể, mà nó là 1 tập hợp các tiêu chuẩn quy định mô hình hoạt động của các thư viện triển khai nó.

CommonJS CommonJS phát triển trước do Mozilla đứng sau, trước khi được đổi tên là CommonJS nó có tên là ServerJS. Nodejs là runtime nổi tiếng nhất ứng dụng CommonJS vào làm module loader system của mình. CommonJS được sử dụng chủ yếu ở server-side, bởi vì các files phía server đa phần có sẵn ở local nên CommonJS hoạt động theo mô hình syncronous. Điều này có nghĩa là mỗi khi đoạn code load dependencies hoạt động sẽ tạm thời block mọi hoạt động khác của ứng dụng.

CommonJS đưa ra : require(): function này có thể được gọi từ JS thuần để load 1 module. exports: keyword này là 1 object đặc biệt mà bạn có thể add các đối tượng muốn đóng gói thành module vào, sau đó có thể dùng require() để load nó ra.

Ví dụ trong nodejs:

File Foo.js

const circle = require('./circle.js');
console.log(`The area of a circle of radius 4 is ${circle.area(4)}`);
Circle.js

const { PI } = Math;
exports.area = (r) => PI * r ** 2;
exports.circumference = (r) => 2 * PI * r;

Cần nhớ là hàm require() là 1 hàm blocking nên nếu muốn sử dụng CommonJS phía front-end, ta cần 1 vài thư viện hỗ trợ minified code và nối thành 1 file để đảm bảo tốc độ.

AMD (Asyncronous Module Definition - Không phải CPU AMD) Khởi đầu là 1 spin-off của CommonJS Transport format sau đó nó đã phát triển trở thành 1 module API riêng biệt. Có thể nói CommonJS và AMD có chung nguồn gốc cũng được. Khác với CommonJS, AMD được tạo ra nhằm làm việc ở môi trường trình duyệt có nghĩa là nó cung cấp cơ chế load file 1 cách bất đồng bộ (ASYNC). Về mặt cú pháp, AMD có phần rườm rà hơn CommonJS. Có 2 khái niệm bạn cần chú ý khi làm việc với AMD (các thư viện implements AMD API): Method define() dùng để định nghĩa 1 module. Method require() dùng để load module. VD:

define(
    module_id /*optional*/, 
    [dependencies] /*optional*/, 
    function () { /*module code*/ }
);
  • Tham số đầu tiên moduleid: tên của module muốn export ra. Khi tham số này rỗng, AMD định nghĩa đây là 1 module nặc danh, tăng tính cơ động hơn cho code cũng như tránh việc conflict tên. 1 nhầm lẫn phổ biến khi load jQuery với AMD do team developer đã define module_id là "jquery" dẫn đến không thể load được thư viện này nếu không config đúng case-sensitive. http://requirejs.org/docs/jquery.html
  • Tham số thứ 2 là các dependencies muốn load được định nghĩa bằng 1 mảng các dependencies. Các dependencies này sẽ đươc module loader load vào giai đoạn parsing code - (lúc code được check cú pháp). Khi nào toàn bộ các dependencies đã được load xong thì code trong definition function mới được thực thi.
  • Tham số thứ 3 là 1 function có argument là các module được định nghĩa trong tham số thứ 2 dependencies

Define:

define(
    [‘myDependencyStringName’, ‘jQuery’], 
    function (myDepObj, $) { 
        ...module code…
    }
);

ES6 module

Như đã nhắc đến ở phần Intro, vào năm 2015 ECMAscript 6 ra đời hỗ trợ built-in module. ES6 ra đời sau nên khắc phục được hầu hết nhược điểm của cả 2 chuẩn AMD và CommonJS. ES6 module đưa ra 2 keyword cơ bản là import và export để thao tác với module, bản thân keyword đã nói lên công dụng của nó nên mình sẽ không trình bày thêm.

Vậy, tôi có cần 1 module-loader khi sử dụng ES6 modules không? Câu trả lời là "Có". ES6 module đặt ra 2 từ khóa import và export và chúng được dùng để định nghĩa dependencies và interface của mỗi module, không nhiều hơn! Bạn sẽ vẫn cần 1 module loader như khi dùng AMD và CommonJS. Và điều đáng mừng là hầu hết các module loader hiện đại đều đã hỗ trợ ES6 module 1 cách chính thức.

Module loader implementations

Tiếp theo chúng ta sẽ nói về các thư viện, tool ứng dụng triển khai các module specification. Tác dụng của module loader là : resolve tên, đường dẫn, đệ quy dependencies,.. của các module được định nghĩa, sau đó nó thực thi 1 function implements từ các chuẩn (AMD/CommonJS/ES6 ...) để load module. Cho đến thời điểm hiện tại, có rất nhiều tool và thư viện điển hình có thể kể ra:

Thư viện: RequireJS, curl, almond,... Tool: systemJS, webpack, browserify,..

Mỗi tool/thư viện này có thể hỗ trợ nhiều chuẩn khác nhau cũng có thể chỉ hỗ trợ 1 chuẩn. Tuy vậy thì hầu hết những công cụ hiện đại đều support cho tất cả các chuẩn module.

Cách hoạt động của các module loader?

Các module loader thay vì append các thẻ <script> vào HTML, nó sẽ tạo ra các nodes <script> động. Sau khi gửi các request lấy file, nó sẽ dựa vào event onReadyStateChange thì mới kích hoạt function cần sử dụng dependencies.

Các bạn tạo thư mục có cấu trúc như sau: File Index.html

<!DOCTYPE html>
<html>
<head>
    <title></title>
    <script src="js/loader.js"></script>
</head>
<body>

</body>
</html>

File Loader.js

(function() {
  var libs = new Array;
  var styles = new Array;

  //Xác định URL của thư viện cần dùng, ở đây mình dùng WOWjs, sử dụng WOWjs yêu cầu 2 dependencies là jQuery và animate.css
  libs[0] = 'https://code.jquery.com/jquery-1.12.4.js';
  libs[1] = 'https://cdnjs.cloudflare.com/ajax/libs/wow/1.1.2/wow.js';
  styles[0] = 'https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.5.2/animate.min.css';
  core = 'main.js';

//Vòng lặp để gửi request thư viện
  for (let i = 0; i < libs.length; i++) {

//Sử dụng Promise để xử lý khi có kết quả trả về
    libs[i] = new Promise ((resolve, reject) => {
      let xhttp = new XMLHttpRequest();
      xhttp.open("GET", libs[i], true);

      xhttp.onload = function () {
//Nếu request tài nguyên thành công, tạo 1 thẻ script và append code vào
        if (this.readyState == 4 && this.status == 200) {
          let script = document.createElement('script');
          script.innerHTML = this.responseText;
          let body = document.getElementsByTagName('body')[0];
          body.appendChild(script);
          resolve();
        }
      }

      xhttp.send();
    });
  }

  for (let i = 0; i < styles.length; i++) {
    styles[i] = new Promise ((resolve, reject) => {
      let xhttp = new XMLHttpRequest();
      xhttp.open("GET", styles[i], true);

      xhttp.onload = function () {
        if (this.readyState == 4 && this.status == 200) {
          let style = document.createElement('style');
          style.innerHTML = this.responseText;
          let body = document.getElementsByTagName('body')[0];
          body.appendChild(style);
          resolve();
        }
      }

      xhttp.send();
    });
  }

//Khi tất cả các dependencies đã load xong, thực thi code chương trình
Promise.all(libs.concat(styles)).then(() => {
    console.log('Initialized !!!');

    let xhttp = new XMLHttpRequest();
      xhttp.open("GET", 'js/main.js', true);

      xhttp.onload = function () {
        if (this.readyState == 4 && this.status == 200) {
          let style = document.createElement('script');
          style.innerHTML = this.responseText;
          let body = document.getElementsByTagName('body')[0];
          body.appendChild(style);
        }
      }

      xhttp.send();
  })

})();

FIle Main.js

new WOW().init();
var element = $('body').append('<h1 class="wow bounceInUp" style="text-align: center">Welcome to Module Loader</h1>');

Đây là cơ chế đơn giản nhất, và hầu hết các module loader đều áp dụng công thức này, tất nhiên là ví dụ nên tôi hard-code nhiều chỗ nhưng nôm na cho dễ hiểu thì cách hoạt động của nó là như thế =))

Live Example

Nếu chưa tin thì trong requirejs, 1 loạt công đoạn phức tạp để parse asset string, tạo execute context, đệ quy modules... sau cùng nó cũng tạo ra 1 node script như vậy. Bạn có thể debugger đoạn code này ở source của requirejs để kiểm tra, nằm ở khoảng dòng 1875 trong source =))

   req.createNode = function (config, moduleName, url) {
        var node = config.xhtml ?
                document.createElementNS('http://www.w3.org/1999/xhtml', 'html:script') :
                document.createElement('script');
        node.type = config.scriptType || 'text/javascript';
        node.charset = 'utf-8';
        node.async = true;
        console.log(node);
        return node;
    };

Tiny loaders

Sự xuất hiện của AMD tạo nên cơn sốt các thư viện module loader, ở thời kỳ đầu có thể kể tới : LAB.js, curljs, Almond. Đây đều là các thư viện nhỏ nhẹ (1-4KB). Về cách hoạt động nó cũng không có gì khác biệt nhiều. Đều là fetch các thư viện về, chờ sau khi fetch hết data về mới kích hoạt function init().

<script src="LAB.js"></script>
<script>
$LAB
.script("http://remote.tld/jquery.js").wait()
.script("/local/plugin1.jquery.js")
.script("/local/plugin2.jquery.js").wait()
.script("/local/init.js").wait(function(){
initMyPage();
});
</script>

Hiện nay, LAB và curljs đã ngừng hỗ trợ, Almond vẫn tiếp tục được maintain, có thể coi nó như 1 phiên bản gọn nhẹ của requirejs

RequireJS

RequireJS thực sự là 1 tượng đài trong số các thư viện module loader. Trước khi các công cụ hiện đại như webpack hay systemjs xuất hiện, requirejs gần như là giải pháp tối ưu khi bạn muốn triển khai javascript web-app theo hướng modules. RequireJS không có khác biệt về cơ chế hoạt động so với các Tiny Loaders. Điều biến requirejs trở nên khác biệt là nó cung cấp 1 bộ configuration giúp developer có thể tùy biến theo cách họ muốn. RequireJS cũng hỗ trợ CommonJS thông qua wrapper. http://requirejs.org/docs/start.html

Browserify

Khác với RequireJS gần với AMD thì Browserify lại cung cấp cho developer khả năng sử dụng CommonJS trên trình duyệt. Nếu bạn còn nhớ thì CommonJS được đề ra để làm việc server-side là chủ yếu. Browserify không chỉ là module loader mà nó là 1 "Module Bundle". Đây hoàn toàn là 1 "build-time" tool, và kết quả đầu ra của nó là các file (bundle) để có thể sử dụng phía trình duyệt. Khi sử dụng AMD, các file sẽ được load ASYNC, còn với Browserify tất cả các modules sẽ được ghép lại thành 1 bundle lớn duy nhất, điều này cũng cải thiện băng thông và tốc độ hơn.

Webpack

Webpack kế thừa và phát triển Browserify, và cung cấp thêm 1 số chức năng. Có thể coi đây như 1 build-tool system hoàn chỉnh. Webpack hỗ trợ cả AMD, CommonJS và ES6 lẫn asset (css, coffeescript ,HTML,...). Có thể nói đây là 1 trong những ngôi sao sáng nhất hiện nay trên bầu trời js library khi đang có sự tăng trưởng số lượng người dùng chóng mặt.

Trong webpack ta sử dụng các 'loader' để làm việc. Mỗi loader là 1 plugin mà nhiệm vụ nó là xử lý mỗi kiểu file nhất do developer quy định. Loader xử lý code thành các phân đoạn, với điểm khởi đầu là entry point (là nơi khởi đầu của tất cả web-app, có thể coi đây là file bootstrap) - khá là giống với Browserify bundle. Webpack tự động cập nhật các phân đoạn này khi code thay đổi.

Thêm 1 tính năng nữa đi cùng webpack là webpack-dev-server, đơn giản thì đây là 1 server nodejs được build bằng Express, và nó lắng nghe sự kiện mỗi khi có thay đổi trong code thì nó sẽ emit cho trình duyệt thay đổi theo (sử dụng EventLoop). Sử dụng webpack-dev-server thì có thể byebye nút refresh được rồi. Sửa code bấm Crtl + S và kết quả ngay lập tức được cập nhật lên trình duyệt. Tuy thế cho đến thời điểm này, Webpack vẫn còn tồn tại 1 số vấn đề liên quan đến việc biên dịch ES6 và làm việc với lưu trữ đám mây, hi vọng trong tương lai chúng sẽ được khắc phục sớm.

SystemJS

SystemJS là module loader mặc định của Angular2 , vậy nên nó có cộng đồng support rất lớn. Giống như Webpack, SystemJS hỗ trợ load các file non-js bằng các loader plugins. SystemJS cũng được đính kèm theo 1 công cụ đơn giản - systemjs-builder - dùng để đóng gói và tối ưu hoá files. Mặc dù vậy, sức mạnh lớn nhất của SystemJS là JSPM, (Javascript Package Manager). Dựa trên nền tảng ES6 Module loader polyfull, npm => JSPM hứa hẹn sẽ giúp nâng tầm việc xây dựng isomorphic js app ( là những js app chạy được trên cả server-side và client-side ). Trang chủ của nó là http://jspm.io, cung cấp hướng dẫn rất đầy đủ và chi tiết. Một điểm khác của SystemJS là sau khi build xong nó sẽ sinh ra các file distribution y như phía local chứ không phân thành các chunks rồi ghép lại như Webpack. Hay nói cách khác thì sản phẩm của SystemJS là nhiều file, bạn cần có thêm 1 công cụ như gulp hay jspm để đóng gói chúng lại như webpack làm => đây là điểm yếu của SystemJS so vói Webpack.

Kết Luận

Hi vọng rằng qua bài viết này mình đã có thể giúp các bạn hình dung ra bức tranh toàn cảnh cũng như lý do ra đời của các module loader để không còn bỡ ngỡ khi tiếp xúc với bất cứ 1 công cụ nào trong project của mình. Hãy nhớ là khi chọn module loader cho dự án, hãy đánh giá thật cẩn thận. Bắt đầu với giải pháp đơn giản nhất là bỏ qua các module loader và dùng các thẻ script nếu dự án không quá phức tạp. Nếu cần 1 module loader nhẹ nhàng, dễ sử dụng thì RequireJS / Almond là những lựa chọn số 1. Thêm Browserify nếu bạn cần CommonJS. Và chỉ nên tính tới các công cụ chuyên biệt như Webpack/SystemJS nếu dự án thực sự có những vấn đề lớn về module mà không thể giải quyết với các tiny loader bởi vì việc học 1 trong số những công cụ này cũng đòi hỏi nhiều thời gian. Tài liệu của chúng cũng còn nhiều hạn chế, cộng đồng Javascript thì ai cũng biết là thay đổi như chong chóng. Trừ khi bạn thực sự cần nếu không thì không nhất thiết phải học và hiểu sâu chúng làm gì, chẳng có gì đảm bảo là 1-2 tháng sau nó không lỗi thời cả.

Tham Khảo

https://appendto.com/2016/06/the-short-history-of-javascript-module-loaders/ Webpack, SystemJS, AMD, CommonJS, RequireJS documentations https://www.w3schools.com/tags/att_script_async.asp http://ktmt.github.io/blog/2013/04/14/gioi-thieu-ve-script-loader-trong-js/


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í