一、基本概念
1、异步
如果一个任务不是连续完成的,那么该任务可以被人为划分为两段,先执行第一段,然后转而执行其他任务,等做好了准备再回过头来准备第二段。而相应的,如果一个任务需要连续执行,中途不能被其他任务插入,那么这种任务就是同步的。
2、回调函数
回调函数就是将函数的第二段写在一个函数里,等到重新执行这个任务的时候,就直接调用该函数,即callback
(重新调用,回调),一个例子如下:
fs.readFile('/path/to/file', 'utf-8', function(err, data) {
// ...
});
Node约定,回调函数的第一个参数,必须是错误对象err(如果没有错误,则该参数是null),这是因为执行分为两段,当第一段执行完成后,任务所在的上下文就已经结束了,在这之后若抛出错误,原来的上下文便无法捕获到这个错误,所以就只能作为参数,传给第二段
3、协程
协程的意思是多个线程互相协作,完成异步任务,协程的运行流程大致如:
- 第一步,协程A开始执行
- 第二步,协程A执行到一半,暂停,执行权转移到协程B
- 第三步,(一段时间后)协程B交还执行权
- 第四步,协程A恢复执行
而generator是ES6中对协程的实现,读取文件的协程写法如:
function* asyncJob() {
// ...
var f = yield readFile(fileName);
// ...
}
当程序执行到yield
语句的时候,就会暂停,然后等到执行权返回(调用next()
方法)后,再从暂停的地方继续往后执行。协程的最大优点就是代码的 写法非常像同步操作
二、使用Generator来进行异步操作
1、暂停执行
Generator函数可以说是异步任务的容器,异步操作需要暂停的地方,都用yield
语句注明,如:
function* gen(x) {
var y = yield x + 2;
return y;
}
const g = gen(x);
g.next(); { value: 3, done: false }
g.next(); { value: undefined, done: true }
next
方法的作用是分阶段执行Generator函数,每次调用next()
方法,都会返回一个对象,表示当前阶段的信息(value
和done
两个属性),value包含了yield
语句后面表达式的值(即会对yield
后接的表达式进行一次求值操作,在异步里则可以用于发出异步请求);done则表示generator函数是否执行完毕
2、数据交换与错误处理
generator的数据交换与错误处理机制,则使得generator能够更好地处理异步任务,作为异步编程的完整解决方案。由于generator可以通过next()
方法的参数由外向内
地输入数据给generator函数内部,所以这就使得当异步操作完成时,数据传给generator函数使用成为了可能,如:
function* gen(x) {
var y = yield x + 2;
return y;
}
const g = gen(1);
g.next(); // { value: 3, done: false }
g.next(2); // { value: 2, done: true }
此外,generator函数内部还能够处理函数体外抛出的错误,如:
function* gen(x) {
try {
var y = yield x + 2;
} catch(e) {
console.log(e);
}
}
var g = gen(1);
g.next();
g.throw(new Error('出错了!'));
这就使得,异步操作里抛出的错误,最后能够返回到generator函数内部被处理,从而在generator内部就能够像同步写法那般处理错误
3、如何进行异步操作
以下来看如何使用generator来处理异步操作,如:
const fetch = require('node-fetch');
function* gen() {
const url = 'http://api.example.com/getUser/1';
const result = yield fetch(url);
console.log(result);
}
const g = gen();
const p = g.next();
p.value.then(function(data) {
return data.json();
}).then(function(data) {
g.next(data);
});
上面代码,可以分为两部分,第一部分主要是generator函数,我们可以把其内部的代码看做同步代码,而第二部分主要是执行
部分,之所以能够使得generator函数内部像同步写法一样写异步操作,分析如下:
yield fetch(url)
时,yield
后的表达式得以求值执行,所以当g.next()
调用的时候,发出请求,并返回fetch(url)
的返回值作为g.next()
返回对象里的value
部分- 拿到这个value后,我们对其进行操作,当这个Promise的状态resolved时,就调用
g.next()
交还程序执行权给generator函数,同时将异步操作得到的数据data作为参数 - generator函数内部能够拿到data,从而成为
result
的值
三、Thunk函数
1、参数的求值策略
参数的求值分为传值调用
和传名调用
,即对于以下的程序:
var x = 1;
function f(m) {
return m*2;
}
f(x+5);
当执行f(x+5)
时,参数是先计算再传给函数呢,还是原封不动传给函数呢?这就有两种策略,前者称为传值调用
,后者称为传名调用
,即:
// 传值调用
f(6);
funtion f(6) {
return 6*2;
}
// 传名调用
f(x+5);
function f(x+5) {
return (x+5)*2;
}
两种方式各有利弊,如传值调用,可能会有参数实际上没用到,却进行了计算所造成的性能损失,如:
function f(a, b) {
return b;
}
f(/* 这是一个很复杂的计算 */, 1);
2、Thunk函数的含义
编译器的传名调用实现,往往是将参数放到一个临时的函数中,再将这个临时函数传入函数体,然后在函数体内执行,而这个临时函数就称为Thunk函数,如前面的例子可以写成:
function thunk() {
return x + 5;
}
function f(thunk) {
return thunk()*2;
}
3、JavaScript中的thunk函数
JavaScript是传值调用,它的thunk函数含义有所不同,在js里,thunk函数替换的不是表达式,而是多参函数,将其器换成一个只接受回调函数作为参数
的单参数函数,如:
// 正常版本(多参数)
fs.readFile(fileName, callback);
// Thunk版本(单参数)
function Thunk(fileName) {
return function(callback) {
return fs.readFile(fileName, callback);
}
}
const readFileThunk = Thunk(fileName);
readFileThunk(callback);
任何函数,只要参数有回调函数,就能够写成Thunk函数的形式,以下是Thunk函数的转化器:
function Thunk(fn) {
return function(...args) {
return function(callback) {
return fn.call(this, ...args, callback);
}
}
}
所以,fs.readFile
可以被转化成Thunk函数如下:
const readFileThunk = Thunk(fs.readFile);
readFileThunk(fileName)(callback);
例子如:
function f(x, cb) {
cb(x);
}
const fThunked = Thunk(f);
fThunked(1)(console.log); // 输出:1
生产环境里,可以使用thunkify
模块,如:
const thunkify = require('thunkify');
const fs = require('fs');
const read = thunkify(fs.readFile);
read('somefile.json')(function(err, data) {
// ...
});
thunkify的源码如下(thunkify限制回调函数只能执行一次):
function thunkify(fn) {
return function() {
var args = new Array(arguments.length);
var ctx = this;
for (var i=0; i<args.length; ++i) {
args[i] = arguments[i];
}
return function(done) {
var called;
args.push(function() {
if (called) return;
called = true;
done.apply(null, arguments);
});
try {
fn.apply(ctx, args);
} catch(err) {
done(err);
}
}
}
}
四、Generator函数的流程管理
Thunk函数可用于generator函数的自动流程管理。
对于同步操作,generator函数可以自动执行,如:
function* gen() {
// ...
}
const g = gen();
let res = g.next();
while (!res.done) {
console.log(res.value);
res = g.next();
}
但是这不适合异步操作,如果必须保证上一步执行完才能执行下一步,那么:
const fs = require('fs');
const thunkify = require('thunkify');
const readFileThunk = thunkify(fs.readFile);
function* gen() {
const r1 = yield readFileThunk('fileName1');
console.log(r1);
const r2 = yield readFileThunk('fileName2');
console.log(r2);
}
其中,yield
命令用于将程序的执行权移出generator函数,那么就需要一种方法能够将执行权再交换给generator函数的,为了便于理解,需要先知道如何手动执行上面那个generator函数:
const g = gen();
let r1 = g.next();
r1.value(function(err, data) {
if (err) throw err;
let r2 = g.next(data);
r2.value(function(err, data) {
if (err) throw err;
g.next(data);
});
});
可以发现:generator函数的执行过程,是将同一个回调函数,反复传入next()
方法返回的value属性。所以,这一过程,我们就可以用递归和Thunk函数来自动完成:
function run(fn) {
const gen = fn();
next();
function next(err, data) {
const result = gen.next(data);
if (result.done) return;
result.value(next);
}
}
run(function* () {
// ...
});
如此一来,我们就可以这么写了:
run(function* () {
const f1 = yield readFileThunk('file1');
const f2 = yield readFileThunk('file2');
const f3 = yield readFileThunk('file3');
// ...
const fn = yield readFileThunk('fileN');
});
五、co模块
co模块是TJ Holowaychuk开发的一个小工具,用于Generator函数的自动执行。使用co模块,我们就可以无需编写Generator函数的执行器,如:
const co = require('co');
co(function* () {
const f1 = yield readFileThunk('file1');
const f2 = yield readFileThunk('file2');
});
co函数返回一个Promise对象,所以可以用then
方法添加回调函数,如:
co(gen).then(function() {
console.log('executed over');
});
1、模块的原理
co模块的原理是将两种自动执行器(Thunk函数和Promise对象)包装成一个模块,使用co的前提是:Generator函数的yield
命令后面,只能是Thunk
函数或者Promise
对象。如果数组或对象的成员,全部都是Promise对象,则也可以使用co
注意: co v4.0以后,yield
命令后面只能是Promise对象,不再支持Thunk函数、
2、基于Promise对象的自动执行
沿用以上例子,我们首先将fs
模块的readFile
方法包装成一个Promise对象,如下:
const fs = require('fs');
function readFile(fileName) {
return new Promise(function(resolve, reject) {
fs.readFile(fileName, function(error, data) {
if (error) return reject(error);
resolve(data);
});
});
}
function* gen() {
const f1 = yield readFile('file1');
const f2 = yield readFile('file2');
console.log(f1);
console.log(f2);
}
为了便于理解,手动执行以上的Generator函数如:
const g = gen();
g.next().value.then(function(data) {
g.next(data).value.then(function(data) {
g.next(data);
});
});
所以可以写出自动执行器如下:
function run(gen) {
const g = gen();
next();
function next(data) {
const result = g.next(data);
if (result.done) return result.value;
result.value.then(function(data) {
next(data);
});
}
}
3、co模块源码分析
首先,co模块接收Generator函数作为参数,返回一个Promise对象,如下:
// 代码片段1
function co(gen) {
const ctx = this;
return new Promise(function(resolve, reject)) {
// 代码片段2
});
}
在代码片段2
中,co首先检查参数gen
是否为Generator函数,如果是就执行该函数,如果不是就返回,并将Promise对象的状态改为resolved
,如下:
function co(gen) {
const ctx = this;
return new Promise(function(resolve, reject) {
if (typeof gen === 'function') gen = gen.call(ctx);
if (!gen || typeof gen.next !== 'function') return resolve(gen);
// 代码片段3
});
}
在代码片段3中,co将Generator函数指针对象的next
方法,包装成onFulfilled
函数,为了能够捕获抛出的错误:
function co(gen) {
const ctx = this;
return new Promise(function(resolve, reject) {
if (typeof gen === 'function') gen = gen.call(ctx);
if (!gen || typeof gen.next !== 'function') return resolve(gen);
onFullfilled();
function onFullfilled(res) {
let ret;
try {
ret = gen.next(res);
} catch(e) {
return reject(e);
}
next(ret);
}
});
}
next()
函数是关键的函数,它会反复调用自身来实现自动执行:
function next(ret) {
if (ret.done) return resolve(ret.value);
let value = toPromise.call(ctx, ret.value);
if (value && isPromise(value)) {
return value.then(onFullfilled, onRejected);
}
return onRejected(
new TypeError(
'You may only yield a function, promise, generator, array or object, '
+ 'but the following object was passed: "'
+ String(ret.value)
+ '"'
)
);
}
4、co处理并发的异步操作
co可以允许某些操作同时进行,等到它们全部完成后,才进行下一步。这需要:将并发的操作都放在数组或对象里面,跟在yield
语句后面,如:
co(function* () {
const res = yield [
Promise.resolve(1),
Promise.resolve(2)
];
console.log(res);
});