深入浅出 ES6-Generator
先不谈概念、定义,我们从实际用例切入。
功能需求
我们现在要做的,是一个基于nodejs+mongoDB的web应用,其中需要一个web页面,显示用户的列表。就这样,需求就这么简单。
编码实现
首先,我们选用了koa作为web层的框架,并且使用mongodb的原生driver访问数据库。
var koa = require('koa');
var route = require('koa-route');
var mongo = require('mongodb').MongoClient;
var app = koa();
// 对/user地址的请求,会被路由到userlist这个函数处理
app.use(route.get('/user',userlist));
app.listen(8002);
function* userlist() {
var _this = this;
// 先连接mongodb,这是异步IO的过程,所以要回调
mongo.connect('mongodb://localhost/koa-test', function(err, db){
// 连接数据库成功后,获取user这个集合
var users = db.collection('user');
// 对user集合进行查询,这也是一个异步IO过程,所以也产生回调
users.find({}).toArray(function(err, docs) {
// 查询完毕,可以关闭数据库连接
db.close();
// 将查询结果写到返回的body中
_this.body = '';
for(var i=0; i<docs.length; i++) {
_this.body += '<h2>' + docs[i].username + '</h2>';
}
});
});
}
上述代码,有两个异步过程,因此产生两个回调函数。为确保异步操作正确完成,最终对返回body的赋值要放到最里面的回调中:
mongo.connect(url, function(){
...
users.find().toArray(function(){
...
_this.body = ...
})
});
但代码实际运行还是会报错:
Error: Can't set headers after they are sent.
这依然是异步造成的问题:koa框架对userlist函数的执行,是不会被connect等异步操作所阻塞,也即mongo.connect触发之后,userlist函数会直接往下走,koa发现没有逻辑了,就把请求返回了。而等mongo.connect、users.find完成后(进入回调函数),请求早已返回,此时对body和header的赋值都将是非法的。
当然,这个问题,也是由于使用koa造成的:如果不实用koa,返回操作是由代码声明,那么就可以在回调中才进行返回操作;而使用了koa,则是由框架执行route handler(定制代码),然后由框架执行返回。
要解决这个问题,就要把异步非阻塞,变成异步阻塞。
这个时候,终于入正题了:ES6-Generator
Generator是什么暂且不详述,我们先看看它如何为我们解决问题:
var koa = require('koa');
var route = require('koa-route');
var monk = require('monk');
var wrap = require('co-monk');
var app = koa();
// 对/user地址的请求,会被路由到userlist这个函数处理
app.use(route.get('/user',userlist));
app.listen(8002);
function* userlist() {
// 连接数据库,非阻塞的异步过程
var db = monk('localhost/koa-test');
// 获取user集合
var users = wrap(db.get('user'));
// 对user集合进行查询,非阻塞异步过程
var userlist = yield users.find({});
// 将查询结果写到返回的body中
this.body = '';
for(var i=0; i<userlist.length; i++) {
this.body += '<h2>' + userlist[i].username + '</h2>';
}
db.close();
}
和之前的代码最大的区别,是两个异步操作(数据库连接,集合查询)的回调函数没有了,代码以更符合人类思维的顺序执行方式,也即代码从横向扩展变成了竖向扩展。
其中,我们要集中看的是这句
var userlist = yield users.find({});
这里在执行users.find之前,多了一个关键字yield。就是它,让我们的异步代码有了阻塞执行的功能。 如果这里我们省去yield,变成这样:
var userlist = users.find({});
代码执行是会报错:
TypeError: Cannot read property 'username' of undefined
这说明,没有yield关键字的时候,users.find异步非阻塞的执行,在等待磁盘IO的时候,下面的逻辑已经在执行
for(var i=0; i<userlist.length; i++) {
this.body += '<h2>' + userlist[i].username + '</h2>';
}
此时查询的磁盘IO还没完成,userlist变量就还没被赋值,所以是undefined,就出现上述的报错了。
至于这一句:
var db = monk('localhost/koa-test');
之所以不需要使用yield关键字,是因为我们使用了monk这个库,monk内部已经进行了包装,所以在外部调用的时候无须加上yield,具体这里不详述。
至此,我们带出的是ES6-Generator中非常重要的yield关键字。要理解generator,必须理解这个yield,除了上面提到的,yield会让异步非阻塞代码变成异步阻塞代码,其实还有一个更好理解的比喻:断点。
看这样一个示例代码:
function test() {
console.log('1');
console.log('2');
console.log('3');
}
就是顺序打印1、2、3,简单到没朋友。加上yield之后呢?
function* test() {
console.log('1');
yield 1;
console.log('2');
yield 2;
console.log('3');
}
代码中间插了两行yield,代表什么呢?
- 当test执行到 yield 1这一行的时候,程序将被挂起,要等待执行下一步的指令;
- 当接收到指令后,test将继续往下运行,直到yield 2这一行,然后程序又被挂起并等待指令;
- 收到指令后,test又将继续运行,而下面已经没有yield了,那么函数运行结束。
这是不是就像,我们调试代码的时候,给插的断点 ?
当然,断点这个比喻,只是表象上比较相像,实质原理还是有非常大差异。
要注意,function后面多了一个星号,这样是表明这个函数将变成一个生成器函数,而不是一个普通函数了。意思就是,test这个函数,将不能被这样执行
test();
但可以获得一个生成器
var gen = test(); // gen就是一个生成器了
然后,生成器可以通过next()来执行运行
gen.next();
也就是上面说的,让函数继续运行的指令。
简单地总结一下:
- 生成器通过yield设置了一些类似”断点“的东西,使得函数执行到yield的时候会被阻断;
- 生成器要通过next()指令一步一步地往下执行(两个yield之间为一步);
- yield 语句后面带着的表达式或函数,将在阻断之前执行完毕;
- yield 语句下面的代码,将不可能在阻断之前被执行;
由此可以看出,yield是如何将异步非阻塞代码,变成 异步阻塞代码。
P.S. generator是要配合执行器使用的,回顾mongodb的示例代码中并没有使用执行器,这是因为使用了koa框架。koa自封装了一个co作为generator的执行器,在koa框架下generator会被co自动执行,所以开发者无需关注这些细节,也因此代码变得更为简洁。
generator是ES6里面一个很重要的部分,目的就是解决JS异步代码带来的问题。generator包含很多概念,如上述的执行器,本文只是从应用场景出发,简单解释了generator是如何解决这些异步代码问题的,里面更深层次的原理未有完整覆盖,如果需要,可以搜索ruanyf的博客查看。