Hướng dẫn: JavaScript Modules

Nếu bạn mới học JavaScript, những thuật ngữ như “module bundlers vs. module loaders,” “Webpack vs. Browserify” và “AMD vs. CommonJS” có thể làm các bạn ‘bội thực’.

Hệ thống module của JavaScript tỏ ra khá đáng sợ, nhưng lại là những kiến thức cơ bản thiết yếu cho bất cứ web developer nào.

Trong bài viết, ta sẽ giải mã những thuật ngữ thông dụng này (kèm theo một vài đoạn code mẫu nữa). Hy vọng các bạn sẽ gặt hái được nhiều kiến thức từ bài viết.

Việc làm Javascript không yêu cầu kinh nghiệm

Part 1: Ông nào giải thích hộ module là gì lần nữa được không?

Tác giả giỏi sẽ chia sách thành chương và phần; lập trình viên giỏi sẽ chia chương trình thành module.

Giống như một chương truyện, module đơn giản chỉ là một loạt từ (hoặc code, tùy theo trường hợp).

Tuy nhiên, module tốt thì lại cực kỳ khép kín với chức năng riêng biệt, cho phép chúng được xáo trộn, xóa bỏ, hoặc thêm vào nếu cần, mà không gây gián đoạn cho hệ thống.

Tại sao phải dùng module?

Có rất nhiều lợi ích đến từ việc sử dụng module so với codebase phân nhánh phức tạp, liên quan đến nhau. Một số lợi ích lớn nhất, theo tôi là:

  • Khả năng duy trì: Theo định nghĩa, module thì khép kín. Một module được thiết kế đúng chuẩn sẽ có mục đích làm giải bớt dependency ở các phần trên codebase càng nhiều càng tốt, từ đó module có thể phát triển và cải thiện độc lập. Khi module được tách bạch khỏi những đoạn code khác, module này sẽ dễ cập nhật hơn rất nhiều.

Quay lại ví dụ cuốn sách, khi bạn muốn thay đổi một chương trong sách, nếu một thay đổi nhỏ trong chương cần phải chỉnh luôn trong các chương khác nữa, đây sẽ nhanh chóng trở thành một cơn ác mộng. Thay vào đó, bạn sẽ muốn viết mỗi chương làm sao để ít (hoặc không) phải động đến các chương khác khi muốn thay đổi gì đó.

  • Namespacing: Trong JavaScript, biến nằm ngoài hàm cấp cao sẽ là global (mọi người đều có thể truy cập). Bởi lẽ này, thường sẽ có “ô nhiễm vùng tên”, tại đây code (hoàn toàn không liên quan) chia sẻ các biến global.

Chia sẻ biến global giữa code không liên quan là thứ cực kỳ không nên xảy ra trong lập trình.

Sâu vào bài viết ta sẽ thấy rằng, module cho phép chúng ta tránh ô nhiễm vùng tên bằng cách tạo không gian riêng cho biến của chúng ta.

  • khả năng tái sử dụng: thành thật mà nói: bằng cách này hay cách khác, tất cả chúng ta ai cũng đã từng copy code đã viết trước đó vào project mới của mình. Ví dụ, hãy tưởng tượng bạn copy một vài method đa dụng bạn đã viết từ project trước đến project hiện nay.

Tốt đẹp cả thôi, nhưng nếu bạn nếu bạn tìm được cách hay hơn để viết một vài phần của code đó, bạn sẽ phải đi lại từ đầu đến cuối và phải nhớ mình đã viết ở chỗ nào để update.

Cách làm này lãng phí rất nhiều thời gian. Nếu ta có một… module có thể dùng đi dùng lại thì tốt biết mấy nhỉ?

Bạn kết hợp module như thế nào?

Có nhiều cách để liên kết module vào chương trình. Hãy cùng xem qua một số phương pháp thường thấy nhé:

Module pattern

Module pattern được sử dụng để mô phỏng khái niệm của class (vì JavaScript vốn không hỗ trợ class) từ đó chúng ta có thể lưu trữ cả các method public lẫn private và các biến trong một object duy nhất – tương tự với cách class được sử dụng trong các ngôn ngữ lập trình khác (như Java hay Python). Từ đó cho phép chúng ta tạo API đại chúng cho các method mà ta muốn tung ra global, mà vẫn kèm theo biến private và các method trong một phạm vi khép kín (closure scope).

Có nhiều cách thực hiện module pattern. Trong ví dụ đầu tiên, tôi sẽ sử dụng gói kín nặc danh (anonymous closure), đặt tất cả code của ta vào hàm ẩn danh (Nên nhớ: trong JavaScript, hàm là cách duy nhất tạo scope mới.)

Ví dụ 1: Gói kín ẩn danh (Anonymous closure)

(function () {
  // Chúng ta để các biến này ở private trong closure scope
  
  var myGrades = [93, 95, 88, 0, 55, 91];
  
  var average = function() {
    var total = myGrades.reduce(function(accumulator, item) {
      return accumulator + item}, 0);
    
      return 'Your average grade is ' + total / myGrades.length + '.';
  }

  var failing = function(){
    var failingGrades = myGrades.filter(function(item) {
      return item < 70;});
      
    return 'You failed ' + failingGrades.length + ' times.';
  }

  console.log(failing());

}());

// ‘You failed 2 times.’

Với cấu trúc này, hàm ẩn danh của chúng ta sẽ có môi trường đánh giá riêng hoặc “gói kín” (closure), và sau đó chúng ta sẽ đánh giá hàm ngay. Như vậy, chúng ta có thể giấu biến khỏi vùng tên mẹ (global namespace).

Cách này có một cái hay là bạn có thể sử dụng biến cục bộ trong hàm này mà không phải vô tình viết chồng lên các biến global đã có, mà vẫn truy cập được biến global, như sau:

var global = 'Hello, I am a global variable :)';

(function () {
  //  Chúng ta để các biến này ở private trong closure scope
  
  var myGrades = [93, 95, 88, 0, 55, 91];
  
  var average = function() {
    var total = myGrades.reduce(function(accumulator, item) {
      return accumulator + item}, 0);
    
    return 'Your average grade is ' + total / myGrades.length + '.';
  }

  var failing = function(){
    var failingGrades = myGrades.filter(function(item) {
      return item < 70;});
      
    return 'You failed ' + failingGrades.length + ' times.';
  }

  console.log(failing());
  console.log(global);
}());

// 'You failed 2 times.'
// 'Hello, I am a global variable :)'

Nên nhớ rằng dấu ngoặc tròn quanh hàm ẩn danh là bắt buộc, vì các câu lệnh (statement) bắt đầu với keyword function luôn được xem là khai báo hàm (nên nhớ, bạn không thể có khai báo hàm không tên trong JavaScript.) Do đó, các dầu ngoặc tròn xung quanh, thay vào đó sẽ tạo biểu thức hàm. Nếu muốn tìm hiểu thêm, bạn có thể đọc ở đây.

Example 2: Global import

global import là một phương pháp nữa khá nổi tiếng, được các thư viện như jQuery sử dụng. Tương tự với gói bao kín ẩn danh ta vừa thấy, nhưng sẽ không chuyển vào global như tham số (parameters):

(function (globalVariable) {

  //  Chúng ta để các biến này ở private trong closure scope
  var privateFunction = function() {
    console.log('Shhhh, this is private!');
  }

  // Expose các methods dưới đây qua giao diện globalVariable interface, đồng thời
  // giấu implementation của method trong 
  // function() block

  globalVariable.each = function(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);
      }
    }
  };

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

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

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

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

    return accumulator;

  };

 }(globalVariable));

Trong ví dụ này, globalVariable là biến global duy nhất. Cách thức này có lợi thế so với gói kín ẩn danh là, bạn có thể khai biến global ngay từ đầu, giúp người đọc code hiểu rõ hơn.

Example 3: Object interface

Còn một cách tạo module khác là với object interface độc lập, như sau:

var myGradesCalculate = (function () {
    
  // Chúng ta để các biến này ở private trong closure scope
  var myGrades = [93, 95, 88, 0, 55, 91];

  // Expose những hàm này thông qua an interface while hiding
  // the implementation of the module within the function() block

  return {
    average: function() {
      var total = myGrades.reduce(function(accumulator, item) {
        return accumulator + item;
        }, 0);
        
      return'Your average grade is ' + total / myGrades.length + '.';
    },

    failing: function() {
      var failingGrades = myGrades.filter(function(item) {
          return item < 70;
        });

      return 'You failed ' + failingGrades.length + ' times.';
    }
  }
})();

myGradesCalculate.failing(); // 'You failed 2 times.' 
myGradesCalculate.average(); // 'Your average grade is 70.33333333333333.'

Như bạn thấy đấy, phương pháp cho phép chúng ta quyết định biến/method nào để giữ private (e.g. myGrades) và biến/method nào chúng ta muốn expose, bằng cách đặt vào câu lệnh trả về (e.g. average & failing).

Example 4: Revealing module pattern

Rất giống với cách làm đã nêu ở trên, ngoại trừ tất cả method và biến sẽ được giữ private cho đến khi được expose dứt khoát:

var myGradesCalculate = (function () {
    
  // Chúng ta để các biến này ở private trong closure scope
  var myGrades = [93, 95, 88, 0, 55, 91];
  
  var average = function() {
    var total = myGrades.reduce(function(accumulator, item) {
      return accumulator + item;
      }, 0);
      
    return'Your average grade is ' + total / myGrades.length + '.';
  };

  var failing = function() {
    var failingGrades = myGrades.filter(function(item) {
        return item < 70;
      });

    return 'You failed ' + failingGrades.length + ' times.';
  };

  // Dứt khoát expose public pointers đến các private functions 
  // mà ta muốn expose công khai

  return {
    average: average,
    failing: failing
  }
})();

myGradesCalculate.failing(); // 'You failed 2 times.' 
myGradesCalculate.average(); // 'Your average grade is 70.33333333333333.'

Nhìn thì nhiều như vậy đấy, nhưng “không may” đây mới chỉ là phần nổi của tảng băng trôi module pattern mà thôi.

CommonJS và AMD

Có một điểm chung ở tất cả các phương pháp trên: sử dụng một biến global duy nhất để gói code trong một hàm, từ đó tạo vùng tên private cho chính nó với phạm vi khép kín (closure scope).

Tuy mỗi phương pháp đều có hiệu quả riêng, nhưng khuyết điểm vẫn không thể tránh khỏi.

Ví dụ như, với cương vị lập trình viên, bạn cần phải biết đúng thứ tự của dependency để load file vào đó. Giả sử, cho là bạn đang dùng Backbone cho project của mình, nên bạn sẽ thêm kèm script tag cho mã nguồn của Backbone trong file.

Tuy nhiên, kể từ khi Backbone có dependency cứng trên Underscore.js, sript tag cho file Backbone không thể được thay thế trước file Underscore.js được.

Là lập trình viên, việc quản lý dependencies song song với xử lý tốt những công việc này đôi khi tỏ ra quá sức.

Một thiếu sót khác là chúng vẫn có thể dẫn đến xung đột vùng tên. Ví dụ, Nếu như có hai module bị trùng tên thì sao? Hoặc nếu như bạn có hai phiên bản của cùng một module, mà bạn lại cần cả hai?

Có lẽ bạn đang tự hỏi: chúng ta có thể thiết kế ra cách yêu cầu interface của module mà không phải đi qua phạm vi toàn thể (global scope).

Thật may mắn, câu trả lời là “có”. Và chúng ta có hai giải phép khá nổi tiếng và tối ưu: CommonJS và AMD.

CommonJS

CommonJS là một nhóm làm việc tình nguyện, thiết kế và tích hợp JavaScript APIs để khai biến.

Một module CommonJS thực ra là một đoạn JavaScript tái sử dụng được, có khả năng export một số object nhất định, giúp các module khác có thể require trong chương trình của chúng. Nếu đã từng lập trình với Node.js, bạn hẳn sẽ rất quen thuộc với format này.

Với CommonJS, mỗi file JavaScript lưu trữ module trong bối cảnh module của riêng nó (cũng giống như gói vào gói kín vậy). Trong phạm vi (scope) này, chúng ta sẽ sử dụng object module.exports để expose module, và require để import.

Khi bạn xác định một module CommonJS, bạn sẽ thường thấy:

function myModule() {
  this.hello = function() {
    return 'hello!';
  }

  this.goodbye = function() {
    return 'goodbye!';
  }
}

module.exports = myModule;

Chúng ta sẽ sử dụng module object đặc biệt và đặt reference của hàm vào module.exports. Từ đó cho phép hệ thống module CommonJS biết được chúng ta muốn expose cái nào, để các file khác có thể tiêu thụ.

Sau đó nếu có người muốn dùng myModule, họ có thể require ngay trong file của họ, như sau:

var myModule = require('myModule');

var myModuleInstance = new myModule();
myModuleInstance.hello(); // 'hello!'
myModuleInstance.goodbye(); // 'goodbye!'

Giải pháp này có hai ích lợi rõ rệt so với các module pattern ta đã bàn đến:

  1. Tránh ô nhiễm vùng tên toàn phần (global namespace pollution)
  2. Để dependencies ở explicit

Hơn nữa, cú pháp cũng rất gọn gàng, tôi rất thích điểm này.

Một đặc điểm nữa cần lưu ý, rằng CommonJS đi theo hướng server-first (server trước) và load module đồng bộ. Lý do ta cần lưu ý là vì nếu ta có ba module khác cần require, thì chỉ load được từng module một mà thôi.

Đến đây, server thì tuyệt rồi, nhưng thật xúi quẩy, ta sẽ gặp khó khăn khi viết JavaScript cho trình duyệt, đọc module từ web sẽ mất rất nhiều thời gian hơn so với đọc từ ổ cứng. Miễng là đoạn script để load module đang chạy, script sẽ chặn trình duyệt không cho chạy bất cứ thừ giì khác cho đến khi module load xong. Chúng ta thấy hành vi này vì thread trong JavaScript sẽ dừng cho đến khi code load xong hết.

AMD

CommonJS rất hay và mượt, nhưng nếu chúng ta muốn load module không đồng bộ thì sao? Câu trả lời là Asynchronous Module Definition (ADM).

Load module bằng ADM sẽ trông như sau:

define(['myModule', 'myOtherModule'], function(myModule, myOtherModule) {
  console.log(myModule.hello());
});

Chúng ta có thể thấy rằng hàm define tiếp nhận một array của dependencies của module làm tham số đầu tiên. Những dependencies này được load trong background (theo hướng không block), và khi đã load, define sẽ call hàm callback nó đã được chỉ định.

Tiếp theo, hàm callback sẽ lấy các dependencies đã được load làm tham số – trong trường hợp của chúng ta, myModulemyOtherModule  cho phép hàm sử dụng những dependencies. Cuối cùng, bản thân dependencies cũng phải được xác định bằng từ khóa define.

Ví dụ, myModule có thể trông như sau:

define([], function() {

  return {
    hello: function() {
      console.log('hello');
    },
    goodbye: function() {
      console.log('goodbye');
    }
  };
});

Vậy, một lần nữa, khác với CommonJS, AMD đi theo hướng browser-first cùng với hành vi không đồng bộ để xử lý công việc. Bên cạnh tính chất không đồng bộ, module của bạn còn có thể là object, hàm, hàm dựng, string, JSON và nhiều kiểu dữ liệu khác, trong khi CommonJS chỉ hỗ trợ object làm module.

Như vậy, AMD không tương thích với io, filesystem, và các tính năng hướng server khác có ở trong CommonJS, và cú pháp gói hàm dài dòng hơn, nếu so với chỉ một câu lệnh require đơn giản.

UMD

Với các project yêu cầu hỗ trợ cả các tính năng của AMD và CommonJS, ta còn một format nữa: Universal Module Definition (UMD).

UMD là hướng dùng một trong hai phương pháp trên, đồng thời hỗ trợ xác định biến global. Từ đó, module trong UMD có thể làm việc trên cả client và server.

Sau đây là một ví dụ ngắn về cách UMD:

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
      // AMD
    define(['myModule', 'myOtherModule'], factory);
  } else if (typeof exports === 'object') {
      // CommonJS
    module.exports = factory(require('myModule'), require('myOtherModule'));
  } else {
    // Browser globals (Chú ý: root là window)
    root.returnExports = factory(root.myModule, root.myOtherModule);
  }
}(this, function (myModule, myOtherModule) {
  // Methods
  function notHelloOrGoodbye(){}; // A private method
  function hello(){}; // Là public method vì được trả kết quả (xem bên dưới)
  function goodbye(){}; // Là public method vì được trả kết quả (xem bên dưới)

  // Các public methods được expose
  return {
      hello: hello,
      goodbye: goodbye
  }
}));

Để dọc thêm ví dụ của UMD format, bạn có thể đọc tại đây.

Native JS

Nhiều quá đúng không, nhưng vẫn chưa hết đâu. Vì chúng ta vẫn còn một kiểu module nữa.

Chắc các bạn cũng đã nhận ra, không có kiểu module nào bên trên có sẵn trong JavaScript cả. Thay vào đó, chúng ta đã tìm cách emulate một hệ thống module thông qua việc sử dụng module pattern, CommonJS hoặc AMD.

May thay, những chuyên gia tại TC39 đã giới thiệu module built-in với ECMAScript 6 (ES6).

ES6 đưa ra một loạt khả năng import và export module đa dạng, sau đây là một số tài nguyên các bạn có thể xem thử:

So với CommonJS và AMD, ES6 module tỏ ra vượt trội hơn: cú pháp gọn gàng và rõ ràng, cùng khả năng load không đồng bộ, và các lợi thế như hỗ trợ cyclic dependencies tốt hơn.

Có lẽ tính năng ưa thích nhất của tôi với ES6 module là: xem trước trực tiếp exort dưới dạng read-only. (So với CommonJS, imports là bản sao của export và vì vậy sẽ không ‘sống’).

Sau đây là một ví dụ minh họa:

// lib/counter.js

var counter = 1;

function increment() {
  counter++;
}

function decrement() {
  counter--;
}

module.exports = {
  counter: counter,
  increment: increment,
  decrement: decrement
};


// src/main.js

var counter = require('../../lib/counter');

counter.increment();
console.log(counter.counter); // 1

Trong ví dụ này, chúng ta về cơ bản tạo hai bản sao module: một bản sao khi export, và một bản sao khi require.

Hơn nữa, bản sao trong main.js hiện đã bị ngắt kết nối khỏi module ban đầu. Đó là lý do ngay cả khi chúng ta gia tăng số counter, nó sẽ vẫn trả về 1 – vì biến counter mà ta import là bản sao đã disconnet của biến counter từ module.

Như vậy, việc tăng counter sẽ tăng số trong module, nhưng sẽ không tăng số version bản sao. Cách duy nhất để điều chỉnh version bản sao của biến counter, là làm thủ công:

counter.counter++;
console.log(counter.counter); // 2

Mặt khác, ES6 tạo xem trước trực tiếp (read-only) của module ta import:

// lib/counter.js
export let counter = 1;

export function increment() {
  counter++;
}

export function decrement() {
  counter--;
}


// src/main.js
import * as counter from '../../counter';

console.log(counter.counter); // 1
counter.increment();
console.log(counter.counter); // 2

Cool nhỉ? Một điểm rất hay của tính năng xem trước trực tiếp là bạn có thể tách module thành nhiều mảnh nhỏ mà không làm mất tính năng nào.

Sau đó bạn có thể quay lại và merge lần nữa. Không có bất cứ sự cố nào cả, nên bạn cứ yên tâm.

Ở phần sau: bundling modules

Như vậy, vừa rồi ta đã tìm hiểu sơ bộ về module. Trong phần tới, chúng ta sẽ khám phá sâu hơn nữa về module trong JavaScript, cụ thể hơn là module bundling (gói module):

  • Tại sao ta phải gói module
  • Các phương pháp gói khác nhau
  • API load module của ECMAScript

Nguồn: topdev.vn via Techtalk via Medium

Tham khảo các vị trí tuyển nhân viên IT hấp dẫn tại đây