[译 + 更新] 参透 Node 中 exports 的 7 种设计形式

媒介

这篇文章试着要整顿,翻译Export This: Interface Design Patterns for Node.js Modules这篇异常值得一读的文章。
但因为这篇文章有些时日了,部份范例已经不符合现况。故这是一篇加上小弟网络汇整而成的更新翻译。

路程的开始

当你在 Node 中载入一个模组,我们究竟会取回什么?当我们撰写一个模组时我们又有哪些选择能够用来设计程式的介面?
在我第一次学习 Node 的时候,发现在 Node 中有太多的体式格局处理这个问题,由于 Javascript 自身异常弹性,加上在社群中的开发者们各自都有差别的实作风格,这让当时的我觉得有点波折。

在原文作者的学习路程中曾持续的观察寻找好的体式格局以应用在其的事变上,在这篇文章中将会分享观察到的 Node 模组设计体式格局。
简略总结了 7 种设计形式(pattern)

  • 汇出命名空间 Namespace

  • 汇出函式 Function

  • 汇出高阶函式 High-Order Function

  • 汇出建构子/构建函式 Constructor

  • 汇出单一实例物件 Singleton

  • 扩展全域物件 Extend Global Object

  • 套用猴子补丁 Monkey Patch

require, exports 和 module.exports

起首我们须要先聊点基础的知识

在 Node 官方文件中定义了汇入一个档案就是汇入一个模组。

In Node.js, files and modules are in one-to-one correspondence. – Node 文件

每个模组内都有一个隐式(implicit)的 module 物件,这个物件身上有一个属性 exports (即 module.exports)。当我们运用 require() 时它所传回的,等于这个模组的 module.exports 所指向的东西。
每个模组的 module.exports 预设都是一个空物件 {},而每个模组内带有一个 exports 捷径变数,预设指向 module.exports

预设情况下,module.exports 是一个物件,而 exports 又指向该物件,所以在预设情况下,以下两个操纵视为等效:

exports.foo = 'hello';
// 同等于
module.exports.foo = 'hello';

如果,你从新指定了某东西给 module.exports,当然 module.exports 跟预设物件的关系就因此被打断,而此时 exports 捷径变数仍然是指向预设物件的。前面有提到,当我们运用 require() 时,模组是以 module.exports 所指向的东西而揭破给外部,因此模组的介面是由 module.exports 所决定,与 exports 捷径变数无关。
因此,一旦 module.exports 被指定新值后,运用捷径变数 exports 所挂入预设物件的内容都将无法被揭破,因此可视为无效。

为了让您更好邃晓关于 exportsmodule.exports 下面的范例供应了较详细的说明

var a = { id: 1 }
var b = a
console.log(a) // {id: 1}
console.log(b) // {id: 1}

// b 参考指向 a,意味着修正 b 的属性 a 会跟着变动
b.id = 2
console.log(a) // {id: 2}
console.log(b) // {id: 2}

// 但如果将一个全新的物件赋予 b 那么参考的关系将会中断
b = { id: 3 }
console.log(a) // {id: 2}
console.log(b) // {id: 3}

别的比较具体的范例

// 模组预设: module.exports = {}, 而 exports = module.exports, 同等于指向预设的空物件

/* person.js */
exports.name = function () {
  console.log('My name is andyyou.')
}
...
/* main.js */
var person = require('./person.js')
person.name()
/* person.js */
module.exports = 'Hey, andyyou'        // module.exports 不再是预设物件
exports.name = function () {        // 透过 exports 捷径挂进预设物件的内容, 将无法被揭破
  console.log('My name is andyyou')
}

/* main.js */
var person = require('./person.js')
// exports 的属性被疏忽了
person.name() // TypeError: Object Hey, andyyou has no method 'name'
  • exports 只是指向 module.exports 的参考(Reference)

  • module.exports 初始值为 {} 空物件,于是 exports 也会获得该空物件

  • require() 回传的是 module.exports 而不是 exports

  • 所以您能够运用 exports.property_name = something 而不会运用 exports = something

  • 一旦运用 exports = something 参考关系便会住手,也就是 exports 的资料都会被疏忽。

本质上我们能够邃晓为一切模组都隐含实作了下面这行程式码 (实际上 moduleexports 是由 node 模组系统传入给我们的模组的)

var exports = module.exports = {}

现在我们知道了,当我们要汇出一个 function 时我们得运用 module.exports
如果令捷径 exports 变数指向该 function,仅仅是捷径 exportsmodule.exports 的关系被打断罢了,真正揭破的还是 module.exports

别的,我们在许多专案看到下面的这行程式码

exports = module.exports = something

这行程式码作用就是确保 exportsmodule.exports 被我们覆写之后,仍能够指向雷同的参考。

接着我们就可以够透过 module.exports 来定义并汇出一个 function

/* function.js */
module.exports = function () {
  return { name: 'andyyou' }
}

运用的体式格局则是

var func = require('./function')

关于 require 一个很重要的行为就是它会快取(Cache) module.exports 的值,未来每一次 require 被调用时都会回传雷同的值。
它会根据汇入档案的绝对路径来快取,所以当我们想要模组能够回传差别得值时,我们就须要汇出 function,云云一来每次执行函式时就会回传一个新值。

下面在 Node REPL 中简易的示范

$ node
> f1 = require('/Users/andyyou/Projects/export_this/function')
[Function]
> f2 = require('./function') // 雷同路径
[Function]
> f1 === f2
true
> f1() === f2()
false

您能够观察到 require 回传了同样的函式物件实例,但每一次调用函式回传的物件是差别的。
更详细的介绍能够参考官方文件,值得一读。

现在,我们能够开始探讨介面的设计形式(pattern)了。

汇出命名空间

一个简单且经常使用的设计形式就是汇出一个包括数个属性的物件,这些属性具体的内容主如果函式,但并不限于函式。
云云,我们就可以够透过汇入该模组来获得这个命名空间下一系列相关的功用。

当您汇入一个命名空间类型的模组时,我们平常会将模组指定到某一个变数,然后透过它的成员(物件属性)来存取运用这些功用。
以至我们也能够将这些变数成员直接指定到区域变数。

var fs = require('fs')
var readFile = fs.readFile
var ReadStream = fs.ReadStream

readFile('./file.txt', function (err, data) {
  console.log('readFile contents: %s', data)
})

这就是fs 中心模组的做法

var fs = exports

起首用一个新的区域变数 fs,令其为捷径 exports,因此预设情况下 fs 就指向了 module.exports 身上的预设物件。上面这一行我们能够说,只是将捷径变数换个名称成为 fs 云云罢了。
接下来,我们就可以够运用新的捷径 fs 了,比方: fs.Stats = binding.Stats

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

其他东西也是一样的作法,比方汇出建构子

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

当汇出命名空间时,您能够指定属性到 exports ,就像 fs 的作法,又或许能够竖立一个新的物件指派给 module.exports

/* exports 作法 */
exports.verstion = '1.0'

/* 或许 module.exports 作法 */
module.exports = {
  version: '1.0',
  doYourTasks: function () {
    // ...
  }
}

一个常见的作法就是透过一个根模组(root)来汇整并汇出其他模组,云云一来只须要一个 require 便能够运用一切的模组。
原文作者在Good Eggs事变时,会将资料模子(Model)拆分红个别的模组,并运用汇出建构子的体式格局汇出(请参考下文介绍),然后透过一个 index 档案 来鸠合该目录下一切的资料模子并一同汇出,云云一来在 models 命名空间下的一切资料模子都能够运用

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

在 ES2015 和 CoffeeScript 中我们以至还能够运用解构指派来汇入我们须要的功用

/* CoffeeScript */
{User, Product} = require './models'

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

而刚刚提到的 index.js 也许就以下

exports.User = require('./User')
exports.Person = require('./person')

实际上这样分开的写法还有更精简的写法,我们能够透过一个小小的函式库来汇入在统一阶层中一切档案并搭配 CamelCase 的命名规则汇出。
于是在我们的 index.js 中看起来就会以下

module.exports = require('../lib/require_siblings')(__filename)

汇出函式

别的一个设计形式是汇出函式当作该模组的介面。常见的作法是汇出一个工厂函式(Factory function),然后呼唤并回传一个物件。
在运用 Express.js 的时候就是这种作法

var express = require('express')
var app = express()    // 实际上 express() 传回的 app 是一个 function,在 JS 中函式也是物件

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

Express 汇出该函式,让我们能够用来竖立一个新的 express 应用程式。
在运用这种形式时,平常我们会运用 factory function 搭配参数让我们能够设定并回传初始化后的物件。

想要汇出 function,我们就一定要运用 module.exportsExpress 就是这么做

exports = module.exports = createApplication

...
function createApplication () {
  ...
}

上面指派了 createApplication 函式到 module.exports 然后再指给 exports 确保参考一致。
同时 Express 也运用下面这种体式格局将导出函式当作命名空间的作法运用。

exports.version = '3.1.1'

这边要简略解释一下由于 Javascript 原生并没有增援命名空间的机制,于是大部份在 JS 中提到的 namespace 指的就是透过物件封装的体式格局来达到 namespace 的结果,也就是第一种设计形式。

注重!并没有任何体式格局能够阻挠我们将汇出的函式作为命名空间物件运用,我们能够用其来援用其他的 function,建构子,物件。

Express 3.3.2 / 2013-07-03 之后已经将 exports.version 移除了

别的在汇出函式的时候最好为其命名,云云一来当出错的时候我们比较轻易从错误堆叠资讯中找到问题点。

下面是两个简单的例子:

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

汇出函式还有些比较特别的案例,值得用别的的名称以区分它们的差别。

汇出高阶函式

一个高阶函式或 functor 基本上就是一个函式能够接收一个或多个函式为其输入或输出。而这边我们要谈论的后者 – 一个函式回传函式
当我们想要模组能够根据输入控制回传函式的行为时,汇出一个高阶函式就是一种异常实用的设计形式。

补充:functor & monad

举例来说 Connect 就供应了许多可挂载的功用给网页框架。
这里的 middleware 我们先邃晓成一个有三个参数 (req, res, next) 的 function。

Express 从 v4.x 版之后不再相依于 connect

connect middleware 惯例就是汇出的 function 执行后,要回传一个 middleware function
在处理 request 的过程中这个回传的 middleware function 就可以够接办运用刚刚提到的三个参数,用来在过程中做一些处理或设定。
同时因为闭包的特征这些设定在整个中介软体的处理流程中都是有用的。

举例来说,compression 这个 middleware 就可以够在处理 responsive 过程中协助压缩

var connect = require('connect')
var app = connect()

// gzip outgoing responses
var compression = require('compression')
app.use(compression())

而它的原始码看起来就以下

module.exports = compression
...
function compression (options) {
  ...
  return function compression (req, res, next) {
    ...
    next()
  }
}

于是每一个 request 都会经过 compression middleware 处理,而代入的 options 也因为闭包的关系会被保留下来

这是一种极具弹性的模组作法,也可能在您的开发项目上帮上许多忙。

middleware 在这里您能够简略想成串连执行一系列的 function,天然其 Function Signature 要一致

汇出建构子

在平常物件导向语言中,constructor 建构子指的是协助我们从类别 Class竖立一个该类别物件实例的一段程式码。

// C#
class Car {
  // c# 建构子
  // constructor 即 class 顶用来初始化物件的 method。
  public Car(name) {
    name = name;
  }
}
var car = new Car('BMW');

由于在 ES2015 之前 Javascript 并不增援类别,某种程度上在 Javascript 当中我们能够把任何一个 function 当作类别,或许说一个 function 能够当作 function 执行或许搭配 new 关键字当作 constructor 来运用。如果想知道更详细的介绍能够阅读MDN 教学

欲汇出建构子,我们须要透过构造函式来定义类别,然后透过 new 来竖立物件实例。

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

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

var person = new Person('andyyou')
console.log(person.greet()) // Hi, I am andyyou

在这种设计形式底下,我们平常会将每个档案设计成一个类别,然后汇出建构子。这使得我们的专案架构越发清晰。

var Person = require('./person')
var person = new Person()

整个档案看起来会以下

/* person.js */
function Person(name) {
  this.name = name
}

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

exports = module.exports = Person

汇出单一物件实例 Signleton

当我们须要一切的模组运用者同享物件的状态与行为时,就须要汇出单一物件实例。

Mongoose是一个 ODM(Object-Document Mapper)函式库,让我们能够运用程式中的 Model 物件去操纵 MongoDB。

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

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

var kitty = new Cat({name: 'Zildjian'})
kitty.save(function (err) {
  if (err)
    throw Error('save failed')
  console.log('meow')
})

那我们 require 获得的 mongoose 物件是什么东西呢?事实上 mongoose 模组的内部是这么处理的

function Mongoose() {
  ...
}

module.exports = exports = new Mongoose()

因为 require 的快取了 module.exports 的值,于是一切 reqire('mongoose') 将会回传雷同的物件实例,之后在整个应用程式当中运用的都会是统一个物件。

Mongoose 运用物件导向的设计形式来封装,解耦(分离功用之间的相依性),维护状态使整体具备可读性,同时透过汇出一个 Mongoose Class 的物件给运用者,让我们能够简单的存取运用。

如果我们有须要,它也能够竖立其他的物件实例来作为命名空间运用。实际上 Mongoose 内部供应了存取建构子的要领

Mongoose.prototype.Mongoose = Mongoose

因此我们能够这么做

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

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

扩展全域物件

一个被汇入的模组不只限于单纯获得其汇出的资料。它也能够用来修正全域物件或回传全域物件,天然也能定义新的全域物件。而在这边的全域物件(Global objects)或称为标准内建物件像是 Object, Function, Array 指的是在全域能存取到的物件们,而不是当 Javascript 开始执行时所产生代表 global scope 的 global object。

当我们须要扩增或修正全域物件预设行为时就须要运用这种设计形式。当然这样的体式格局是有争议,您必须谨慎运用,特别是在开放原始码的专案上。

比方:Should.js是一个常被用在单元测试顶用来判断剖析 是不是正确的函式库。

require('should')

var user = {
  name: 'andyyou'
}

user.name.should.equal('andyyou')

这样您是不是比较清晰了,should.js 增添了底层的 Object 的功用,加入了一个非列举型的属性 should ,让我们能够用简洁的语法来撰写单元测试。

而在内部 should.js 做了这样的事变

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
});

就算看到这边您一定跟我一样有满满的迷惑,全域物件扩展定义跟 exports 有啥关联呢?

事实上

/* whoami.js */
exports = module.exports = {
  name: 'andyyou'
}

Object.defineProperty(Object.prototype, 'whoami', {
  set: function () {},
  get: function () {
    return 'I am ' + this.name
  }
})

/* app.js */
var whoami = require('whoami')
console.log(whoami) // { name: 'andyyou' }

var obj = { name: 'lena' }
console.log(obj.whoami) // I am lena

现在我们邃晓了上面说的修正全域物件的意义了。should.js 汇出了一个 should 函式然则它重要的运用体式格局则是把 should 加到 Object 属性上,透过物件自身来呼唤。

套用猴子补丁(Monkey Patch)

在这边所谓的猴子补丁特别指的是在执行时期动态修正一个类别、模组或物件(也称 object augmentation),平常会这么做是愿望补强某的第三方套件的 bug 或功用。

假设某个模组没有供应您客制化功用的介面,而您又须要这个功用的时候,我们就会实作一个模组来补强既有的模组。
这个设计形式有点类似扩展全域物件,但并非修正全域物件,而是依托 Node 模组系统的快取机制,当其他程式码汇入该模组时去补强该模组的实例物件。

预设来说 Mongoose 会运用小写以及复数的惯例替资料模子命名。比方一个资料模子叫做 CreditCard 最终我们会获得 collection 的名称是 creditcards 。如果我们愿望能够换成 credit_cards 并且其他地方也遵照一样的用法。

下面是我们试着运用猴子补丁的体式格局来替既有的模组增添功用

var pluralize = require('pluralize') // 处理复数单字的函式库
var mongoose = require('mongoose')
var Mongoose = mongoose.Mongoose

mongoose.Promise = global.Promise // v4.1+ http://mongoosejs.com/docs/promises.html
var model = Mongoose.prototype.model

// 补丁
var fn = function(name, schema, collection, skipInit) {
  collection = collection || pluralize.plural(name.replace(/([a-z\d])([A-Z])/g, '$1_$2').toLowerCase())
  return model.call(this, name, schema, collection, skipInit)
}
Mongoose.prototype.model = fn

/* 实际测试 */
mongoose.connect('mongodb://localhost/test')
var CreditCardSchema = new mongoose.Schema({number: String})
var CreditCardModel = mongoose.model('CreditCard', CreditCardSchema);

var card = new CreditCardModel({number: '5555444433332222'});
card.save(function (err) {
  if (err) {
    console.log(err)
  }
  console.log('success')
})

您不该轻易运用上面这种体式格局补丁,这边只是为了说明猴子补丁这种体式格局,mongoose 已经有供应官方的体式格局设命名称

var schema = new Schema({..}, { collection: 'your_collection_name' })

当这个模组第一次被汇入的时候便会让 mongoose 从新定义 Mongoose.prototype.model 并将其设回底本的 model 的实作。
云云一来一切 Mongoose 的实例物件都具备新的行为了。注重到这边并没有修正 exports 所以当我们 require 的时候获得的是预设的物件

别的如果您想运用上面这种补丁的体式格局时,记得阅读原始码并注重是不是产生冲突。

请善用汇出的功用

Node模组系统供应了一个简单的机制来封装功用,使我们能够竖立了清晰的介面。愿望控制这七种设计形式供应差别的优缺点能对您有所帮助。

在这边作者并没有彻底的调查一切的体式格局,一定有其他选项可供选择,这边只要形貌几个最常见且不错的要领。

小结

  • namespace: 汇出一个物件包括须要的功用

    • root module 的体式格局,运用一个根模组汇出其他模组

  • function: 直接将 module.exports 设为 function

    • Function 物件也能够拿来当作命名空间运用

    • 为其命名轻易侦错

    • exports = module.exports = something 的作法是为了确保参考(Reference)一致

  • high-order function: 能够透过代入参数控制并回传 function 。

    • 可协助实作 middleware 的设计形式

    • 换句话说 middleware 即一系列雷同 signature 的 function 串连。一个接一个执行

  • constructor: 汇出类别(function),运用时再 new,具备 OOP 的优点

  • singleton: 汇出单一物件实例,重点在各个档案能够同享物件状态

  • global objects: 在全域物件作的修正也会一同被汇出

  • monkey patch: 执行时期,应用 Node 快取机制在 instance 加上补丁

笔记

  • 一个 javascript 档案可视为一个模组

  • 解决特定问题或需求,功用完全由单一或多个模组组合而成的整体称为套件(package)

  • require 汇入的模组具有本身的 scope

  • exports 只是 module.exports 的参考,exports 会记录网络属性如果 module.exports 没有任何属性就把其资料交给 module.exports ,但如果 module.exports 已经具备属性的话,那么exports 的一切资料都会被疏忽。

  • 就算 exports 置于后方仍会被疏忽

  • Node 初始化的顺序

    • Native Module -> Module

    • StartNodeInstance() -> CreateEnvironment() -> LoadEnvironment() -> Cached

  • Native Module 载入机制

    • 检查是不是有快取

    • -> 有; 直接回传 this.exports

    • -> 没有; new 一个模组物件

    • cache()

    • compile() -> NativeModule.wrap() 将原始码包进 function 字串 -> runInThisContext() 竖立函式

    • return NativeModule.exports

  • Node 的 require 会 cache ,也就是说:如果愿望模组产生差别的 instance 时应运用 function

资源

    原文作者:andyyu0920
    原文地址: https://segmentfault.com/a/1190000006955651
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞