Javascript là một ngôn ngữ hết sức uyển chuyển,với một vấn đề bạn gặp phải có hàng tá cách để xử lý. Là một người mới bắt đầu vào việc lập trình javascript thì bạn rất dễ hoảng loạn không biết đâu là good choice ..
Trong bài viết này chúng ta sẽ tìm hiểu về hệ thống module của Node và cách thức sử dụng nó để đóng gói ,chia sẻ code. Bài viết đúc kết từ kinh nghiệm cá nhân của tác giả với mong muốn giúp các bạn hình dung ra các pattern (hình mẫu) trong việc thiết kế interface của module.

Interface các quy ước trung gian để chuẩn hóa giao tiếp giữa các thành phần trong ứng dụng.

Tìm hiểu về require, exports và module.exports

Đầu tiên hãy tìm hiểu các khái niệm đã.

Trong Nodejs, require một file là require đến cái module nó định nghĩa. Tất cả các module đều tham chiếu đến một đối tượng module ( thằng này có thuộc tính là exports ). Cái bạn nhận được khi require chính là giá trị bạn gán cho thằng exports này. Bên cạch đó bạn có thêm tham chiếu giữa thằng module.exports và thằng exports. Có rắc rối quá không nhỉ...

Đầu tiên, bạn thấy rằng bạn có thể gọi module và thằng exports ở bất kì file nào trong hệ thống . Hành vi của nó như kiểu biến global vậy.

global.module = {
    exports : {}
}

global.exports =  module.exports

Trong thức tế thì cái đối tượng module này khá là lửng lơ nó nằm giữa kiểu biến globallocal. Hành vi của nó giống như việc bạn khai báo lệnh var ở đầu các file.

var exports = module.exports = {};

Vấn đề của Javascript ở browser

Trở lại thời điểm javascript vẫn còn chưa xuất hiện trên server, khi phát triển ứng dụng lớn , hoặc khi mở rộng ứng dụng Javascript bạn gặp phải một số vấn đề :

  • Khả năng đóng gói
  • Khả năng chia nhỏ ửng dụng ra thành nhiều file
  • Tái sử dụng các hàm từ code cũ hay các thư viện. Với từng vấn đề tất nhiên cộng đồng Javascript đều tìm được giải pháp. Để tăng tính đóng gói bạn có thể sử dụng closure, áp dụng mẫu thiết kế module.
var MyModule = (function() {
  var exports = {};
  exports.foo = function() {
   // ...
  }

  //Biến private bar
  var bar = { /* ... */ };

  //Đưa hàm foo ra bên ngoài.
  return exports;
})();

Giải pháp này đóng gói giá trị của bar và để thay đổi thằng này bạn cần tương tác với các hàm mà nó xuất ra foo. Vấn đề đầu tiên được giải quyết.

Vấn đề chia nhỏ file và tái sử dụng code, Bạn chỉ có thể làm 2 cách :

  • Gọi nhiều lần thẻ <script>
  • Đóng gói tất cả file thành 1 file trước khi đẩy lên thực tế.

Giải pháp thứ hai tất nhiên là ổn hơn vì nó giảm thiểu số request lên server. Thật không may cả hai giải pháp này đều không giúp bạn tái sử dụng code một cách chọn lọc. Dễ hiểu hơn là để sử dụng 1 hàm bạn phải gọi đến cả thư viện.....(Copy mỗi hàm đó chắc chắn không phải là giải pháp ổn định vì bạn không chắc chắn được cái hàm đó bị phụ thuộc bởi thằng nào khác không ) Nó giống như việc bạn xây dựng 1 khối ứng dụng monolithic. File của bạn đưa lên server sẽ thật là khủng khiếp . Để giảm bớt việc phải tải đi tải lại cái file này , đặt cache trên trình duyệt và sử dụng CDN.

Rõ ràng là Javascript ở frontend lúc đó không có một cơ chế xử lý thành phần phụ thuộc một cách hiệu quả

Khóa học "Node.js xây dựng web site tốc độ cao" sẽ hướng dẫn bạn lập trình từ Java Script căn bản đến khi hoàn chỉnh một web site trên Node.js

Commonjs

Để giải quyết vấn đề trên khi phát triển ứng dụng trên server Nodejs, nó sử dụng hệ thống module CommonJS. Thay vì chạy từng file javascript ở trong scope global, Commonjs chạy từng file ở trong 1 ngữ cảnh riêng của nó ( giống như việc bạn đóng gói hàm bằng closure ở trên). Trong scope này nó thêm vào cho bạn ba biến "module.exports" , "exports" để xuất hàm và "require" dạng function để gọi các hàm.

Exports, require cơ bản

Quay trở lại nội dung bài viết, nếu bạn muốn xuất ra một hàm, bạn phải gán nó cho thằng module.exports

module.exports = function () {
  return {name: 'Jane'};
};

Để gọi nó ở một file khác ta dùng require

var func = require('./function');

Một đặc điểm khá quan trọng của thằng require là nó cache dữ liệu của bạn. Ở lần gọi thứ 2 đến file gốc của bạn nó sẽ lấy trong cache chứ không gọi đến file nữa. Cache ở đây thực chất là lưu trong object Global.

require.cache : {
    <đường dẫn tuyệt đối> : <dữ liệu export>
}

require trả về cho các bạn cùng một function nhưng giá trị nhận được từ function đó khác nhau nhé vì nó chạy khác chuồng mà :D

$ node
> f1 = require('/Users/alon/Projects/export_this/function');
[Function]
> f2 = require('./function'); // Same location
[Function]
> f1 === f2
true
> f1() === f2()
false

Bạn có thể tìm hiểu sâu hơn về module tại document của thằng Nodejshttps://nodejs.org/api/modules.html

Các hình mẫu thiết kế interface

Export a namespace

namespace (không gian theo tên gọi). Việc bạn phân nhóm các hàm, class, object theo một tên gọi nhất định để tiện lợi cho việc tham chiếu.

Đây là hình mẫu đơn giản và phổ biến nhất khi export. Ở đây bạn đặt một tập hợp các hàm, đối tượng dưới một tên gọi xác định. Ví dụ đơn giản là các module mặc định của nodejs được phân nhóm theo chức năng vào từng cái tên dễ hiểu như fs( quản lý file), http( xử lý server http) .v.v.

Khi gọi đến các module sử dụng hình mẫu này thì bạn có 2 lựa chọn

  • Lấy toàn bộ các thằng bên trong. Cần thằng nào thì gọi từ thằng to vào.
var fs = require('fs');
fs.readFile('./file.txt', function(err, data) {
  console.log("readFile contents: '%s'", data);
});
  • Tạo tham chiếu đến thằng con :
var readFile = require('fs').readFile;

Chúng ta đã xem cách require còn cách export thì thế nào nhỉ. Trong module fs :

var fs = exports;

Đầu tiên tạo biết local fs tham chiếu đến đối tượng "exports". Sau đó nó gán các hàm vào thằng fs. Việc gán các hàm vào biến fs thực chất là gán giá trị vào cho thằng exports ( cuối cùng thì cũng vào thằng module.exports) vì biến object trong javascript byReference không phải byCopy

fs.readFile = function(path, options, callback_) {
  // ...
};

Không chỉ exports function bạn hoàn toàn có thể export Constructor (tương tự hàm để khởi tạo của Class)

fs.ReadStream = ReadStream;

function ReadStream(path, options) {
  // ...
}
ReadStream.prototype.open = function() {
  // ...
}

Cách export thứ 2 là gán đối tượng cho thằng module.exports

module.exports = {
  version: '1.0',

  doSomething: function() {
    //...
  }
}

Hình mẫu này giúp cho bạn nhóm các thằng chung chứ năng lại với nhau . Ví dụ: trong ứng dụng bạn có nhiều các module khác nhau như User, Product .v.v Các thằng này ở các thư mục khác nhau, bên trong mỗi thằng lại có file models riêng của nó . Chúng ta cần nhóm các models này lại cho dễ gọi

var models = require('./models'),
    User = models.User,
    Product = models.Product;

Để thực hiện điều này bạn cần tạo file chứa toàn bộ các link tới từng modules rồi export nó ra

module.exports = {
    User : require('/user/model.js'),
    Product : require('/product/model.js'),
}

Trong es6 bạn có 1 cách import từng thành phần với cú pháp

 import {User,Product} from './models'

Hình mẫu này khá phổ biết khi tạo các module nodejs trên NPM. Các bạn sẽ thấy 1 file index.js ở ngay ngoài thư mục gồm rất nhiều require đến thư viện bên trong.

Export a Function

Export hàm là hình mẫu cũng khá hay gặp. Hình mẫu này sử dụng để export hàm nhà máy ( factory function) . Khi chạy hàm này nó sẽ trả về cho các bạn một đối tượng .

Bạn có thể thấy hình mẫu này trong Express.js

var express = require('express');
var app = express();

app.get('/hello', function (req, res) {
  res.send "Hi there! We're using Express v" + express.version;
});

Ở đây khi chạy hàm chúng ta sẽ tạo ra một ứng dụng Express mới.

Factory function và Contructor đều nhằm mục đích tạo đối tượng nhưng Contructor yêu cầu bạn sử dùng new còn Factory function thì không. Xin phép không so sánh quá sâu hai thằng này trong bài viết này nhé, trong một bài khác mình sẽ so sánh 2 thằng này.

Để có thể export một hàm rất đơn giản , gán hàm đó cho module.exports :

exports = module.exports = createApplication;

...

function createApplication () {
  ...
}

Một chú ý nhỏ là khi bạn exports function thì hãy đặt tên cho nó. Điều này giúp bạn có thể debug dễ dàng hơn. Lập trình viên Javascript có thói quen dùng Anonymous function khá nhiều.

// bomb1.js
module.exports = function () {
  throw new Error('boom');
};
// bomb2.js
module.exports = function bomb() {
  throw new Error('boom');
};
$ node
> bomb = require('./bomb1');
[Function]
> bomb()
Error: boom
    at module.exports (/Users/alon/Projects/export_this/bomb1.js:2:9)
    at repl:1:2
    ...
> bomb = require('./bomb2');
[Function: bomb]
> bomb()
Error: boom
    at bomb (/Users/alon/Projects/export_this/bomb2.js:2:9)
    at repl:1:2
    ...

Export a Higher Order Function

Higher Order Function là các hàm nhận vào một hoặc nhiều function hoặc trả về 1 function.

Ở đây chúng ta nói đến các hàm trả về 1 function nhé. Hình mẫu này thực sự hữu dụng khi bạn cần trả về một hàm nhưng lại muốn đặt cấu hình cho hàm đó nữa.

Hình mẫu này gặp khá nhiều ở middleware trong Express. Như ta biết middleware Express có cấu trúc là function với ba tham số truyền vào req,res,next . BodyParser, QueryString và rất nhiều các middleware khác đều cần phải tuân thủ cái logic này nhưng chúng cũng cần phải cấu hình thích hợp. Như vậy các thằng này cần trả về 1 cái function theo đúng chuẩn của middleware .

Đây là cách thằng BodyParser thực hiện :

exports = module.exports = bodyParser;

function bodyParser(options){
  var _urlencoded = urlencoded(options);
  var _json = json(options);

  return function bodyParser(req, res, next) {
    _json(req, res, function(err){
      if (err) return next(err);
      _urlencoded(req, res, next);
    });
  }
}

Điều này giúp cho bạn có thể cấu hình thoải mái

app.use(bodyParser({}))

Export a Contructor

Chúng ta có thể định nghĩa các class bằng các hàm contructor. Để tạo biến từ contructor ta cần từ khóa new.

function Person(name) {
  this.name = name;
}

Person.prototype.greet = function() {
  return "Hi, I'm Jane.";
};

var person = new Person('Jane');
console.log(person.greet()); // prints: Hi, I'm Jane

Trong hình mẫu này bạn tách từng class ra thành một file, ở từng phai bạn exports cái contructor ra . Điều này giúp ứng dụng của bạn rõ ràng dễ cho lập trình viên tìm kiếm các class

function Person(name) {
  this.name = name;
}

Person.prototype.greet = function() {
  return "Hi, I'm " + this.name;
};

module.exports = Person;

Export a Singleton

Khi bạn muốn bạn muốn tất cả các lời gọi đều gọi đến một thằng duy nhất thực hiện hãy áp dụng hình mẫu này. 
Ví dụ : Khi sử dụng Mongoose để kết nối MongoDB

var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/test');

var Cat = mongoose.model('Cat', { name: String });

Đối tượng mongoose bạn nhận được khi require là gì ?

function Mongoose() {
  //...
}

module.exports = exports = new Mongoose();

Ở đây bạn thấy có hàm contructor nhưng cái bạn nhận được chỉ là 1 đối tượng đã tạo sẵn. Với cơ chế cache của require, tất cả các câu lệnh trên moogoose đều trỏ đến một đối tượng xử lý mà thôi.

Nếu bạn muốn tạo 1 đối tượng model khác để trỏ vào 1 db khác thì sao 
Bên trong Moogoose có đoạn code

Mongoose.prototype.Mongoose = Mongoose;

Để tạo một đối tượng mới bạn cần lôi cái Constructor ra

var mongoose = require('mongoose'),
    Mongoose = mongoose.Mongoose;

var myMongoose = new Mongoose();
myMongoose.connect('mongodb://localhost/test');

Extends a Global Object

Module system không chỉ giúp bạn export giá trị. Nó còn giúp bạn chỉnh sửa các đối tượng global hoặc các đối tượng được trả về khi require các module khác.

Hình mẫu này được sử dụng khi bạn cần mở rộng hoặc thay thế hành vi của một đối tượng global. Chú ý chỉ sử dụng nó khi cần thiết vì nhiều khi nó làm lập trình viên khác khi tiếp cận ứng dụng sẽ khó hiểu hành vi của đối tượng truyền thống.

module should là một ví dụ. Bạn thêm vào đó khá nhiều các hàm để đánh giá.

require('should');

var user = {
    name: 'Jane'
};

user.name.should.equal('Jane');

Như bạn thấy ở đây user.name là String và mặc định String không có hàm nào là should cả. Ở đay cái thư viện này đã mở rộng các hàm của Object ,String .v.v

var should = function(obj) {
  return new Assertion(util.isWrapperType(obj) ? obj.valueOf(): obj);
};

//...

exports = module.exports = should;

//...

Object.defineProperty(Object.prototype, 'should', {
  set: function(){},
  get: function(){
    return should(this);
  },
  configurable: true
});

Ứng dụng Monkey Patch

Monkey Patch là cách để mở rộng hoặc chỉnh sửa những hàm, biến, class mặc định của hệ thống . Các thay đổi trong quá trình chạy chương trình

Sử dụng monkey patch chúng ta mở rộng class hoặc module vào khi chạy chương trình nhằm sửa lỗi hoặc thay đổi hành vi của 1 tính năng.

Cách thực hiện giống như việc bạn làm ở phần trước với đối tượng global. Thay vì chỉnh sửa biến global. Ta thay đổi đối tượng export lưu trong require.cache > thay đổi hành vi của việc require đối tượng trên toàn hệ thống.

Ví dụ mặc định thằng Mongoose đặt tên collection trong Mongo db là chữ viết tường số nhiều. Nếu bạn đặt model là UserCourse nó sẽ chuyển lại thành usercourses. Nhưng tôi không thích cách hoạt động này mà muốn nó đặt tên db là user_course .

var Mongoose = require('mongoose').Mongoose;
var _ = require('underscore');

var model = Mongoose.prototype.model;
var modelWithUnderScoreCollectionName = function(name, schema, collection, skipInit) {
  collection = collection || _(name).chain().underscore().pluralize().value();
  model.call(this, name, schema, collection, skipInit);
};
Mongoose.prototype.model = modelWithUnderScoreCollectionName;

Khi module được require, nó gọi đến thằng moogoose và thay đổi hành vi hàm của nó trong cache. Cảnh giác cao độ với cái trò này nhé. Nguy hiểm lắm và nhiều khi làm cho người đọc code của bạn không hiểu gì đâu :D

Bài viết được dịch từ http://bites.goodeggs.com/posts/export-this/