nodejs设计模式
以下内容来自:[《Node.js Design Patterns Second Edition》]
(https://book.douban.com/subject/26819950/)
箭头函数
箭头函数更易于理解,特别是在我们定义回调的时候:
const numbers = [2, 6, 7, 8, 1];
const even = numbers.filter(function(x) {
return x % 2 === 0;
});
使用箭头函数语法,更简洁:
const numbers = [2, 6, 7, 8, 1];
const even = numbers.filter(x => x % 2 === 0);
如果不止一个 return 语句则使用 => {}
const numbers = [2, 6, 7, 8, 1];
const even = numbers.filter((x) => {
if (x % 2 === 0) {
console.log(x + ' is even');
return true;
}
});
最重要是,箭头函数绑定了它的词法作用域,其 this 与父级代码块的 this 相
同。
function DelayedGreeter(name) {
this.name = name;
}
DelayedGreeter.prototype.greet = function() {
setTimeout(function cb() {
console.log('Hello' + this.name);
}, 500);
}
const greeter = new DelayedGreeter('World');
greeter.greet(); // 'Hello'
要解决这个问题,使用箭头函数或 bind
function DelayedGreeter(name) {
this.name = name;
}
DelayedGreeter.prototype.greet = function() {
setTimeout(function cb() {
console.log('Hello' + this.name);
}.bind(this), 500);
}
const greeter = new DelayedGreeter('World');
greeter.greet(); // 'HelloWorld'
或者箭头函数,与父级代码块作用域相同:
function DelayedGreeter(name) {
this.name = name;
}
DelayedGreeter.prototype.greet = function() {
setTimeout(() => console.log('Hello' + this.name), 500);
}
const greeter = new DelayedGreeter('World');
greeter.greet(); // 'HelloWorld'
class 语法糖
class 是原型继承的语法糖,对于来自传统的面向对象语言的所有开发人员(如 Java 和 C# )来说更熟悉,新语法并没有改变 JavaScript 的运行特征,通过原型来完成更加方便和易读。传统的通过 构造器 + 原型 的写法:
function Person(name, surname, age) {
this.name = name;
this.surname = surname;
this.age = age;
}
Person.prototype.getFullName = function() {
return this.name + '' + this.surname;
}
Person.older = function(person1, person2) {
return (person1.age >= person2.age) ? person1 : person2;
}
使用 class 语法显得更加简洁、方便、易懂:
class Person {
constructor(name, surname, age) {
this.name = name;
this.surname = surname;
this.age = age;
}
getFullName() {
return this.name + '' + this.surname;
}
static older(person1, person2) {
return (person1.age >= person2.age) ? person1 : person2;
}
}
但是上面的实现是可以互换的,但是,对于 class 语法来说,最有意义的是 extends 和 super 关键字。
class PersonWithMiddlename extends Person {
constructor(name, middlename, surname, age) {
super(name, surname, age);
this.middlename = middlename;
}
getFullName() {
return this.name + '' + this.middlename + '' + this.surname;
}
}
这个例子是真正的面向对象的方式,我们声明了一个希望被继承的类,定义新的构造器,并可以使用 super 关键字调用父构造器,并重写 getFullName 方法,使得其支持 middlename 。
对象字面量的新语法
允许缺省值:
const x = 22;
const y = 17;
const obj = { x, y };
允许省略方法名
module.exports = {
square(x) {
return x * x;
},
cube(x) {
return x * x * x;
}
};
key的计算属性
const namespace = '-webkit-';
const style = {
[namespace + 'box-sizing']: 'border-box',
[namespace + 'box-shadow']: '10px 10px 5px #888'
};
新的定义getter和setter方式:
const person = {
name: 'George',
surname: 'Boole',
get fullname() {
return this.name + ' ' + this.surname;
},
set fullname(fullname) {
let parts = fullname.split(' ');
this.name = parts[0];
this.surname = parts[1];
}
};
console.log(person.fullname); // "George Boole"
console.log(person.fullname = 'Alan Turing'); // "Alan Turing"
console.log(person.name); // "Alan"
这里,第二个 console.log 触发了 set 方法。
CPS
同步CPS
在 JavaScript 中,回调函数作为参数传递给另一个函数,并在操作完成时调用。在函数式编程中,这种传递结果的方法被称为 CPS 。这是一个一般概念,而且不只是对于异步操作而言。实际上,它只是通过将结果作为参数传递给另一个函数(回调函数)来传递结果,然后在主体逻辑中调用回调函数拿到操作结果,而不是直接将其返回给调用者。
为了更清晰地理解 CPS ,让我们来看看这个简单的同步函数:
function add(a, b) {
return a + b;
}
上面的例子成为直接编程风格,其实没什么特别的,就是使用 return 语句把结果直接传递给调用者。它代表的是同步编程中返回结果的最常见方法。上述功能的 CPS 写法如下:
function add(a, b, callback) {
callback(a + b);
}
add() 函数是一个同步的 CPS 函数, CPS 函数只会在它调用的时候才会拿
到 add() 函数的执行结果,下列代码就是其调用方式:
console.log('before');
add(1, 2, result => console.log('Result: ' + result));
console.log('after');
既然 add() 是同步的,那么上述代码会打印以下结果:
before
Result: 3
after
异步CPS
那我们思考下面的这个例子,这里的 add() 函数是异步的:
function additionAsync(a, b, callback) {
setTimeout(() => callback(a + b), 100);
}
在上边的代码中,我们使用 setTimeout() 模拟异步回调函数的调用。现在,我
们调用 additionalAsync ,并查看具体的输出结果。
console.log('before');
additionAsync(1, 2, result => console.log('Result: ' + result));
console.log('after');
上述代码会有以下的输出结果:
before
after
Result: 3
因为 setTimeout() 是一个异步操作,所以它不会等待执行回调,而是立即返
回,将控制权交给 addAsync() ,然后返回给其调用者。 Node.js 中的此属性至
关重要,因为只要有异步请求产生,控制权就会交给事件循环,从而允许处理来自
队列的新事件。
下面的图片显示了 Node.js 中事件循环过程:
image.png
当异步操作完成时,执行权就会交给这个异步操作开始的地方,即回调函数。执行将从事件循环开始,所以它将有一个新的堆栈。对于 JavaScript 而言,这是它的优势所在。正是由于闭包保存了其上下文环境,即使在不同的时间点和不同的位置调用回调,也能够正常地执行。同步函数在其完成操作之前是阻塞的。而异步函数立即返回,结果将在事件循环的稍后循环中传递给处理程序(在我们的例子中是一个回调)。
Node.js回调风格
对于 Node.js 而言, CPS 风格的 API 和回调函数遵循一组特殊的约定。这些约定不只是适用于 Node.js 核心 API ,对于它们之后也是绝大多数用户级模块和应用程序也很有意义。因此,我们了解这些风格,并确保我们在需要设计异步 API 时遵守规定显得至关重要。
回调总是最后一个参数在所有核心 Node.js 方法中,标准约定是当函数在输入中接受回调时,必须作为最后一个参数传递。我们以下面的 Node.js 核心 API 为例:
fs.readFile(filename, [options], callback);
从前面的例子可以看出,即使是在可选参数存在的情况下,回调也始终置于最后的位置。其原因是在回调定义的情况下,函数调用更可读。
错误处理总在最前在 CPS 中,错误以不同于正确结果的形式在回调函数中传递。在 Node.js 中, CPS 风格的回调函数产生的任何错误总是作为回调的第一个参数传递,并且任何实际的结果从第二个参数开始传递。如果操作成功,没有错误,
第一个参数将为 null 或 undefined 。看下列代码:
fs.readFile('foo.txt', 'utf8', (err, data) => {
if (err)
handleError(err);
else
processData(data);
});
上面的例子是最好的检测错误的方法,如果不检测错误,我们可能难以发现和调试代码中的 bug ,但另外一个要考虑的问题是错误总是为 Error 类型,这意味着简单的字符串或数字不应该作为错误对象传递(难以被 try catch 代码块捕获)。
错误传播
对于同步阻塞的写法而言,我们的错误都是通过 throw 语句抛出,即使错误在错误栈中跳转,我们也能很好地捕获到错误上下文。
但是对于 CPS 风格的异步调用而言,通过把错误传递到错误栈中的下一个回调来完成,下面是一个典型的例子:
const fs = require('fs');
function readJSON(filename, callback) {
fs.readFile(filename, 'utf8', (err, data) => {
let parsed;
if (err)
// 如果有错误产生则退出当前调用
return callback(err);
try {
// 解析文件中的数据
parsed = JSON.parse(data);
} catch (err) {
// 捕获解析中的错误,如果有错误产生,则进行错误处理
return callback(err);
}
// 没有错误,调用回调
callback(null, parsed);
});
};
从上面的例子中我们注意到的细节是当我们想要正确地进行异常处理时,我们如何向 callback 传递参数。此外,当有错误产生时,我们使用了 return 语句,立即退出当前函数调用,避免进行下面的相关执行。
不可捕获的异常
从上述 readJSON() 函数,为了避免将任何异常抛到 fs.readFile() 的回调函数中捕获,我们对 JSON.parse() 周围放置一个 try catch 代码块。在异步回调中一旦出错,将抛出异常,并跳转到事件循环,不把错误传播到下一个回调函数去。
在 Node.js 中,这是一个不可恢复的状态,应用程序会关闭,并将错误打印到标准输出中。为了证明这一点,我们尝试从之前定义的 readJSON() 函数中删除 try catch 代码块:
const fs = require('fs');
function readJSONThrows(filename, callback) {
fs.readFile(filename, 'utf8', (err, data) => {
if (err) {
return callback(err);
}
// 假设parse的执行没有错误
callback(null, JSON.parse(data));
});
};
在上面的代码中,我们没有办法捕获到 JSON.parse 产生的异常,如果我们尝试传递一个非标准 JSON 格式的文件,将会抛出以下错误:
SyntaxError: Unexpected token d
at Object.parse (native)
at [...]
at fs.js:266:14
at Object.oncomplete (fs.js:107:15)
现在,如果我们看看前面的错误栈跟踪,我们将看到它从 fs 模块的某处开始,恰好从本地 API 完成文件读取返回到 fs.readFile() 函数,通过事件循环。这些信息都很清楚地显示给我们,异常从我们的回调传入堆栈,然后直接进入事件循环,最终被捕获并抛出到控制台中。 这也意味着使用 try catch 代码块包装对 readJSONThrows() 的调用将不起作用,因为块所在的堆栈与调用回调的堆栈
不同。以下代码显示了我们刚才描述的相反的情况:
try {
readJSONThrows('nonJSON.txt', function(err, result) {
// ...
});
} catch (err) {
console.log('This will not catch the JSON parsing exception');
}
前面的 catch 语句将永远不会收到 JSON 解析异常,因为它将返回到抛出异常的
堆栈。我们刚刚看到堆栈在事件循环中结束,而不是触发异步操作的功能。 如前所
述,应用程序在异常到达事件循环的那一刻中止,然而,我们仍然有机会在应用程
序终止之前执行一些清理或日志记录。事实上,当这种情况发生时, Node.js 会
在退出进程之前发出一个名为 uncaughtException 的特殊事件。以下代码显示了
一个示例用例:
process.on('uncaughtException', (err) => {
console.error('This will catch at last the ' +
'JSON parsing exception: ' + err.message);
// Terminates the application with 1 (error) as exit code:
// without the following line, the application would continue
process.exit(1);
});
重要的是,未被捕获的异常会使应用程序处于不能保证一致的状态,这可能导致不可预见的问题。例如,可能还有不完整的 I/O 请求运行或关闭可能会变得不一致。这就是为什么总是建议,特别是在生产环境中,在接收到未被捕获的异常之后写上述代码进行错误日志记录。
Node.js 的模块化鼓励我们遵循采用单一职责原则( SRP ):每个模块应该对
单个功能负责,该职责应完全由该模块封装,以保证复用性。
注意,这里讲的 substack模式 ,就是通过仅导出一个函数来暴露模块的主要功
能。使用导出的函数作为命名空间来导出别的次要功能。
构造器(类)导出
导出构造函数的模块是导出函数的模块的特例。其不同之处在于,使用这种新模
式,我们允许用户使用构造函数创建新的实例,但是我们也可以扩展其原型并创建
新类(继承)。以下是此模式的示例:
// 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');
通过 ES2015 的 class 关键字语法也可以实现相同的模式:
class Logger {
constructor(name) {
this.name = name;
}
log(message) {
console.log(`[${this.name}] ${message}`);
}
info(message) {
this.log(`info: ${message}`);
}
verbose(message) {
this.log(`verbose: ${message}`);
}
}
module.exports = Logger;
鉴于 ES2015 的类只是原型的语法糖,该模块的使用将与其基于原型和构造函数的方案完全相同。导出构造函数或类仍然是模块的单个入口点,但与 substack模式 比起来,它暴露了更多的模块内部结构。然而,另一方面,当想要扩展该模块功能时,我们可以更加方便。这种模式的变种包括对不使用 new 的调用。这个小技巧让我们将我们的模块用作工厂。看下列代码:
function Logger(name) {
if (!(this instanceof Logger)) {
return new Logger(name);
}
this.name = name;
};
其实这很简单:我们检查 this 是否存在,并且是 Logger 的一个实例。如果这些条件中的任何一个都为 false ,则意味着 Logger() 函数在不使用 new 的情况下被调用,然后继续正确创建新实例并将其返回给调用者。这种技术允许我们将模块也用作工厂:
// file logger.js
const Logger = require('./logger');
const dbLogger = Logger('DB');
accessLogger.verbose('This is a verbose message');
ES2015 的 new.target 语法从 Node.js 6 开始提供了一个更简洁的实现上述功能的方法。该利用公开了 new.target 属性,该属性是所有函数中可用的 元属性 ,如果使用 new 关键字调用函数,则在运行时计算结果为 true 。 我们可以使用这种语法重写工厂:
function Logger(name) {
if (!new.target) {
return new LoggerConstructor(name);
}
this.name = name;
}
这个代码完全与前一段代码作用相同,所以我们可以说 ES2015 的 new.target 语法糖使得代码更加可读和自然。