Module Javascript: Hướng dẫn cho người mới

Bài viết được dịch từ Javascript Modules: A Beginner's Guide của tác giả Preethi Kasireddy.

Nếu bạn là người mới học Javascript, những từ như "module bundlers với module loaders", "Webpack với Browserify" và "AMD với CommonJS" có thể nhanh chóng trở nên choáng ngợp.

Hệ thống module của Javascript có thể hơi đáng sợ, nhưng hiểu được nó là một điều quan trọng đối với lập trình viên web.

Trong bài này, tôi sẽ giải thích những từ trên bằng tiếng Anh thuần (và kèm theo một chút code ví dụ). Tôi hy vọng bạn sẽ thấy nó hữu ích.

Chú ý: để đơn giản, bài viết này sẽ được chia thành 2 phần: Phần 1 sẽ chủ yếu giải thích module là gì và tại sao chúng ta dùng chúng. Phần 2 (được đăng vào tuần sau) sẽ tìm hiểu xem việc đóng gói các module là gì và các cách khác nhau để làm việc đó.

Phần 1: Ai đó có thể làm ơn giải thích module là gì được không?

Các tác giả giỏi chia các cuốn sách của họ thành chương và mục, các lập trình viên giỏi chia chương trình của họ thành các module.

Giống như một chương sách, các module chỉ là tập hợp các từ (hoặc code, tùy từng trường hợp).

Các module tốt, tuy nhiên, lại có tính tự đóng gói cao với những tính năng riêng biệt, cho phép chúng có thể bị xáo trộn, xóa bỏ, hay thêm vào nếu cần thiết mà không làm hỏng hệ thống.

Tại sao lại sử dụng module

Có rất nhiều lợi ích khi sử dụng module trong codebase dài dòng và phụ thuộc lẫn nhau. Những lợi ích quan trọng nhất, theo ý kiến của tôi, là:

  1. Dễ bảo trì: Theo định nghĩa, một module tự đóng gói. Một module được thiết kế tốt nhắm tới việc làm giảm sự phụ thuộc của các phần trong codebase càng nhiều càng tốt để nó có thể phát triển một cách độc lập. Cập nhật một module dễ dàng hơn nhiều khi module đó liên kết lỏng lẻo với các phần code khác.

Trờ lại với ví dụ về cuốn sách của chúng ta, nếu bạn muốn cập nhật một chương trong cuốn sách, sẽ thật là ác mộng nếu một thay đổi nhỏ ở một chương khiến bạn phải chỉnh sửa tất cả các chương còn lại. Thay vào đó, bạn sẽ muốn viết mỗi chương theo cách mà những cải thiện có thể được thực hiện mà không ảnh hưởng đến các chương khác.

  1. Phân chia không gian tên: Trong Javascript, các biến bên ngoài phạm vi của một hàm cấp cao nhất là toàn cục (nghĩa là mọi người đều có thể truy cập đến nó). Vì vậy, việc bị "ô nhiễm không gian tên", khi các đoạn code hoàn toàn không liên quan đến nhau chia sẻ chung các biến toàn cục, là thường thấy.

Chia sẻ các biến toàn cục giữa các đoạn code không liên quan là một việc rất tệ trong quá trình phát triển.

Chúng ta sẽ thấy ở phấn sau của bài viết này, các module cho phép chúng ta tránh việc ô nhiễm không gian tên bằng việc tạo ra các không gian riêng cho các biến của mình.

  1. Tính tái sử dụng: Hãy thành thật: chúng ta đều sao chép code mình viết trước đó đến một dự án mới lúc này hay lúc khác. Ví dụ, hãy tưởng tượng bạn sao chép một vài hàm tiện ích bạn viết ở dự án trước cho dự án hiện tại của bạn.

Điều đó hoàn toàn tốt, nhưng nếu bạn không tìm được một cách tốt hơn để viết các phần code đó bạn sẽ phải quay lại và cập nhật bất cứ chỗ nào bạn viết chúng.

Điều đó chăc chắn cực kỳ lãng phí thời gian. Sẽ không dễ dàng hơn sao nếu có một module mà chúng ta có thể dùng đi dùng lại?

Làm thế nào bạn có thể tích hợp các module?

Có rất nhiều cách để tích hợp các module vào chương trình của bạn. Hãy xem một vài cách trong số chúng:

Mẫu module

Mẫu module được sử dụng để bắt chước khái niệm class (do bản thân Javascript không hỗ trợ class) để chúng ta có thể lưu trữ cả các phương thức và biến public và private trong một đối tượng độc lập - tương tự cách class được sử dụng trong các ngôn ngữ lập trình khác như Java hay Python. Điều đó cho phép chúng ta tạo ra một API public cho các phương thức chúng ta muốn để lộ ra ngoài, trong khi vẫn đóng gói các biến và phương thức private trong một closure scope.

Có một vài cách để đạt được mẫu module. Trong ví dụ đầu tiên này, tôi sẽ dùng một closure vô danh. Điều đó giúp chúng ta đạt được mục đích của mình bằng cách đặt tất cả code của mình vào một hàm vô danh. (Nhớ rằng: trong Javascript, dùng hàm là cách duy nhất để tạo ra scope mới.)

Ví dụ 1: Closure vô danh

(function () {
  // We keep these variables private inside this 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 vô danh của chúng ta có không gian tính toán của riêng nó hay "closure", và sau đó chúng ta thực thi nó ngay lập tức. Điều này giúp chúng ta giấu các biến khỏi không gian tên cha (toàn cục).

Điều tốt của phương pháp này là bạn có thể dùng biến bên trong hàm mà không lỡ tay ghi đè lên các biến toàn cục mà vẫn có thể truy cập đến các biến toàn cục, như sau:

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

(function () {
  // We keep these variables private inside this 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 :)'

Chú ý rằng cặp ngoặc bao quanh hàm vô danh là bắt buộc, vì các lệnh bắt đầu với từ khóa function luôn được coi là khai báo hàm (nhớ rằng, bạn không thể khai báo một hàm không có tên trong Javascript). Do vậy, cặp ngoặc bao quanh tạo ra một biểu thức hàm thay vào đó. Nếu bạn tò mò, bạn có thể đọc thêm ở đây.

Ví dụ 2: Import toàn cục

Một cách phổ biến khác được sử dụng bởi những thư viện như jQuery là import toàn cục. Cách này tương tự với closure vô danh như ta đã thấy, ngoại trừ việc chúng ta truyền biến toàn cục vào như một tham số:

(function (globalVariable) {

  // Keep this variables private inside this closure scope
  var privateFunction = function() {
    console.log('Shhhh, this is private!');
  }

  // Expose the below methods via the globalVariable interface while
  // hiding the implementation of the method within the
  // 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 toàn cục duy nhất. Lợi ích của phương pháp này so với closure vô danh là bạn khai báo một biến toàn cục trước, giúp mọi người dễ đọc code của bạn hơn.

Ví dụ 3: Object interface

Một cách khác để tạo module sử dụng một object interface tự đóng gói như sau:

var myGradesCalculate = (function () {

  // Keep this variable private inside this closure scope
  var myGrades = [93, 95, 88, 0, 55, 91];

  // Expose these functions via 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, phương pháp này cho phép chúng ta quyết định biến/phương thức nào chúng ta muốn giữ private (ví dụ myGrades) và biến/phương thức nào chúng ta muốn lộ ra bằng cách đặt chúng vào lệnh return (ví dụ averagefailing).

Ví dụ 4: Mẫu revealing module

Phương pháp này rất tương đồng với phương pháp phía trên, ngoại trừ việc nó đảm bảo tất cả các phương thức và biến được giữ private cho đến khi nó được để lộ ra một cách rõ ràng:

var myGradesCalculate = (function () {

  // Keep this variable private inside this 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.';
  };

  // Explicitly reveal public pointers to the private functions
  // that we want to reveal publicly

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

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

Có vẻ như trên đã là khá nhiều, nhưng đó mới chỉ là một phần nhỏ của tảng băng khi mới chỉ đề cập đến mẫu module. Dưới đây là một số tài nguyên tôi thấy hữu ích trong cuộc khám phá của mình:

CommonJS và AMD

Những phương pháp trên đều có chung một thứ: việc sử dụng một biến toàn cục để đóng gói code trong một hàm, do đó tạo ra một không gian riêng cho bản thân nó bằng cách sử dụng một closure scope.

Dẫu cho mỗi phương pháp đều hiệu quả theo cách của riêng mình, chúng cũng có những nhược điểm.

Là một lập trình viên, bạn cần biết chính xác thứ tự phụ thuộc để nạp các file. Ví dụ, bạn sử dụng Backbone trong dự án của mình, nên bạn include thẻ script liên kết đến mã nguồn của Backbone trong file của mình.

Tuy nhiên, do Backbone phụ thuộc sâu vào Underscore.js, thẻ script liên kết đến Backbone không thể được đặt trước Underscore.js.

Để quản lý sự phụ thuộc và khiến cho chúng đúng thỉnh thoảng có thể gây đau đầu.

Một nhược điểm khác là chúng có thể dẫn đến xung đột không gian tên. Ví dụ, nếu 2 module của bạn có cùng tên thì sao? Hay bạn có 2 phiên bản của một module và bạn cần dùng cả 2 thì thế nào?

Vậy nên bạn có thể tự hỏi: liệu chúng ta có thể thiết kế ra một phương pháp gọi đến giao diện một module mà không cần thông qua scope toàn cục?

May mắn là, câu trả lời là có.

Có hai phương pháp phổ biến và được triển khai tốt là: CommonJS và AMD.

CommonJS

CommonJS là một nhóm tiên phong thiết kế và triển khai Javascript API để khai báo module.

Một module CommonJS có bản chất là một nhóm code Javascript có thể tái sử dụng mà chỉ xuất ra các object riêng biệt, khiến chúng sẵn có để các module khác require. Nếu bạn đã lập trình Node.js, bạn sẽ rất quen với định dạng này.

Với CommonJS, mỗi file Javascript chứa các module trong module context độc nhất của chúng (giống như gói chúng trong một closure). Trong scope này, chúng ta sử dụng đối tượng module.exports để lộ ra các module, và require để import chúng.

Khi bạn định nghĩa một module CommonJS, nó sẽ trông như thế này:

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

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

module.exports = myModule;

Có 2 lợi ích rõ ràng của phương pháp này so với mẫu module mà chúng ta đã thảo luận ở trước:

  1. Tránh ô nhiễm không gian tên toàn cục.
  2. Làm cho sự phụ thuộc trở nên rõ ràng.

Hơn nữa, cú pháp rất chặt chẽ, điều mà cá nhân tôi rất thích.

Một chú ý nữa là CommonJS hướng tới phía server và nạp các module một cách đồng bộ. Điều này ảnh hưởng vì nếu chúng ta có 3 module cần require, nó sẽ nạp từng cái một.

Nó hoạt động tốt trên server nhưng không may là nó khiến cho việc viết Javascript cho phía trình duyệt trở nên khó khăn hơn. Việc đọc module từ web mất rất nhiều thời gian hơn so với việc đọc từ đĩa. Chừng nào việc nạp các module còn chạy, trình duyệt sẽ bị ngưng chạy bất cứ thứ gì khác cho đến khi kết thúc nạp. Trình duyệt hoạt động như vậy vì luồng Javascript ngừng cho đến khi code đã nạp xong. (Tôi sẽ nói tới cách giải quyết vấn đề này ở phần 2 khi chúng ta thảo luận đến việc đóng gói module. Hiện tại, đó là tất cả những gì chúng ta cần biết).

AMD

CommonJS rất tốt, nhưng nếu chúng ta muốn nạp các module một cách bất đồng bộ thì sao? Câu trả lời là thứ gọi là Asynchronous Module Definition, hay ngắn gọn là AMD.

Nạp các module sử dụng AMD sẽ trông như thế này:

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

Thứ diễn ra ở đây là hàm define nhận tham số đầu tiên của nó là một mảng chứa các phụ thuộc của module hiện tại. Các phụ thuộc này được nạp ở background (theo cách non-blocking), và khi đã nạp xong hàm define sẽ gọi hàm callback được chỉ định.

Kế tiếp, hàm callback nhận các tham số là các phụ thuộc đã được nạp - trong trường hợp của chúng ta là myModulemyOtherModule - và hàm này được phép sử dụng các phụ thuộc này. Cuối cùng, bản thân các phụ thuộc cũng phải được khai báo bằng cách dùng từ khóa define.

Ví dụ, myModule trông như sau:

define([], function() {

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

Một lần nữa, không giống như CommonJS, AMD hướng tới phía trình duyệt và sử dụng bất đồng bộ để hoàn thành công việc. (Chú ý, có rất nhiều người tin rằng việc nạp các tệp theo từng phần khi bắt đầu chạy code là không tốt, chúng ta sẽ khám phá thêm khi đến phần xây dựng module).

Bên cạnh tính bất đồng bộ, một lợi ích khác của AMD là module của bạn có thể là object, hàm, constructor, string, JSON và rất nhiều kiểu khác, trong khi CommonJS chỉ hỗ trợ object.

AMD không tương thích với io, filesystem và các tính năng hướng server như CommonJS, và cú pháp hàm đóng gói có chút rườm ra so với một lệnh require đơn giản.

UMD

Với các dự án yêu cầu bạn hỗ trợ cả AMD và CommonJS, sẽ có một định dạng khác là: Universal Module Definition (UMD).

Về cơ bản UMD tạo ra một cách sử dụng một trong hai cách trên, trong khi vẫn hỗ trợ định nghĩa biến toàn cục. Kết quả là module UMD có thể hoạt động trên cả client và server.

Dưới đây là một cái nhìn lướt về cách UMD làm việc:

(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 (Note: root is window)
    root.returnExports = factory(root.myModule, root.myOtherModule);
  }
}(this, function (myModule, myOtherModule) {
  // Methods
  function notHelloOrGoodbye(){}; // A private method
  function hello(){}; // A public method because it's returned (see below)
  function goodbye(){}; // A public method because it's returned (see below)

  // Exposed public methods
  return {
      hello: hello,
      goodbye: goodbye
  }
}));

Để xem nhiều ví dụ hơn về định dạng UMD, hãy xem repo hướng dẫn này trên GitHub.

Native JS

Phù! Bạn vẫn còn đó chứ? Tôi vẫn chưa lạc mất bạn trong rừng chứ hả? Tốt! Bởi vì chúng ta *còn một* kiểu module có thể định nghĩa trước khi xong.

Như bạn có thể đã nhận thấy, không một module nào phía trên là tự nhiên đối với Javascript. Chúng ta tạo ra các cách để giả lập một hệ thống module bằng cách sử dụng mẫu module, CommonJS hay AMD.

May thay, những con người thông minh ở TC39 (một tổ chức chuẩn định nghĩa cú pháp và ngữ nghĩa của ECMAScript) đã giới thiệu module được tích hợp với ECMAScript 6 (ES6).

ES6 cung cấp rất nhiều cách import và export module mà những người khác đã giải thích rất tốt - dưới đây là một số tài nguyên trong số đó:

Điều tuyệt vời về module ES6 liên quan đến CommonJS và AMD là cách nó quản lý để đạt tới những thứ tốt nhất của cả 2: chặt chẽ và cú pháp biểu đạt nạp bất đồng bộ, cộng thêm các lợi ích như hỗ trợ tốt hơn các các phụ thuộc lặp vòng.

Có lẽ tính năng yêu thích nhất của tôi về module ES6 là import là tham chiếu chỉ đọc hoạt động thực sự của export. (So sánh với CommonJS, import là bản sao của export và kết quả là nó không hoạt động thực sự).

Đây là ví dụ về cách nó hoạt động:

// 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

Ở ví dụ này, chúng ta tạo ra 2 bản sao của module, một khi chúng ta export nó, và một khi chúng ta require nó.

Hơn nữa, bản sao trong main.js giờ mất kết nối với module gốc. Đó là lý do mà thậm chi khi chúng ta tăng counter lên thì nó vẫn là 1 - bởi vì biến counter chúng ta import là một bản sao mất kết nối của biến counter trong module.

Vậy thì, tăng biến counter sẽ tăng ở trong module, không tăng trong bản copy. Cách duy nhất để thay đổi bản copy của biến counter là làm bằng tay:

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

Mặt khác, ES6 tạo ra một tham chiếu chỉ đọc hoạt động thực sự của module mà 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

Một thứ tuyệt với chứ hả? Điều mà tôi thấy thực sự thuyết phục về tham chiếu chỉ đọc hoạt động thật này là cách chúng cho phép bạn chia module của mình ra thành cách phần nhỏ hơn mà không mất đi chức năng của nó.

Và bạn có thể quay lại và gộp chúng lại, không vấn đề. Nó "hoạt động".

Tiếp theo: đóng gói các module

Chà! Thời gian trôi bao lâu rồi thế nhỉ? Một chuyến tung hoành, nhưng tôi thực sự mong rằng nó đã cho bạn hiểu biết tốt hơn về module trong Javascript.

Trong phần tiếp theo tôi sẽ nói về đóng gói các module, gồm các chủ đề chính:

  • Tạo sao chúng ta đóng gói các module
  • Các phương pháp khác nhau để đóng gói
  • API nạp module của ECMAScript
  • ... và nhiều nữa. 😃

Chú ý: để cho mọi thứ đơn giản, tôi sẽ bỏ qua các thứ cơ bản (như: phụ thuộc lặp vòng) trong bài này. Nếu tôi bỏ qua thứ gì quan trọng và/hoặc hấp dẫn, xin hãy cho tôi biết ở phần bình luận!