参考资料:
异步
定义
- 什么是异步?
- 程序中某一操作A,现在执行,而结果需要等一段时间之后才能获取到,而在此段时间内我可以继续做其他的操作B、C、D……这种情况我们称A与其他操作之间是异步的。我们称这种某一操作(A)还没执行完就可以接着执行其他操作(BCD)的现象为“非阻塞”的。
- 同步:与此相对,同步的概念则是我必须完全处理完一件事A才能继续下一件事B……这种串行执行我们称之为是同步的。跟此类现象相对应的一个术语叫“阻塞”,即一件事情执行不完就不可以继续执行接下来的事情。
异步现象
-
前端常见的异步事件就是请求——向后端请求数据并接收数据。
- 我们理想的程序是这样执行的:
- 发出请求—>返回结果—>拿到数据—>利用数据做一些事情
- 理想的代码表示就是这样子的:
var data = get('/api');
alert(data); // undefined!
- 事与愿违,这里的alert(data)并不能够alert出请求返回的结果,事实上在var data = get(‘/api’)这一句就已经没有获取到了。原因是请求一个异步事件而获取数据和利用数据是同步的。也就是说请求的操作get(‘/api’)会执行一段时间,结果是在将来某一时间才会得到(我们并不能明确多久之后会得到结果),获取数据和利用数据在它执行的这段时间里就会执行。于是程序真实的执行流程是这样的:
-
发出请求—>拿数据(肯定拿不到啊)—>利用数据做一些事情—>返回结果
- 我们的大脑思维逻辑总是一件事情完了之后再去做另一件事情,尤其是两件事情相互影响关联的情况下,也可以认为这种思维是同步的。于是异步事件与我们常规的处理事件思维不同,它打乱了我们事件执行的顺序。因为我们需要拿到异步事件的结果去做一些别的事,所以会期望将非阻塞的异步转化为同步的样子来处理,也就是说我一定要等到异步事件有了结果再执行别的事情,不要非阻塞,就要阻塞——于是我们需要在结果返回的时刻运行我们的处理结果函数,这一运行时机的把控交给了执行环境,而我们传达这种想法给环境的方式有多种,回调函数就是其中之一。
- 比如说让上面的代码按正确的同步流程走就可以改成:
// 传入一个函数,它会在异步事件结束完成后执行(注意这里假设get方法是个第三方组装的请求方法)
get('/api', function (res) {
var data = res;
alert(data);
})
- 重申一下异步事件:现在执行,而结果需要等一段时间之后才能获取到。换一种表达方式就是异步操作分为两步:第一发起异步任务,第二处理任务完成后得到的结果,于是这里的回调函数就充当了第二步的角色,我们在异步执行完后执行这个回调函数,那此时就肯定是能取到结果的。
- 你可能会问如何把握结果返回的时机呢?js运行环境帮助我们监听了结果返回的时机。
异步原理
- js代码片段会放入js引擎的执行栈执行(主线程上),而何时执行哪一段代码则由js的宿主环境决定或者说调度。比如js在浏览器执行就是浏览器环境,在服务端执行就是node环境。同步代码会直接放到执行栈中执行,而异步代码则会先按上面提到的第一步由主线程发起异步任务,然后交给环境里其他工作线程完成,最后取得结果时将处理结果的程序(比如回调函数)放到“任务队列”里,按某种调度机制再挪到执行栈上继续执行。这些环境都提供了此调度机制,叫eventLoop(事件循环)。
- 下面从提到的各概念入手,进一步说明。
执行栈
- 执行栈并非说是js代码执行的顺序是先进后出,这里栈中的元素指的是执行上下文,或者说被调用时候的某作用域,执行环境。
- 看下面代码
console.log(1);
function foo() {
console.log(2);
bar();
console.log(4);
}
function bar() {
console.log(3);
}
foo();
console.log(5);
- 执行结果:1 2 3 4 5
- 执行栈每遇到一个执行上下文,就会将该上下文压入栈并执行该上下文里的内容。上面的代码执行流程如下图:
- 从其表现来看简单说就是,主线程上执行程序会按照调用顺序依次执行——这符合我们的预期。
- 同步的任务都会在这个直接在此执行栈中得到执行。
任务队列(task queue)
- 队列特点需要知道:先进先出。
- 任务队列里存放了处理异步事件结果的程序,并且是当异步事件完成后才会将对应的处理程序入队。
- 详细说就是主线程发起异步任务,工作线程完成这个异步任务得到结果,此时处理该结果的程序就会被推入任务队列等待被调用。
- 任务队列可以有一个或多个。我们常见的异步任务细究起来属于两类:
- 宏任务(优先级由高到低):(主线程同步代码), setTimeout, setInterval, setImmediate, I/O操作, UI渲染
- 微任务(优先级由高到低):process.nextTick(Nodejs), Promises, Object.observe, MutationObserver
- 这两类任务的区别(之一)是:执行的优先级是微任务大于宏任务——具体在下面解释。
事件循环
- 前面提到环境里负责调度异步任务处理程序的执行顺序的机制就是事件循环。
- 其实说起来比较简单,就是当主线程上执行栈里的任务执行完,判断为空时,主线程就会查看任务队列,然后按先进先出原则(队首)取一个任务,放到执行栈里执行,判断,再取,再执行……这个循环往复的过程就是事件循环。取一次我们称为一次tick。
- 这个循环里还有些细节需要补充,先再重申一下相关的两点:
- 微任务优先级大于宏任务
- 任务队列不止一个
- 所以这里要补充一下tick的细节:主线程执行栈空后会先检查微任务排成的任务队列,tick掉所有微任务后,在宏任务排起的队列上执行一次tick,紧接着再看向微任务队列,不断tick直至此微任务队列为空,然后再tick一个宏任务,然后再检查微任务队列……
- 合并起来微宏任务的优先级由高到低:
- 也就是说:
- 先执行主线程同步代码
- 每次tick都会先检查微任务队列
- tick一次宏任务后就会转到微任务队列上一直在微任务队列上执行tick直至清空
- 规范里把主程序代码放到了宏任务优先级最高处,所以可以看到这样的执行顺序解释:先执行一个宏任务,然后转向并直到清空微任务队列,然后再执行一个宏任务,再清空微任务……
-
注意此处探讨的事件循环是浏览器环境里的,node环境里不尽相同,之后记得的话可能会更(有需要提醒我)。
-
关于此知识点面试时的会有类似下面的考察:
console.log(1);
setTimeout(()=>{
console.log(2);
Promise.resolve().then(ret=>{
console.log(3);
})
Promise.resolve().then(ret=>{
console.log(4);
})
});
setTimeout(()=>{
console.log(5);
});
Promise.resolve().then(ret=>{
console.log(6);
})
- 问:上面输出的结果顺序是什么?
-
答案:1 6 2 3 4 5
- 分析如下图:
- 以上就是关于“异步”的介绍。
- 我们提到,异步事件和我们同步的大脑思维是不符合的,我们总是想以一种更优雅、清晰的编程方式去表达、管理异步任务,使之动向完全处于我们的掌控之内。于是js发展史中出现了几种不同的针对异步事件的编程方式。我们需要一直明确的是,他们都一样,只是对于异步事件的不同表达,而不是什么新事物。
异步编程方案
回调
- 回调函数意指异步事件完成后再回过头来调用的函数,在前面的例子中我们已经见识到了使用回调函数管理异步事件结果的例子。
- 用一个例子比喻回调:我在写代码,然后告诉小明帮我拿一本书过来我要查一个问题,然后小明去拿书了,而我在他拿回来之前继续写代码,等他帮我把书拿回来之后我就开始查这本书。此场景中我写代码就是主线程中正在执行的同步代码,告诉小明帮我拿书则是发出一个异步任务,小明去做了,然后我拿到这本书查的事件就是处理结果的回调函数了。类代码如下:
写代码1;
小明拿书((书)=>{
查书;
});
写代码2;
- 此场景中写代码1与写代码2不会被小明拿书阻塞,而且小明拿过书来我就会执行查书的回调函数。
- 写回调函数是最基础的异步编程方案。但是它有如下几个缺点一直让程序员诟病:
1. 令人费解
- 从代码字面上来看的执行顺序和实际的执行顺序不同。
- 比如上面的,虽然我们从上到下写的顺序是:写代码1->小明拿书查书->写代码2,但实际的执行顺序是:写代码1->写代码2->小明拿书查书。
- 所以说掺杂了很多回调的代码其可读性是较差的,我们并不能直观地看出该程序的运行顺序是怎样的。尤其是出现了经典的回调地狱(回调函数嵌套的情况)时:
fn1(function callbackA(){
fn2(function callbackB(){
fn3(function callbackC(){
......
});
});
});
2. 信任问题
- 再翻上去看一看让小明拿书的例子。在该例子中我一直干的事情是写代码,然后交代小明拿书这里请注意,执行任务的主人公已经发生变化——是小明去拿书了,然后我以一个参数的形式丢给异步方法一个处理结果的回调,小明把书给我时执行这个查书回调。
- 在这个场景中的异步事件上我已经把控制权转移给了的第三方(小明),这种情况叫控制反转,这回导致一系列的不信任问题:(书)=>{查书;}此回调函数会在结果返回时被调用几次?我能相信他会及时调用吗?他是否会早调用?,也就是说,结果返回的时机由环境监控,但是如何利用此时机则是由第三方决定的。
- 我们经常会调用第三方的异步方法,而对于此第三方我们可以完全信任他会正确触发我们的回调吗?——这就是回调的信任问题。而我们要解决这个信任问题就还需要插入很多更难维护的代码。比如说上面我要防止多次调用查书的回调就会加一个锁:
var flag = false;
小明拿书((书)=>{
if(!flag) {
查书;
flag = true;
} else return;
});
Promise
- 上面提到回调函数方式的缺点是缺乏顺序性和可控性,于是我们又推出了Promise来进行异步编程,以期弥补回调的缺点。
- 这里只简单介绍一下promise基础(需要深入推荐阮一峰的ES6入门),重点在于说明它作为异步解决方案上的特点。
- 我们将异步事件包装为promise,执行完后仍返回的是一个promise对象。使用promise我们可以监控到异步事件的执行状态:发起一个异步事件,没有结束时为pending状态,任务失败后为rejected状态,成功返回结果后为resolved,并且一旦这个状态由pending转换为成功或者失败状态后就是不可再发生变化的。
- 我们使用.then的方式获取到它的执行结果(是个promise),并用两个函数作为then方法里的参数表示成功状态的处理方法和失败状态的处理方法。
可信任
-
区别于用回调表示异步的方式,回调函数是从异步事件开始到处理结果全部交给第三方完成,而promise最终只会返回异步事件的最终执行的状态而不自行处理结果,于是对于处理结果的方法的控制权仍在我们手里,我们想如何调用,何时调用就怎么调用,并且promise状态一旦发生改变就不会再发生变化,意味着某状态的回调只会执行一次——这便是可信任得了。
-
比如说请求数据:
// 回调的写法(假设get函数接收三个参数,后两个是成功和失败分别调用的回调)
// 我们并不知道get这个任务会什么时候调用、调用几次我们的回调(是由get函数内部实现控制的)。
get('/api', function (res) {
alert(res);
}, function (err)=>{
alert(err);
});
// -----------------------------------------------------------
// promise写法(get方法返回一个只变化一次状态的promise)
function promise(url) {
return new Promise((resolve, reject)=>{
// new Promise里的函数会立即执行,结果在then函数里取
get(url, (res,err)=>{
// 状态只会变化一次,很安全
if(err) reject(err);
else resolve(res);
})
});
}
// promise('/api')得到一个promise,执行then之后又返回一个promise
promise('/api').then(
// 如果该任务执行成功我们规定调用这个resolved函数,否则执行rejected函数
function resolved(res)=>{
alert(res);
},
function rejected(err)=>{
alert(err);
}
)
串行的链式调用
-
我们之前提到promise执行一次后返回的仍是promise,并且我们使用then方法接收处理promise。也就是意味着如果每个得到的promise中都有下一步操作的promise的话我们可以一直then下去形成一条链,并且他们的执行顺序是肯定和我们写出来的then链保持一致。
-
对比之前回调函数的“回调地狱”:
// 回调嵌套的情况用回调函数写出来既不美观可读性也不高
fn1(function callbackA(data){
fn2(function callbackB(data){
fn3(function callbackC(data){
......
});
});
});
// 封装一个异步操作为promise
var fn1 = new Promise((resolve, reject)=>{
var data = ajax('./api', (res,err)=>{
if(err) reject(err);
else resolve(res);
})
})
// then的链式调用写出来就很符合我们的思维,宛如同步代码一般,令猿舒适,而实际含义和上面的嵌套代码相同。
fn1.then(function callbackA(res){
resolve(res);
}).then(res=>{
fn2(function callbackB(res){
resolve(res);
});
}).then(res=>{
fn3(function callbackC(res){
......
});
})
Generator
- 生成器的意思。你可能会问这个生成器能生成什么?答案是能生成迭代器(Iterator)。
- Generator又是一种异步编程方案,他可以以更“同步式”的方法表达异步。下面从概念入手,看看生成器是如何组织异步的。
Iterator
- 迭代器,也叫遍历器,是为了以统一方式遍历某类型的某组数据时需要的访问接口,说白了就是,只要一组数据拥有迭代器,就能够以for…of…的形式遍历里面的元素。
- 比如Array类型,Map类型,Set类型能以for…of…的形式遍历里面的元素,因为他们都有默认的迭代器接口,即他们身上有一个属性叫Symbol.iterator,是构造器的构造函数;而普通Object对象则不能,因为它没有迭代器接口。
- 某一组数据的迭代器上有next的方法,可以手动挨个获取元素:
// 一个可以迭代的数组
var arr = [5,4,3,2,1];
// 实例化它的一个迭代器
var iter = arr[Symbol.iterator]();
iter.next(); // {value: 5, done: false}
iter.next(); // {value: 4, done: false}
iter.next(); // {value: 3, done: false}
简单认识Generator
- Generator最大的特点就是能控制程序的运行与暂停,就像debug一样:打一个断点,然后可以控制程序按我们的意愿走走停停。
- 详细分析Generator的运行流程可以参考我之前写的一篇分析:Generator执行流程,更深入建议参考阮一峰的ES6入门,这里只做简单介绍:
// 加了*号表示这是一个生成器函数
function *foo() {
var val = 1;
// 进入此循环val就会不断加1
while(true) {
val ++;
// 碰到yield的地方会暂停
var temp = yield val;
console.log(temp);
}
}
// 调用生成器函数能生成一个迭代器,而不执行生成器函数里的逻辑
var iter = foo();
// 每次next就会执行到下一个yield停止,并返回一个对象表示迭代器里的值(value,表示yield后面式子的值,这里就是val的值)与是否迭代完成(done)
iter.next(); //{value: 2, done: false}
// yield没有返回值,temp赋为undefined
iter.next(); //{value: 3, done: false} undefined
// 传递给next方法的参数会被当做yield的返回值,付给temp
iter.next(55); //{value: 4, done: false} 55
- 介绍完了。
Generator控制异步
- 我们说Generator又是一种异步编程方案,看了上面的你可能还不能联想到这跟异步有什么关系,继续拿回调函数方案做对比:
// 我们要实现的流程是:请求数据->成功:alert / 失败:throw抛错
// 一个看起来比较优雅的回调实现
function get(url, cb) {
ajax(url, cb);
};
// 调用
get('/api', (res) => {
if(res.code === 200) alert(res.data);
else throw res;
});
// ---------------------------------------------
// 用生成器实现
function get(url) {
ajax(url, (res) => {
if(res.code === 200) iter.next(res.data);
else it.throw(res);
});
}
function *main() {
// 请注意这两句熟悉的代码
var data = yield get('/api'); // 这里是个暂停点
alert(data);
}
// 得到迭代器
var iter = main();
// 请求,启动!跳到get函数里,如果200就会继续iter.next(res.data),然后执行yield后面的alert(data);
it.next();
- 一开始我们讨论异步时提到过下面错误的代码:
var data = get('/api');
alert(data); // undefined!
- 然后我们这里利用生成器让这样的同步代码正确执行了异步:
function *main() {
var data = yield get('/api');
alert(data); // data取到了值
}
- 用生成器写异步最大的好处就在于此,把异步事件按同步思维表达出来,代码读起来非常舒适。
-
这种方案可以理解为在回调方案里加入了生成器(callback+Generator的编程方案)。
- 这里可能懵逼了,我用图来描述一下程序执行流程:
- 这里能正确执行的秘诀就在于我们的yield“断点”阻止了异步事件完成之前给data赋值以及alert,而是生成器之外的get方法不会被暂停(暂停的只是生成器体内的语句执行),在异步函数的结果处理函数里解除暂停,而此结果处理函数是在请求结束返回结果时才会执行,进而保证了给data赋值以及alert的操作会在得到结果时执行。概而言之,就是把非阻塞的异步转化为阻塞的同步了
-
于是程序执行的顺序是:var data -> ajax(‘/api’ -> 调用回调得到值) -> 给data赋值 -> alert(data)
- 但是,生成器的使用虽然完美地解决了回调编程里代码执行顺序混乱难读的问题,我们发现它并没有解决信任问题:这里依旧出现了控制反转,把对结果的处理权交给了get方法。而使用promise虽然解决了信任问题但代码又不如Generator来的好看。
- 怎么办呢?既然Generator和promise都有各自的优点,那我们就各取所长,将二者结合使用不就好了~
Generator + Promise组合控制
- 先再用promise方式写一下上面的请求例子:
function get(url) {
return new Promise((resolve, reject)=>{
ajax(url, (res)=>{
if(res.code === 200) resolve(res);
else reject(res);
})
});
}
get('/api').then(
function resolved(res)=>{
alert(res);
},
function rejected(err)=>{
throw err;
}
)
- 我们非常中意它的状态转换机制,因为这解决了信任问题,但我们还想要Generator优雅的同步表达。于是我们在promise中加入了Generator:
function get(url) {
return new Promise((resolve, reject)=>{
ajax(url, (res)=>{
if(res.code === 200) resolve(res);
else reject(res);
})
});
}
// 生成器函数
function *main() {
var data = yield get('/api'); // 暂停点
alert(data);
}
// 得到迭代器
var iter = main();
var promise = iter.next().value; // 取得yield后面表达式的值,这里就是get()返回的promise
// 根据promise的执行结果状态决定生成器函数里的下一步行动,推动promise和生成器的执行
promise.then(
// 成功返回值那就继续生成器函数(为data赋值和alert)
function resolved(res)=>{
iter.next(res.data);
},
// 失败就抛错
function rejected(err)=>{
iter.throw(err);
}
)
- 可以看到我们的生成器函数依旧是优雅的同步代码,而异步代码我们已经用可信任的promise替换掉了。
- 整体的执行流程和回调+Generator类似:
-
var data -> ajax(‘/api’ -> 得到不可再变的成功状态) -> 给data赋值 -> alert(data)
- 上面例子有点恶心的地方在于,我们需要在优雅的生成器函数的背后,手动推动迭代器next下去,编写并调用适用于promise的then(链)方法以期为生成器函数提供需要的promise(iter.next().value)。
- 信任问题交给promise解决了,我们只想舒畅地使用优雅的生成器函数,不想写推动promise一直then下去的的那一坨代码——如果能让他自己跑完promise链把最后的结果通过next抛给main函数就好了!
- 很多人都想到了这个问题,于是有很多相关库实现了这种自动化工具,比如下面这段代码就是其中之一:
// 摘自《你不知道的JS》(中卷)
function run(main) {
// args取到了可能要传给生成器函数的参数
var args = [].slice.call(arguments, 1), iter;
// 调用生成器函数得到迭代器
iter = main.apply(this, args);
// 返回一个promise
return Promise.resolve().then(
// 推动main函数继续执行
function handleNext(data) {
var next = iter.next(data);
return (function handleResult(next) {
// 执行完了吗
if(next.done) {
return next.value;
} else {
// 没有执行完就继续
return Promise.resolve(next.value).then(
handleNext,
function handleErr(err) {
return Promise.resolve(
it.throw(err);
).then(handleResult);
}
)
}
})(next);
}
)
}
- 如果你不需要自定义化把控不同状态的promise的回调而只关注最终数据结果,那就尽情将过程交给此类工具(叫执行器),还有更为复杂精妙的工具(比如co库),我没用过,感兴趣可以去了解,你也可以自己封装。
- 利用这个工具我们的代码就可以更加优雅:
......
function *main(){
......
}
run(main);
async/await
- async和await是生成器函数使用的语法糖,即简写版生成器:
// 生成器函数写法
function *main() {
var data = yield get('/api'); // 暂停点
alert(data);
}
// 对应的语法糖写法
async function main() {
var data = await get('/api'); // 暂停点
alert(data);
}
- 除了将*号替换成async,yield替换为await之外(很明显单这两点看不出来这语法糖哪里甜),更出彩的是async/await是内置了执行器——每遇到一个await的表达式就会自动运行并等待返回结果,然后其之后的代码也能紧接着执行,不需要next:
function get(url) {
return new Promise((resolve, reject)=>{
ajax(url, (res)=>{
if(res.code === 200) resolve(res);
else reject(res);
})
});
}
async function main() {
var promise = await get('/api'); // 运行get('/api'),得到的是一个promise
return promise;
}
// 跑一下
main().then(res=>{
alert(res.data);
})
- 这样看起来怪怪的,你可能会说那我还不如直接跑get然后then——你说的很对。
实际应用里
- 于是在实际情况中我们更可能像下面这样用:
// api
function get(url) {
return new Promise((resolve, reject)=>{
ajax(url, (res)=>{
if(res.code === 200) resolve(res);
else reject(res);
})
}).then(res=>{
return res.data;
});
}
// async/await写法(常见)------------------------
async function main() {
var data = await get('/api'); // 运行get('/api'),会等他返回了data
alert(data);
}
// 跑一下就会直接按顺序执行,妈妈再也不用担心我异步的用同步代码取不到值或者没有拿next驱动啦
main();
// 生成器写法(既然有了语法糖我们就很少采用这种写法了)-----------------------------
function *main() {
var data = field get('/api');
alert(data);
}
var iter = main();
var value = iter.next().value; //推一下,得到了field后面表达式的值,是res.data
iter.next(value); //再推一下,赋值给data并且alert
// promise写法(常见)----------------------------
get('/api').then((data)=>{
alert(data);
});