node设计模式第二章-回调/模块/观察者模式

回调

闭包的概念: 一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。

闭包可以模拟私有方法。

var Counter = (function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  }
})();

console.log(Counter.value()); /* logs 0 */
Counter.increment();
Counter.increment();
console.log(Counter.value()); /* logs 2 */
Counter.decrement();
console.log(Counter.value()); /* logs 1 */

cps(continuation passing style)

回调,是一个作为参数传递给另一个函数的函数,当操作完成时将调用该函数,在函数编程中,这种传播方式称为cps, 这是一个通用概念,并不总是与异步操作关联

cps即表示通过将结果传递给另一个函数(回调)而使结果传播,而不是直接返回给调用者。

同步函数会发生阻塞,直到它完成操作,异步函数会立即返回,并且在事件循环的后续周期将结果传递给处理程序(回调)

回调约定

回调函数置尾,当一个函数在输入中接受一个回调时,它必须作为最后一个参数传递

暴露错误优先,cps函数产生的任何错误总是作为回调的第一个参数传递

传播错误,在异步cps中,可以将错误简单传递到链中的下一个回调来进行正确的错误传播

未捕获异常,在异步回调中抛出异常将导致异常跳转到事件循环,并且不会传播到下一个回调,在nodejs中这是一个不可恢复的状态,应用程序将简单地关闭并输出错误到stderr接口。

模块

模块用于构造程序时,隐藏信息保护一些函数等。CommonJS 是一个旨在标准化JavaScript生态系统的团体,其中最受欢迎的提案之一称为 CommonJS模块, nodejs在这个规范之上构建了模块系统。

书中写了一个简单代码来能让人快速理解其中模块的构造

function loadModule(filename, module, require){

function loadModule(filename, module, require){
	const wrappedSrc = `(function(module, exports, require){
    ${fs.readFileSync(filename, 'utf8')}
  })(module, module.exports, require);` ;
  eval(wrappedSrc);
}

模块的源代码基本上被包装到一个函数中,将变量列表 module exports require 传递给了模块

require加在模块的示例

 const require = (moduleName) => {
  console.log(`Require invoked for module: ${moduleName}`);
  const id = require.resolve(moduleName); //[1] 模块名称被接受作为输入, 解析模块的完整路径,称之为id, 由 require.resolve 完成,它实现了一个特定的解析算法
  if(require.cache[id]) {                 //[2] 如果已经加载过模块,那应该在缓存中,所以可以直接从缓存返回
    return require.cache[id].exports;
  }

  //module metadata
  const module = {                        //[3] 如果模块没有加载,需要为首次加载设置运行环境,创建一个module对象,其中包含用空对象字面量初始化的 exports属性, 该属性将用于模块的代码导出任何公共API
    exports: {},
    id: id
  };
  //Update the cache
  require.cache[id] = module;             //[4] module 对象被缓存
  //load the module
  loadModule(id, module, require);        //[5] 模块源代码从其文件被读取,代码被评估, 通过操作或替换 module.exports 对象来导出其公共API
  //return exported variables
  return module.exports;                  //[6] module.exports 的内容表示模块的公共API, 返回给调用者
};
require.cache = {};
require.resolve = (moduleName) => {
     /* resolve a full module id from the moduleName */
};

简要概述就是require加载对应的模块之后,将其模块中的module.exports返回给require。

exports与module.exports

使用时经常看到这2个却不晓得其区别,查阅资料如下

从上面的require函数可以知道,require是将module.exports返回出去。那么exports呢

exports变量,为了方便,Node为每个模块提供一个exports变量,指向module.exports。这等同在每个模块头部,有一行这样的命令。

var exports = module.exports;

造成的结果是,在对外输出模块接口时,可以向exports对象添加方法。

exports.area = function (r) {
  return Math.PI * r * r;
};

exports.circumference = function (r) {
  return 2 * Math.PI * r;
};

不能直接将exports变量指向一个值,因为这样等于切断了exportsmodule.exports的联系。

exports = function(x) {console.log(x)};

下面的写法也是无效的。

exports.hello = function() {
  return 'hello';
};

module.exports = 'Hello world';

上面代码中,hello函数是无法对外输出的,因为module.exports被重新赋值了。

总结而来,exports默认指向了module.exports的引用地址,在不改变exports地址和不重新赋值module.exports的情况下,他们是等价的

require函数是同步的。

模块解析规则

解析主要分为三个主要部分,文件模块,核心模块,包模块

  1. 文件模块:如果moduleName以/开头,则被视为模块的绝对路径,如果以 ./ 开头,则被视为相对路径

  2. 核心模块:如果moduleName没有前缀/ ./ , 则算法首先尝试从nodejs核心模块中搜索

  3. 包模块:如果在核心模块中没找到与moduleName 匹配的,就在所在目录的 node_modules 目录下面查找,并从所在目录结构中向上导航,直至在路径上的node_modules目录下面找到了为止,否则报错。

对于文件或者包模块,单个文件和目录都可以与moduleName匹配。算法将会尝试匹配以下

  • .js
  • /index.js
  • /package.json

node_module目录实际是npm安装每个包依赖的地方。每个包都是可以有自己私有依赖。

模块缓存

每个模块仅在第一次需要时才加载和评估,后续任何require调用都将简单地返回缓存的版本,模块缓存通过 require.cache 变量公开, 因此如果需要,可以直接访问

使用缓存使得模块依赖项中可以有循环, 在某种程度上保证在给定包中需要相同模块时总是返回相同的实例。

循环依赖

循环以来可能导致模块加载不完整,应该避免循环依赖。而且其不会在编译期发现,容易引发bug

模块定义模式

最大限度的隐藏信息和API可用性,可扩展性,代码重用

命名导出

导出一个公开接口最基本的方式是使用命名导出,即把我们想导出的值赋给 exports (或者 module.exports)

导出函数

给整个 module.exports 赋值一个函数也是一种非常流行的方式。主要的优点是只导出一个功能更加清晰明了,这被社区叫作垂直模式(substack pattern)。

暴露构建器

暴露构建器专门用于暴露函数。不同的是这种模式允许我们使用构建器创建一个新的实例,但是我们可以扩展原型并伪造新的类。

//file logger.js
function Logger(name) {
 this.name = name;
}
Logger.prototype.log = function(message) {
 console.log(`[${this.name}] ${message}`);
};
Logger.prototype.info = function(message) {
 this.log(`info: ${message}`);
};
Logger.prototype.verbose = function(message) {
 this.log(`verbose: ${message}`);
};
module.exports = Logger;

//file main.js
const Logger = require('./logger');
const dbLogger = new Logger('DB');
dbLogger.info('This is an informational message');
const accessLogger = new Logger('ACCESS');
accessLogger.verbose('This is a verbose message');

如上就是常用的导出构造函数,使其类更利于拓展,增加其灵活性。在taf中其log也是这种形式的导出,便于我们封装出我们想要的类

暴露一个实例

在常用的业务代码中service,dao层通常都是暴露一个实例,因为其中的方法都是无状态的,不需要有多个实例。

module.exports = new Logger();

观察者模式

观察者模式,常用设计模式之一,其定义了一个对象,当他的状态发生改变时,他可以通知一组观察者。

EventEmitter

观察者模式已经被构建到 node 核心中并以 EventEmitter 类暴露。EventEmitter 允许我们注册一个或多个监听器。

一些触发器的基础方法:

  • on(event, listener): 这个方法允许我们来为给定的事件注册一个新的监听器
  • once(event, listener): 这个方法注册一个新的监听器且只监听一次
  • emit(event, [arg1], [...]): 这个方法将产生新的事件并可传入多个参数
  • removeListener(event, listener): 这个方法移除事件的监听器

因为所有的方法都会返回 EventEmitter 实例所以你可以进行串联。监听函数只接受发射器上发射的参数。

我们可以发现很多与回调不同的地方,尤其是第一个参数不再是 error 对象。

示例
const EventEmitter = require('events').EventEmitter;
   const fs = require('fs');
   function findPattern(files, regex) {
     const emitter = new EventEmitter();
     files.forEach(function(file) {
       fs.readFile(file, 'utf8', (err, content) => {
         if(err)
           return emitter.emit('error', err);
         emitter.emit('fileread', file);
         let match;
         if(match = content.match(regex))
           match.forEach(elem => emitter.emit('found', file, elem));
       });
    });
    return emitter;
}

findPattern(
       ['fileA.txt', 'fileB.json'],
       /hello \w+/g
     )
     .on('fileread', file => console.log(file + ' was read'))
     .on('found', (file, match) => console.log('Matched "' + match +
       '" in file ' + file))
     .on('error', err => console.log('Error emitted: ' + err.message));

创建好的发射器产生三个事件:

  • fileread: 文件被读取时发生
  • found: 匹配找到时发生
  • error: 在读取文件发生错误时触发
传递错误

EventEmitter 在回掉内出现时不会在错误发生时抛出一个异常,因为如果事件是被异步触发的那么它将在事件轮询内丢失。每次都注册一个监听 error 事件的监听器是一项最佳实践,因为 Node.js 将以一种特别的方式来处理它然后再抛出一个异常,并在没有发现相关监听器时退出程序。

观察一切对象

有时直接从 EventEmitter 类创建一个新的对象还不够,因为提供生成新事件以外的功能不切实际。我们可以拓展EventEmitter类,来实现

示例

const EventEmitter = require('events').EventEmitter;
   const fs = require('fs');
   class FindPattern extends EventEmitter {
     constructor (regex) {
       super();
       this.regex = regex;
       this.files = [];
     }
     addFile (file) {
       this.files.push(file);
       return this;
     }
     find () {
       this.files.forEach( file => {
         fs.readFile(file, 'utf8', (err, content) => {
           if (err) {
             return this.emit('error', err);
           }
           this.emit('fileread', file);
           let match = null;
           if (match = content.match(this.regex)) {
             match.forEach(elem => this.emit('found', file, elem));
           }
         });
       });
       return this;
     }
   }

const findPatternObject = new FindPattern(/hello \w+/);
   findPatternObject
     .addFile('fileA.txt')
     .addFile('fileB.json')
     .find()
     .on('found', (file, match) => console.log(`Matched "${match}"
       in file ${file}`))
     .on('error', err => console.log(`Error emitted ${err.message}`));

EventEmitter 与回调的选择

回调在支持不同类型的事件时会有一些限制。 实际上,我们可以传入多种类型的参数来区分事件。 但是这算不上是优雅的 API。在这种情况下 EventEmitter 更适合。

对另一个函数来说,EventEmitter 可能更适合当相同的事件可以多次发生时。回调实际上只期待被调用一次,无论操作成功与否。当然这里使用 EventEmitter 更合适。

最后,一个使用回调的接口只可以指定一个回调,而 EventEmitter 可以使用多个监听器来接收相同的事件。

观察者模式,对于业务类型多的更利于其拓展