JS高阶五(JS中的异步编程下)
Posted 稻香Snowy
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JS高阶五(JS中的异步编程下)相关的知识,希望对你有一定的参考价值。
JS异步编程之生成器
我们确定了会调函数表达异步控制流程的两个关键缺陷:
基于回调的异步不符合大脑对任务步骤的规划方式;
由于控制反转,回调并不是可信任或可组合的 Promise实现了将回调的控制反转回来,恢复了可信任/可组合性。
现在我们把注意力转移到一种顺序,看似同步的异步流程控制表达风格。是这种风格称为可能的“魔法”就是ES6中的生成器(Generator)。
一、打破完整运行
在回调函数那一章,我们解释javascript开发者在代码中几乎普遍依赖的一个假定:一个函数一旦开始执行,就会运行到结束,期间不会有其他代码能够打断它并插入期间。
可能看起来似乎有点奇怪,不过ES6引入了一个新的函数类型,它并不符合这种运行到结束的特性。这类新的函数被成为生成器。
考虑如下这个例子来了解其含义:
var x=1;
function foo(){
x++;
bar();
console.log('x:',x);
}
function bar(){
x++;
}
foo();//x:3
在这个例子中,我们确信bar()会在x++和console.log(x)之间运行。但是,如bar()并不在那里会怎样呢?显然结果会是2,而不是3.
现在想一下。如果bar()并不在那儿,但出于某种原因它仍然可以在x++和console.lgo(x)语句之间运行,这又会怎样呢?这如何才会成为可能呢?
如果foo()自身可以通过某种形式在代码的这个位置指示暂停的话,那就仍然可以以一种合作式的方式实现这样的中断(并发)。
ES6代码中指定暂停点的语法是yield,这也礼貌地表达了一种合作式的控制放弃。
下面是实现这样的方式并发的ES6代码:
var x=1;
function *foo(){
x++;
yield;//暂停
console.log("x",x);
}
function bar(){
x++;
}
现在,我们要如何运行前面的代码,使得bar()在*foo()内部的yield处执行呢?
//构造一个迭代器it来控制这个生成器
var it=foo();
//这里启动foo()!
it.next();
console.log(x);//2
bar();
console.log(x);//3
it.next();//x:3
上面的代码运行过程:
it=foo()运算并没有执行生成器*foo(),而只是构造了一个迭代器(iterator),这个迭代器会控制它的执行。
第一个it.next()启动了生成器*foo(),并运行生成器函数中的第一行x++
foo()在yield语句出暂停,在这一点上第一个it.next()调用结束,此时生成器仍然在运行并且是活跃的,但是处于暂停状态。
我们查看x的值,此时为2
我们调用bar(),它通过x++再次递增x。
我们再次查看x的值,此时为3.
最后的it.next()调用从暂停处恢复了生成器的运行,并运行console.log(..)语句,这条语句使用当前x的值3.
显然,foo()启动了,但是没有完整运行,它在yield出暂停了。后面恢复了foo()并让它运行到结束,但这不是必需的。
因此,生成器是一类特殊的函数,可以一次或者多次启动和停止,并不一定非得要。
1.输入和输出
生成器函数是一个特殊的函数,具有前面我们展示的新的执行模式。但是,它仍然是一个函数,这意味着它仍然有一些基本的特性没有改变。比如,它仍然可以接受参数(即输入),也能够返回值(即输出)。
function *foo(x,y){
return x*y;
}
var it=foo(6,7);
var res=it.next();
res.value;//42
现在我们可以看到生成器和普通函数在调用上的一个区别,显然foo(6,7)看起来很熟悉。但难以理解的是,生成器foo(...)并没有像普通函数一样实际运行。
事实上,我们只是创建了一个迭代器对象,把它赋给了一个变量it,用于控制生成器foo(...)。然后调用it.next(),指示生成器从当前位置开始继续运行,停在下一个yield处或者直到生成器结束。
这个next(...)调用的结果是一个对象,它有一个value属性,持有从生成器返回的值(如果有的话)。换句话说,yield会导致生成器在执行过程中发出一个值,这有点类似于中间的return。
1.迭代消息传递
除了能够接受参数并提供返回值之外,生成器甚至提供了更强大更引人注目的内建消息输入输出能力,通过yield和next(...)实现。
考虑:
function *foo(){
var y=x*(yield);
return y;
}
var it=foo(6);
//启动foo(...)
it.next();
var res=it.next(7);
console.log(res.value);//42
首先6作为参数x。然后调用it.next(),这会启动*foo(...)。
在*foo(...)内部,开始执行语句var y=x...但随后就遇到来了yield表达式。它就会在这一点上暂停生成器函数(在赋值语句中间),并在本质上要求了调用代码为yield表达式提供一个结果值。接下来it.next(7),这一句吧值7传回作为被暂停的yield表达式的结果。
所以,这是赋值语句实际上就是var y=6*7。现在,return y返回值42作为调用it.next(7)的结果。
一般来书,需要的next(...)调用要比yield表达式语句多一个。
消息是双向绑定的:yield作为一个表达式可以发出消息,响应next(...)调用,next(...)也可以向暂停的yield表达式发送值。
考虑如下示例:
function *foo(x){
var y=x*(yield 'hello');
return y;
}
var it=foo(6);
var res=it.next();//第一个next(),并不传入任何东西
console.log(res.value);//'Hello'
res=it.next(7);//向等待的yield传入7
console.log(res.value);//42
yield..和next(...)这一堆组合起来,在生成器的执行火锅城中够成一个双向消息传递系统。
我们并没有向第一个next()调用发送值,这是有意为之。只有暂停的yield才能接受这样一个通过next(...)传递的值。而生成器的起始处我们调用第一个next()时还没有暂停的yield来接受这样的一个值。规范和所有兼容浏览器都会默默丢弃传递给第一个next()的任何东西。因此,启动生成器时一定要不带参数的next()。
第一个next()调用(没有参数的)基本就是在提示出一个问题:“生成器*foo()要给我们的下一个值是什么”谁来回答这个问题呢?第一个“yield "hello"”表达式。
如果你的生成器函数中没有return语句的话,在生成器中和普通函数中一样。
return当然不是必须的——总有一个假定的(隐式的)return,也就是return undefined。当然它在默认情况下回答的是最后的it.next(7)调用提出的问题。
2.多个迭代器
每次构建一个迭代器,实际上就是隐式构建了生成器的一个实例,通过这个迭代器来控制的是这个生成器的实例。
同一个生成器的实例可以同时运行,它们甚至可以彼此交互。
funciton *foo(){
var x=yield 2;
z++;
var y=yield(x*z);
console.log(x,y,z);
}
var z=1;
var it1=foo();
var it2=foo();
var val1=it1.next().value;//2 yield 2
var val2=it2.next().value;//2 yield 2
val1=it1.next(val2*10).value;//x:20,z:2, val1:40
varl2=it2.next(val1*5).value;//x:200,z:3 val2:600
it1.next(val2/2);//y:300, x:20,z:3
it2.next(val1/4);//y:10,x:100,z:3
我们简单梳理一下流程:
foo()的两个实例同时启动,两个next()分别从yield 2语句得到值2
val2*10也就是,发送到第一个生成器实例it1,因此x得到值20。z从1增加到2,然后20和z相乘通过yield发出,将val1设置为40
val1*5,发送到第二个生成器实例it2,因此x得到值200。z再次从2递增到3,然后200和3相乘通过yield发出,将val2设置为600。
val2/2也就是600除以2,发送到第一个生成器实例it1,因此y得到值300,然后打印出x,y,z的值分别是20 300 3
val1/4也就是40除以4,发送到第二个生成器实例it2,因此y得到值10,然后打印出x y z的值分别是200 10 3
二、生成器生产值
1.生产者与迭代器
假定你要生产一系列的值,其中每个值都与前面一个有特定的关系。要实现这一点,需要一个有状态的生产者能够记住其生成的最后一个值。
我们可以实现一个直接使用函数闭包的版本:
var gimmeSomething=(function(){
var nextVal;
return function(){
if(nextVal===undefined){
nextVal=1;
}else{
nextVal=(3*nextVal)+6;
}
return nextVal;
}
})();
gimmeSomething();//1
gimmeSomething();//9
gimmeSomething();//33
gimmeSomething();//105
闭包函数会保留变量不被垃圾回收机制回收。
实际上,这个任务是非常通用的设计模式,通常通过迭代器来完成。迭代器是一个定义良好的接口,用于从一个生产者一步步得到一系列值。javascript迭代器的接口,与多数语言类似,就是每次想从生产者得到一个值的时候调用next()。
如下示例代码:
var something=(function(){
var nextVal;
return{
//for..of循环需要
[Symbol.iterator]:function(){return this;},
//标准迭代器节后方法
next:function(){
if(nextVal===undefined){
nextVal=1;
}else(){
nextVal=(3*nextVal)+6;
}
return {done:false,value:nextVal};
}
}
})();
something.next().value;//1
something.next().value;//9
something.next().value;//33
something.next().value;//105
next()调用返回一个对象。这个对象有两个属性。done是一个boolean值,标识迭代器的完成状态;value中放置迭代值。
ES6还新增了一个for...of循环,这意味着可以通过原生循环的语法自动遍历迭代器:
for(var v of something){
console.log(v);
if(v>500){
break;
}
}
三、异步迭代生成器
生成器与异步编码模式及解决回调问题等,有什么关系呢?我们还是重新讨论《javascript异步编程之回调函数》中的一个例子:
function foo(x,y,cb){
ajax('http://some.urlx='+x+"y="+y,
cb
);
}
foo(1,31,function(err,text){
if(err){
console.log(err)
}else{
console.log(text);
}
});
如果想通过生成器来表达同样的任务流程控制,可以这样实现:
function foo(x,y){
ajax(
'http://some.urlx='+x+"y="+y,
function(err,data){
if(err){
//向*main()抛出一个错误
it.throw(err);
}else{
//用收到的data恢复*main()
it.next(data);
}
}
);
}
function *main(){
try{
var text=yield foo(11,31);
console.log(text);
}catch(err){
console.log(err);
}
}
var it=main();
//这里启动
it.next();
这段代码与会调函数代码的区别在于生成器中使用yield。正是这一点使得我们看似阻塞同步的代码,实际上并不会阻塞,整个程序中,它只是暂停或阻塞了生成器本身的代码,在yield foo(11,31)中首先使用foo(11,31),它没有返回值(即返回undefined),所以我们发出了一个调用来请求数据,但实际上之后调用的是yield undefined。
这没有问题,因为这段代码当前并不依赖yield出来的值来做任何事情。
所以生成器在yield处暂停,本质上是在提出一个问题:我们该返回什么值赋给变量text?谁来回答这个问题呢。
看一下foo(...),如果这个Ajax请求成功,我们调用:
it.next(data);
这会用响应数据恢复生成器,意味着我们暂停的yield表达式直接收到了这个值。然后随着生成器代码的继续运行,这个值被赋值给局部变量text。
本质上而言,我们把异步作为实现细节抽象了出去,使得我们可以以同步顺序的形式追踪流程控制:"发出一个Ajax请求,等它完成后打印出响应的结果"。
同步错误处理
try...catch可以捕获到异步错误吗?
前面我们已经看到yield是如何让赋值语句暂停下来等待foo(...),使得响应完成后可以赋给text。精彩的是yield暂停也使得生成器能够捕获错误。通过下面的这段代码,把错误抛出到生成器中:
if(err){
//向*main()抛出一个错误
it.throw(err);
}
四、生成器+Promise
我们现在要将生成器的优点(看似同步的异步代码)和Promise的优点(可信任,可组合)结合在一起。
之前Ajax请求的例子中,基于Promise的实现方法:
function foo(x,y){
return request('http://...x='+x+'y='+y);
}
foo(11,31)
.then(function(text){
console.log(text);
},
function (err){
console.log(err);
}
)
这里foo(...)在发出Ajax调用之后返回一个Promise。这暗示我们可以通过foo(...)构造一个promise,然后通过生成器把它yield出来,然后迭代器代码就可以接收到这个promise了。并且监听这个promise的决议(完成或拒绝),然后要么自己完成消息恢复生成器,要么向生成器抛出一个带有拒绝原因的错误。
示例:
function foo(x,y){
return request('http://...x='+x+'y='+y);
}
function *main(){
try{
var text=yield foo(11,31);
console.log(text);
}catch(err){
console.log(err)
}
}
//运行*main(),手工实现:
var it=main();
var p=it.next().value;
//等待paromise p决议
p.then(function(text){
it.next(text);
},function(err){
it.throw(err)
})
扩展asyn/await
async/await是generator和promise结合的语法糖。 前已经讲到了,这里不再多说。
Javascript异步编程——“回调地狱”的一些解决方案
异步编程在javascript中非常重要。过多的异步编程也带了回调嵌套的问题,本文会提供一些解决“回调地狱”的方法
setTimeout(function(){
console.log('延时触发');
},2000);
fs.readFile('./sample.txt','utf-8',function(err,res){
console.log(res);
})
上面就是典型的回调函数,不论是在浏览器中,还是在node中,javascript本身是单线程,因此,为了对应一些单线程带来的问题,异步编程成为了javascript中非常重要的一部分。
不论是浏览器中最为常见的ajax、事件监听、还是node中文件读取、网络编程、数据库操作,都离不开异步编程。在异步编程中,许多操作都会放在回调函数(callback)中。同步与异步的混杂、过多的回调嵌套都会使得代码变得难以理解与维护,这也是常受人诟病的地方。
先看下面这段代码:
fs.readFile('/somple.txt','utf-8',(err,content)=>{
let keyword=content.substring(0,5);
db.find(`select * from sample where kw=${keyword}`,(err,res)=>{
get(`/sampleget?count=${res.length}`,data=>{
console.log(data);
});
});
});
首先我们读取的一个文件中的关键字keyword,然后根据该keyword进行数据库查询,最后依据查询结构请求数据。
其中包含了三个异步操作:
文件读取:fs.readFile
数据库查询:db.find
http请求:get
可以看到,我们没有增加一个异步请求,就会多添加一层回调函数的嵌套,这段代码中三个异步函数的嵌套已经开始使一段本可以语言明确的代码编程不易阅读与维护了。
抽象出来这种代码会变成下面这样:
asyncFunc1(opt,(...args1)=>{
asyncFunc2(opt,(...args2)=>{
asyncFunc3(opt,(...args3)=>{
asyncFunc4(opt,(...args4)=>{
//some operation
});
});
});
});
左侧明显出现了一个三角形的缩进区域,过多的回调就让我们陷入“回调地狱”。接下来会介绍一些方法来规避回调地狱。
一、拆解function
回调嵌套所带来的一个重要问题就是代码不易阅读与维护。因为普遍来说,过多的缩进(嵌套)会极大的影响代码的可读性。基于这一点,可以进行一个简单的优化——将各步拆解为单个的function。
function getData(count){
get(`/sampleget?count=${count}`,data=>{
console.log(data);
});
}
function queryDB(kw){
db.find(`select * from sample where kw=${kw}`,(err,res){
getData(res.length);
});
}
function readFile(filepath){
fs.readFile(filepath,'utf-8',(err,content)=>{
let keyword=count.substring(0,5);
queryDB(keyword);
});
}
readFile('./sample.txt');
可以看到,通过上面的改写方式,代码清晰了许多。该方法非常简单,具有一定的效果,但是缺少通用性。
二、事件发布/监听模式
如果在浏览器中写过 事件监听addEventListener,那么你对这种事件发布/监听的模式一定不陌生。
借鉴这种思想,一方面,我们可以监听某一事件,当事件发生时,进行相应回调操作;另一方面,当某些操作完成后,通过发布事件触发回调。这样就可以将原本捆绑在一起的代码解耦。
const events=require('events');
const eventEmitter=new events.EventEmitter();
eventEmitter.on('db',(err,kw)=>{
db.find(`select * from sample where kw=${kw}`,(err,res)=>{
eventEmitter.emit('get',res.length);
});
});
eventEmitter.on('get',(err,count)=>{
get(`/sampleget?count=${count}`,data=>{
console.log(data);
});
});
fs.readFile(`./sample.txt`,'utf-8',(err,content)=>{
let keyword=content.substring(0,5);
eventEmitter.emit('db',keyword);
});
使用这种模式的实现需要一个事件发布/监听的库。上面代码中使用node原生events模块,当然你可以使用任何你喜欢的库。
三、Promise
Promise是一种异步解决方案,最早由社区提出并实现,后来写进了es6规范。
目前一些主流浏览器已经原生实现了Promise的API,可以在Can I use里查看浏览器的支持情况。当然,如果想要做浏览器的兼容,可以考虑使用一些Promise的实现库,例如bluebird、Q等。下面以bluebird为例:
首先,我们需要将异步方法改写为Promise,对于符合node规范的回调函数(第一个参数必须是Error),可以使用bluebird的promisify方法。该方法接收一个标准的异步方法并返回一个Promise对象。
const bluebird=require('bluebird');
const fs=require('fs');
const readFile=bluebird.promisify(fs.readFile);
这样,readFile就变成一个Promise对象。
但是,有的异步方法无法进行转换,或者我们需要使用原生Promise,这就需要我们手动进行一些改造。下面提供一种改造的方法。
以fs.readFile为例,借助原生Promise来改造该方法:
const readFile=function(filepath){
let resolve,
reject;
let promise=new Promise((_resolve,_reject)=>{
resolve=_resolve;
reject=_reject;
});
let deferred={
resolve,
reject,
promise
};
fs.readFile(filepath,'utf-8',function(err,...args){
if(err){
deferred.reject(err);
}else{
deferred.resolve(...args);
}
});
return deferred.promise;
}
我们在方法中创建一个Promise对象,并在异步回调中根本不同的情况使用reject与resolve来改变Promise对象的状态。该方法返回这个Promise对象。其他的一些异步方法也可以参照这种方式进行改造。
假设通过改造,readFile,queryDB与getData方法均会返回一个Promise对象。代码就变为了:
readFile('./sample.txt').then(content=>{
let keyword=content.substring(0,5);
return queryDB(keyword);
}).then(res=>{
return getData(res.length);
}).then(data=>{
console.log(data);
}).catch(err=>{
console.log(err);
});
可以看到,之前的嵌套操作编程了通过then连接的链式操作。代码的整洁度上有了一个较大的提高。
四、generator
generator是ES6中的一个新的语法。
在function关键字后添加*即可将函数变为generator。
const gen=function* (){
yield 1;
yield 2;
return 3;
}
执行generator将会返回一个遍历器对象,用于遍历generator内部状态。
let g=gen();
g.next();//{value:1,done:false}
g.next();//{value:2,done:false}
g.next();//{value:3,done:true}
g.next();//{value:undefined,done:true}
可以看到,generator函数有一个最大的特点,可以在内部执行的过程中交出程序的控制权,yield相当于其到了一个暂停的作用;而当一定情况下,外部又将控制权移交回来。
想象一下,我们用generator来封装代码,在异步任务处使用yield关键词,此时generator会将程序执行权交给其他代码,而在异步任务完成后,调用next方法来恢复yield下方代码的执行。以readFile为例,大致流程如下:
//我们的主任务显示关键字
//使用yield暂时中断下方代码执行
//yield以后为Promise对象
const showKeyword=function* (filepath){
console.log('开始读取');
let keyword=yield readFile(filepath);
console.log(`关键字为${filepath}`);
}
//generator的流程控制
let gen=showKeyword();
let res=gen.next();
res.value.then(res=>gen.next(res));
在任务部分,原本readFile异步的部分变成类似同步的写法,代码变得非常清晰。而在下部分,则是对于什么时候需要移交会控制权给generator的流程控制。
然而,我们需要手动控制generator的流程,如果能够自动执行generator(在需要的时候自动移交控制权)那么会更加具有使用性。
为此,我们可以使用co这个库。它可以省去我们对于generator流程控制的代码。
const co=require('co');
//我们主任务——显示关键字
//使用yield暂时中断下方代码执行
//yield后面为promise对象
const showKeyword=function* (filepath){
console.log('开始读取');
let keyword=yield readFile(filepath);
console.log(`关键字为${filepath}`);
}
//使用co
co(showKeyword);
其中,yield关键字后面需要时function, promise,generator,array或者object。可以改写文章一开始的例子:
const co=require('co');
const task=function* (filepath){
let keyWord=yield readFile(filepath);
let count=yield queryDB(keyword);
let data=yield getData(res.length);
console.log(data);
}
co(task,'./somple.txt');
五、async/await
可以看到,上面的方法虽然都在一定程度上解决了异步编程中回调带来的问题。然而
function拆分的方式其实仅仅只是查分代码块,会不利于后期维护;
事件发布/监听方式模糊了异步方法之间的流程关系;
Promise虽然使得多个嵌套的异步调用能够通过链式的API进行操作,但是过多的then也增加了代码的冗余,也对阅读代码中各阶段的异步任务产生了一定的干扰;
通过generator虽然能提供较好的语法结构,但是毕竟generator与yield的语境用在这里多少还有一些不太贴切。
因此,这里再介绍一个方法,它就是es7中的async/await。
简单介绍一下async/await。基本上,任何一个函数都可以成为async函数,以下都是合法的书写形式:
async function foo(){};
const foo=async function(){};
const foo=async ()=>{};
在async函数中可以使用await语句。await后一般是一个Promise对象。
async function foo(){
console.log('开始');
let res=await post(data);
console.log(`post已完成,结果为:${res}`);
};
当上面的函数执行到await时,可以简单的理解为,函数挂起,等待await后的Promise返回,再执行下面的语句。
值得注意的是,这段异步操作代码,看起来就像是“同步操作”。这就大大方便了异步代码的编写与阅读。下面改写我们的例子。
const pintData=async function(filepath){
let keyword=await readFile(filepath);
let count=await queryDB(keyword);
let data=await getData(res.length);
console.log(data);
}
printData('./sample.txt');
可以看到,代码简洁清晰,异步代码也具有有了“同步”代码的结构。
注意,其中readFile,queryDB与getData方法都需要返回一个Promise对象。这可以通过在第三部分Promise里提供的方式进行改写。
以上是关于JS高阶五(JS中的异步编程下)的主要内容,如果未能解决你的问题,请参考以下文章