Using AMD and RequireJS to modularize large JavaScript project

Posted in Programming on May 12, 2016 by manhhomienbienthuy Comments
Using AMD and RequireJS to modularize large JavaScript project

Trong quá trình phát triển Web, chắc chắn bạn sẽ phải làm việc với JavaScript. Theo thời gian, cùng với sự phát triển của dự án, code JavaScript cũng càng ngày càng phức tạp. Bài viết này sẽ trình bày một thư viện sẽ giúp chúng ta module hóa code JavaScript để giúp nó dễ bảo trì hơn, đó là sử dụng AMD (Asynchronous Module Definition) và RequireJS.

Tại sao phải module hóa JavaScript

Module có thể hiểu đơn giản là một phần của code cài đặt một chức năng nào đó. Phần code này sẽ được lưu trữ riêng biệt với các phần code khác. Thông thường, chúng ta lưu các module thành các file riêng biệt. Module hóa đơn giản là việc chia code thành các module. Vậy tại sao phải làm như vậy?

Nếu không chia module, code JavaScript trở nên phức tạp với hàng trăm nghìn dòng code. Mà với những file lớn như vậy, bạn phải là một siêu nhân mới có thể đọc và hiểu chúng. Để code dễ đọc hơn, và dễ bảo trì, thay đổi hơn, chúng ta cần chia chúng thành các thành phần nhỏ hơn. Mỗi phần có những tính năng riêng biệt, khi cần sửa chữa phần nào chỉ cần quan tâm đến đúng phần đó là được.

Việc module hóa là một việc rất đỗi tự nhiên như vậy, nhưng JavaScript có vẻ lại chưa có cơ chế giúp chúng ta làm việc đó. Hiện nay ECMAScript 2015 (ES2015 hoặc có thể gọi là ES6) có thể giải quyết vấn đề này bằng cách thêm khái niệm module cho ngôn ngữ. Nhưng các trình duyệt vẫn chưa hoàn toàn hỗ trợ ES2015 nên đây vẫn là chuyện của tương lai.

Có thể bạn đang thắc mắc rằng, chẳng phải mỗi file .js chính là một module rồi sao? Đương nhiên, bạn có thể chia code thành các file và load chúng bằng thẻ script. Nhưng cách làm này chưa hoàn toàn được coi là module hóa. Có rất nhiều vấn đề liên quan đến cách làm này.

Chúng ta hãy xem qua ví dụ sau, đây là một ví dụ khá điển hình nếu chúng ta chia code JavaScript thành các file .js.

<!DOCTYPE html>
<head>
  <meta charset="utf-8">
  <title>Lorem Ipsum</title>
  <link rel="stylesheet" href="assets/css/styles.css">
</head>

<body>
  <!-- lược bỏ nội dung body -->
  <script src="components/jquery.js"></script>
  <script src="app/modules/utils/load-html-content.js"></script>
  <script src="app/modules/utils/show-hide-div.js"></script>
  <script src="app/modules/svg-pie-chart.js"></script>
  <script src="app/modules/donation.js"></script>
  <script src="app/modules/login.js"></script>
  <script src="app/modules/utils/new-content-loader.js"></script>
  <script src="app/modules/generic-module.js"></script>
  <script src="app/modules/module1.js"></script>
  <script src="app/modules/module2.js"></script>
  <script src="app/config.js"></script>
  <script src="app/main.js"></script>
</body>

Chúng ta hãy bỏ qua các thành phần khác, tạm thời chỉ quan tâm đến phần load các file .js mà thôi. Các file này cần phải được load theo đúng thứ tự như sau:

  1. jQuery phải được load đầu tiên vì tất cả các file khác đều cần đến nó.
  2. Những thành phần nằm trong app/modules sẽ được dùng bởi các thành phần chính của ứng dụng, nên chúng cần phải được load trước.
  3. Tiếp theo chúng ta load code chính của ứng dụng.

Chúng ta có thể dễ dàng nhận ra rằng, việc load các file theo đúng thứ tự là rất quan trọng vì chúng phụ thuộc lẫn nhau. Ngoài ra, để hạn chế sự ảnh hưởng của những thẻ script tới quá trình render trang, chúng ta thường đặt chúng ở cuối trang.

Cách làm này không được hiệu quả cho lắm vì chúng ta phải tự mình quản lý các module và load chúng cho đúng thứ tự thì mới chạy được. Ngoài ra các thẻ script còn yêu cầu thêm các request để tải các file này, và trình duyệt sẽ ngừng việc render cho đến khi các file này được tải và thực thi xong.

Việc này ban đầu có thể chưa phải là vấn đề, nhưng khi code phức tạp dần lên, các file tăng dần lên và chúng ta càng ngày càng khó quản lý các thẻ script hơn. Kể cả chúng ta dùng các công cụ minify code thì chúng ta vẫn phải gộp chúng theo đúng thứ tự.

Ngoài ra còn một vấn đề khác nữa. Các trang của một ứng dụng thường dùng chung layout với nhau, do đó sẽ load JavaScript giống nhau. Tuy nhiên, không phải code JavaScript nào cũng cần cho tất cả các trang. Có rất nhiều phần code chỉ cần cho một vài trang nhất định mà thôi.

Khi đó, chúng ta phải làm một việc, đó là đặt class hay id cho một thành phần HTML nào đó, rồi trong code JavaScript thì kiểm tra xem trang có thành phần đó hay không. Nếu có thì chạy, không thì thôi. Việc load hoàng loạt JavaScript thừa như vậy thật là lãng phí băng thông.

Những lúc như vậy, tôi rất hy vọng JavaScript có một cơ chế kiểu như import của Python, để chúng ta chỉ load những gì cần cho trang mà thôi.

Như vậy, vấn để ở đây rất rõ ràng, và chúng ta cần một phương thức tốt hơn để module hóa JavaScript hơn là chỉ đơn giản là chia code thành nhiều file và load từng file một. Sau một thời gian tìm hiểu, thì tôi biết đến RequireJS. Trong bài viết này, tôi sẽ trình bày một số kiến thức mà mình đã thu được, hy vọng sẽ giúp các bạn trong quá trình phát triển ứng dụng Web.

Cách module hóa JavaScript

Mặc dù JavaScript không hỗ trợ các module, nhưng cộng đồng các developer đã cố gắng tìm ra cách để làm việc này. Sau một thời gian phát triển, thì hiện nay có một số phương thức module hóa như sau:

Trong những phương án trên, module pattern không yêu cầu bất cứ một công cụ hay thư viện nào, nó có thể hoạt động ở mọi môi trường JavaScript. CommonJS hướng tới mục tiêu là JavaScript chạy ở server-side. AMD chính là phương thức rất phổ biến với những ứng dụng Web, và nó cũng là phương thức mà RequireJS sử dụng.

Nói qua một chút về module pattern. Có thể bạn đã sử dụng nó rồi mà không hay biết, nhất là khi bạn thường xuyên code CoffeeScript.

Trong JavaScript, module pattern là cách để đóng gói code, nhằm giảm thiểu những xung đột có thể xảy ra khi định nghĩa tên hàm và biến. Nó đơn giản chỉ là code được đặt trong một hàm vô danh được thực thi ngay. Hàm này sẽ trả kết quả là một đối tượng và đối tượng này sẽ được sử dụng như một module. Mọi code JavaScript để tạo ra đối tượng đó hoàn toàn được đóng gói trong hàm vô danh. Hãy xem qua ví dụ sau, bạn sẽ thấy nó rất quen thuộc.

var loginModule = (function() {
    "use strict";

    var module = {};
    var privateVariable = 42;

    var privateLogin = function(userNameValue, userPasswordValue) {
        if (userNameValue === "admin" && userPasswordValue === "secret") {
            return privateVariable;
        }
    };

    module.myConstant = 1984;
    module.login = function(userNameValue, userPasswordValue) {
        privateLogin(userNameValue, userPasswordValue);
        console.log("login implementation omitted");
    };

    module.logout = function() {
        console.log("logout implementation omitted");
    };

    return module;
})();

Trên đây chỉ là một ví dụ đơn giản, module pattern có thể được áp dụng phức tạp hơn nhiều, bằng việc sử dụng các đối tượng global hay namespace.

Module pattern hoạt động tốt với những ứng dụng nhỏ. Nó rất dễ dàng để cài đặt và không cần thư viện nào cả. Tuy nhiên, khi ứng dụng phức tạp hơn, cách làm này không còn phù hợp nữa. Khi làm việc với ứng dụng có nhiều module, bạn sẽ phải làm nhiều việc nhàm chán như kiếm tra đối tượng có tồn tại ở global hay không, quản lý namespace thật cẩn thận, và vì bạn đang tạo ra các đối tượng, bạn cần phải suy nghĩ để tranh những xung đột khi đặt tên.

Hơn nữa, module pattern không thể giải quyết được những vấn đề mà chúng ta đã nói đến ở phần trước. Vì thế chúng ta cần đến AMD và RequireJS.

Asynchronous Module Definition (AMD)

AMD là một phương thực định nghĩa module mà module và những thành phần phụ thuộc của nó có thể được load không đồng bộ. Ban đầu, AMD là một bản nháp đặc tả kỹ thuật của CommonJS, nhưng nó không nhận được sự ủng hộ nên sự phát triển tiếp sau đó của nó được chuyển sang amdjs GitHub page.

AMD API có những hàm sau:

  • define để định nghĩa module.
  • require để load module và các thành phần phụ thuộc.

Hàm define cần 3 tham số, đó là module ID (có thể không có), một xâu các thành phần phụ thuộc và một hàm callback sẽ được gọi khi các thành phần được load.

Một khai báo đơn giản cho hàm define như dưới đây:

define(
    module_id,
    [dependencies],
    function {}
);

Hàm callback function{} chỉ được thực thi một lần.

Hàm require cần 2 tham số:

  • Một xâu các module cần load.
  • Một hàm callback được thực thi sau khi các module này load xong. Các module ở tham số thức nhất sẽ được truyền vào hàm này như là tham số theo đúng thứ tự chúng được liệt kê.

Dưới đây là một ví dụ đơn giản cho hàm require:

require(["main"], function() {
    console.log("module main is loaded");
});

Dưới đây là những ưu, nhược điểm của AMD, nếu bạn thấy nó phù hợp với mình thì có thể sử dụng:

Ưu điểm:

  • API rất đơn giản, chi cần 2 hàm requiredefine.
  • Có rất nhiều cách để load khác nhau. Chúng ta sẽ xem xét một ví dụ ở phần sau.
  • AMD không khó để debug.
  • Khi module hóa ứng dụng, bạn dễ dàng tìm ra phần code nào bị lỗi.
  • Performance: các module được load khi cần, do đó ứng dụng khi khởi tạo rất nhẹ nhàng.

Nhược điểm:

  • Danh sách thành phần phụ thuộc sẽ rất dài khi ứng dụng trở nên phức tạp.
define(
       ["one", "two", "three", "four", "five", "six"],
       function(one, two, three, four, five, six){
            "use strict";
            // module's code omitted
});
  • Một lỗi bất cẩn của lập trình viên có thể khiến ứng dụng hoạt động sai. Ví dụ sau, việc danh sách các thành phần phụ thuộc và tham số hàm callback không khớp không phải lỗi cú pháp và nó sẽ khiến ứng dụng hoạt động theo cách mà không ai hiểu được.
define(
       ["one", "two", "three", "four", "five", "six"],
       function(one, two, three, four, six, five){
            "use strict";
            // module's code omitted
});

Ngoài lề: ES2015 module

ES2015 (ES6) đã được phát hành chính thức, tuy nhiên các trình duyệt chưa hoàn toàn hỗ trợ nó đầy đủ. Vào thời điểm này, chúng ta vẫn chưa thể tin tưởng để sử dụng hết những tính năng mới của nó được. Tuy nhiên, hy vọng trong tương lai gần chúng ta sẽ sớm tiếp cận với nó.

Một tính năng rất quan trọng mà ES2015 mô tả đó là cú pháp module. Chúng ta có thể code module login theo mô tả của ES2015 như sau:

export function login(userNameValue, userPasswordValue) {
    return userNameValue + "_" + userNameValue;
}

Từ khóa export chỉ định hàm hoặc đối tượng sẽ được trích xuất là một module. Module này có thể được sử dụng ở bất kỳ code JavaScript nào khác. Ví dụ như chúng ta dùng module main như dưới đây:

import {login} from './login'
var result = login("admin", "password");

Với từ khóa import chúng ta gán hàm login() với module login. Việc chia module như vậy rất hay, hy vọng chúng ta sẽ sớm được sử dụng nó một cách rộng rãi.

RequireJS

Theo định nghĩa trên trang chủ thì

RequireJS là một file JavaScript và một module loader. Nó được tối ưu hóa cho môi trường trình duyệt nhưng cũng có thể sử dụng trong các môi trường JavaScript khác, như Rhino hoặc Node. Sử dụng các module loader như RequireJS sẽ giúp tăng tốc độ và chất lượng code của bạn.

Tất nhiên, đây là một định nghĩa có hơi hướng quảng cáo. Chúng ta có thể hiểu đơn giản RequireJS là một thư viện cho phép chúng ta module hóa code JavaScript theo quy chuẩn của AMD.

Download

Bạn có thể dễ dàng download RequireJS từ trang download của họ. Có 2 phiên bản cho bạn lựa chọn: bản minified và bản thường. Bạn có thể download phiên bản nào mà bạn muốn. Theo tôi thì chúng ta nên download bản minified vì dung lượng nhỏ hơn, và chúng ta cũng không cần thiết phải thay đổi thư viện của họ.

Ngoài thư viện require.js (vào thời điểm này phiên bản mới nhất là 2.2.0), thì RequireJS còn cung cấp một công cụ để tối ưu hóa code JavaScript là r.js, tuy nhiên, tạm thời chúng ta có thể bỏ qua nó.

Các hàm của RequireJS

RequireJS cung cấp hai hàm tương tứng với hai hàm trong API của AMD đó là definerequirejs. Thực ra, những phiên bản trước đây hàm requirejs có tên là require (giống với API), nhưng nó đã được đổi tên trong các phiên bản gần đây.

Chúng ta sẽ tìm hiểu cụ thể về những hàm này thông qua ví dụ ở phần tiếp theo.

Sử dụng RequireJS để module hóa

Với những hiểu biết cơ bản về AMD và RequireJS, bây giờ, chúng ta sẽ thực hành sử dụng chúng trong một ví dụ thực tế.

Giải thích qua một chút về cấu trúc thư mục và các module cần thiết. Trước hết, đây là một ví dụ nên tôi tạm sử dụng một file HTML tĩnh. Trong trang HTML này, tôi có design một navbar và với hy vọng nó hoạt động được cho cả desktop và smartphone, tôi sẽ dùng JavaScript để xử lý collapse nó khi hiển thị trên điện thoại.

Ngoài ra, tôi muốn có thể một nút "Back to top" nổi ở khu vực cuối màn hình khi người dùng cuộn trang. Tuy nhiên, nút này không phải lúc nào cũng hiển thị, tôi muốn nó chỉ được hiển thị khi trang dài hơn 2 lần độ cao của màn hình mà thôi. Ví dụ này tôi thấy khá thực tế, chúng ta thường load JavaScript giống nhau cho tất cả các trang nhưng có những thành phần chỉ chạy trên một vài trang mà thôi.

Với 2 yêu cầu trên, tôi sẽ viết 2 module JavaScript, và tổ chức cấu trúc thư mục như sau:

project-directory
├── index.html
└── assets
    ├── images
    ├── js
    │   ├── lib
    │   │   └── require.js
    │   ├── mobile-menu.js
    │   ├── scroll-top.js
    │   └── main.js
    └── css

Ở đây, chúng ta tổ chức như sau:

  • Tất cả các module đều được viết ở thư mục assets/js. Bạn có thể đưa chúng vào thư mục con (modules) chẳng hạn cho dễ quản lý.
  • Các thư viện JavaScript sẽ lưu trong thư mục assets/js/lib. Ở đây chúng ta lưu thư viện require.js. Ngoài ra, tôi sẽ sử dụng cả jQuery nhưng từ CDN nên không cần lưu vào đây.
  • Module chính của ứng dụng là assets/js/main.js.

Với việc sử dụng RequirejS và module hóa, chúng ta chỉ cần load JavaScript trong file HTML đơn giản như sau:

<!DOCTYPE html>
<head>
    <!-- content omitted -->
</head>
<body>
  <!-- page body -->
  <script src="assets/js/lib/require.js"
          data-main="assets/js/main.js"></script>
</body>

Việc load JavaScritp có thể hiểu như sau:

  • Khi thư việc RequireJS được load xong, nó sẽ tìm kiếm thuộc tính data-main, trong trường hợp của chúng ta đó là assets/js/main.js. RequireJS sẽ load file JavaScript trên một cách không đồng bộ, và file này trở thành entry point của ứng dụng của chúng ta.

Bên trong file main.js

RequireJS sử dụng một đối tượng để cấu hình các module và thành phần phụ thuộc cần phải được quản lý bởi framework. Có nhiều cách để tách biệt config và ứng dụng, tuy nhiên chúng ta có thể viết cấu hình này ngay trong file main.js như sau:

requirejs.config({
    paths: {
        'jquery': 'https://ajax.googleapis.com/ajax/libs/jquery/2.2.2/jquery.min',
        'mobile-menu': './mobile-menu',
        'scroll-top': './scroll-top'
    }
});

Ở trên, chúng ta cấu hình đường dẫn của các module. Ví dụ của chúng ta còn khá đơn giản nên chưa có nhiều thành phần phụ thuộc. Với những ứng dụng phức tạp hơn, chúng ta cần thêm config shim. Ví dụ chúng ta muốn sử dụng jQuery.inview (phụ thuộc vào jQuery) thì chúng cần cấu hình shim như thế này:

requirejs.config({
    shim: {
        'jquery': [],
        'jquery.inview': ['jquery']
    },
    ...
});

Sau khi cấu hình đường dẫn rồi, chúng ta cần load các module này. Việc load module thực ra không khó. Chúng ta có thể dễ dàng load các module mà chúng ta cần.

// mobile-menu cần load cho tất cả các trang
var mods = ['mobile-menu'];

function getPageHeight() {
    // hàm lấy độ cao thực của trang
    var body = document.body;
    var html = document.documentElement;

    return Math.max(body.scrollHeight, body.offsetHeight,
                    html.clientHeight, html.scrollHeight, html.offsetHeight);
}

if (getPageHeight() > window.innerHeight * 2) {
    // chỉ load scroll-top cho trang có độ cao đủ lớn
    mods.push('scroll-top');
}

requirejs(mods);

Viết các module

File main.js chỉ đơn giản là cấu hình và load các module cần thiết. Công việc còn lại của chúng ta là code cho các module này.

Module mobile-menu sẽ như sau (tôi đang học ES2015 nên code này muốn chạy được phải transpile về ES5):

define(['jquery'], function($) {
    class MobileMenuExport {
        constructor(menu) {
            this.menu = $(menu);
            this.init();
        }

        init() {
            this.menu.addClass('mobile-navigation');
            this.button = $('<div class="navigation-button"><a href="#"><i class="fa fa-bars"></i></a></div>');
            this.button.insertBefore(this.menu.parent());
            this.button.children('a').on( 'click', (event) => {
                event.preventDefault();
                this.menu.toggleClass('active');
                this.button.toggleClass('active');
            });
        }
    }

    return new MobileMenuExport('header .navigation');
});

Và module scroll-top sẽ như sau:

define(['jquery'], function($) {
    class ScrollTopExport{
        constructor(window, body) {
            this.body = $(body);
            this.window = $(window);
            this.offset = this.window.height() * 0.75;
            this.init();
        }

        init() {
            this.button = $('<a href="#top" class="back-to-top"><i class="fa fa-chevron-up"></i></a>');
            this.body.append(this.button);

            this.window.scroll(() => {
                if ($(window).scrollTop() > this.offset) {
                    this.button.fadeIn('medium');
                } else {
                    this.button.fadeOut('medium');
                }
            });
        }
    }

    return new ScrollTopExport(window, 'body');
});

Về cơ bản, việc định nghĩa module giống với hàm define của AMD API.

  • Tham số đầu tiên của hàm define là chuỗi các thành phần phụ thuộc, trong ví dụ của chúng ta là jQuery.
  • Ở đây chúng ta đã load các module theo yêu cầu từ main.js rồi, nên code của chúng ta khá đơn giản. Thực ra chúng ta còn có thể có những xử lý phức tạp hơn nhiều: kiểm tra module đã được load chưa, load module theo event của JavaScript.

Như vậy, chúng ta đã có 2 module hoạt động rất tốt. Khi cần thêm tính năng gì, chúng ta chỉ cần thêm module và load chúng nữa là xong. Một lưu ý nhỏ là các module này phụ thuộc rất lớn vào cấu trúc DOM và CSS, tuy nhiên, đây không phải là nội dung chính của bài viết này nên tôi không đề cập ở đây.

Summary

Kích thước của ứng dụng sẽ ngày càng lớn theo thời gian, không sớm thì muộn, bạn sẽ cần đến các phương thức khác nhau để module hóa ứng dụng. Tất nhiên, bạn bắt tay vào việc này càng sớm càng tốt.

Ở bài viết này, chúng ta đã tìm hiểu một phương pháp module hóa ứng dụng sử dụng RequireJS và AMD. Hy vọng bài viết giúp ích cho bạn, và bạn có thêm một lựa chọn nữa khi cần module hóa JavaScript.

Cuối cùng, bạn có thể xem qua demo của tôi với ví dụ trong bài.

I apologise for any typos. If you notice a problem, please let me know.

Thank you all for your attention.