JavaScript 异步编程
我们知道,无论是浏览器tab还是nodejs,默认情况下JavaScript通常都运行在一个进程中, 采用了 单线程+ 事件循环的机制来执行浏览器相关的处理任务(JavaScript Event Loop)。
程序执行过程中,所产生的处理任务(例如HTML解析, 交互响应等等),会被依次加入到任务队列中。 而Event Loop 则负责轮询并从队列中取出一个任务交给 JavaScript Engine执行。
JavaScript 的 Event Loop 机制保证了事件任务的按序执行, 同时,Engine 每次只能执行一个任务,并保证只有在当前任务执行完毕后才会开始执行下一个任务。 所以任务执行过程中对数据操作不会出现并发问题。
然而,采用串行的方式执行任务,会导致当遇到一个任务执行耗时特别长的时候, 出现用户UI交互相关的任务无法得到及时处理的问题。 举个例子:
<a id="block" href="">Block for 5 seconds</a>
<p>
<button>This is a button</button>
<div id="statusMessage"></div>
<script>
document.getElementById('block')
.addEventListener('click', onClick);
function onClick(event) {
event.preventDefault();
setStatusMessage('Blocking...');
// 模拟一个需要执行5s的耗时任务
setTimeout(function () {
sleep(5000);
setStatusMessage('Done');
}, 0);
}
function setStatusMessage(msg) {
document.getElementById('statusMessage').textContent = msg;
}
function sleep(milliseconds) {
var start = Date.now();
while ((Date.now() - start) < milliseconds);
}
</script>
上面例子中,setTimeout
会在任务队列中添加一个任务,sleep模拟了一个5s的耗时操作,
在这个耗时任务执行完毕之前,用户的交互操作是无法被响应的。
为了避免UI响应被阻塞,我们的程序尽量不要在主线程中同步执行耗时操作。 更好的做法是在主线程中将耗时操作任务交给子线程去异步执行,当任务执行完毕后再通知主线程获取任务结果。
Callback
在ES6之前,对于异步执行结果的处理方式基本上都是通过注册事件监听(event)或者回调函数来完成的。
基本实现思想就是,主线程中发起异步任务的同时,通过回调函数的方式,指定异步任务执行完毕后,需要执行的任务。 对于结果的处理通常会被封装在一个Callback函数中,当异步任务执行完毕,向事件队列中加入一个新的任务来执行Callback函数。
在大多数简单的业务场景下,使用这种方法非常方便,程序可读性也很好。
然而一旦业务逻辑复杂起来,就会陷入Callback Hell
: 程序中回调函数嵌套了回调函数,可维护性骤然下降。
另外,如果程序还需要考虑到异常和错误的处理问题,使用Callback回调也比较繁琐。
因为通常无法提供一个通用的异常处理回调,我们不得不去逐一判断异常类型,然后写很多错误处理函数。
将callback函数作为函数的入参,并在合适的时候回调的编程风格也被称为Continuation-passing style
。
Promise
在程序逻辑复杂的情况下,由于函数嵌套层级过深导致程序可读性和可维护性下降,Callback相关的异步结果处理方式不够优雅。
作为Callback的替代方案,ES6引入的Promise
为调用方提供了处理异步操作结果的新姿势。
(关于如何使用Promise的基础知识)
在程序控制流中,调用方通常并不关心异步任务的执行过程,而只负责发起任务和获取结果并处理; 而异步任务则负责执行异步操作,并根据最终不同的结果状态(成功或失败)通知给调用方。
Promise
的基本设计思路就是,使用Promise
对象将异步任务部分的处理逻辑和结果封装起来,
而Promise
对象本身作为结果返回给调用方,调用方只需指定不同结果状态(成功或失败)后的处理流程即可。
这是一个很常见的“生产者-消费者”场景:Promise
负责产出和分发结果(成功或失败),调用方负责处理不同的结果。
const promise = new Promise(function(resolve, reject) {
// 异步操作发起
// 异步操作回调
{
if (/* 异步操作成功 */){
resolve(value);// 分发成功结果
} else {
reject(error);// 分发失败结果
}
}
});
// 调用方
promise.then(value=>{
// 处理成功结果
}, error=>{
// 处理失败结果
});
当我们通过Promise
来封装一个异步结果,这意味着Promise
对象处于三种状态的一种:
- Pending: 异步任务还未执行完毕
- Fulfilled: 异步任务执行成功
- Rejected: 异步任务执行失败
从时序上来讲,当异步任务执行完毕,根据结果的不同,通过调用resolve
或reject
函数,
Promise
的状态从Pending切换到Fulfilled或Rejected,且这个状态切换是单向不可逆的。
一旦状态发生切换,就会通知给调用方(消费者)执行对应的后续操作,因此,这个状态时序的保证是Promise
能够处理异步流程的关键。
链式调用
then()
方法挂载在Promise
原型对象上,用来让调用方来指定对于不同状态的返回结果该如何处理:
onFulfilled
和onRejectd
函数分别处理了fulfilled
和rejected
两种状态的回调。
同时,then
方法也返回了一个新的promise对象,它可以让调用方使用链式调用的方式继续处理业务逻辑。
那这个新的promise对象的状态是什么样的呢?假定promise对象P,它的then方法新返回的promise对象是Q,
Q的状态由P的onFulfilled
和onRejectd
函数的执行决定:
p.then(value=>{
try{
...
return a
}catch(e){
throw c
}
}, error=>{
return b;
}).then(val=>{
// val is a or b
// 捕获onFulfilled和onRejectd的返回结果
},error2=>{
// error2 is c
// 捕获onFulfilled和onRejectd执行过程中的异常
})
可以看到,只要是onFulfilled
和onRejectd
函数正常返回的值(例如a,b)都会被Q的onFulfilled
捕获执行;
如果执行过程中发生了异常,则会被Q的onRejectd
捕获到。
还有一种情况需要单独拉出来说明一下,如果P的onFulfilled
方法中返回了一个promise对象,那么这个promise对象就会被作为then方法的返回对象Q。
这样的机制有助于把promise的嵌套调用扁平化,避免出现callback那种嵌套地狱。
虽然Promise
的出现改进了回调的嵌套问题,
尽管如此,一堆then方法的链式调用仍然显得代码冗余,可读性仍然有待提高。
使用ES6的Generator + Promise 的方式可以让我们写出更符合直觉的代码逻辑(线性的执行),配合流程管理模块,让代码能够自动地执行。
而ES7中更是将这一机制变成了async 函数来使用。
async 函数
ES7引入的async
关键字让我们在异步编程的过程中更加方便的使用Promise
,
它用于将一个函数声明为异步函数,表示当前函数中可能有耗时操作,需要异步执行。
async 函数的返回值是一个promise对象,这个对象P在异步函数执行之初就被创建了出来。
异步函数执行过程中,return
和throw
操作都会使函数停止执行立即返回:
- 异步函数 return 一个普通值x(非Promise对象),则异步函数的返回结果为
Promise<x>
,状态为fulfilled; - 异步函数 return 一个新的Promise Q, 则异步函数的返回结果为Q;
- 异步函数 throw 一个error 则返回一个状态为rejected的
Promise<err>
await 操作符
await 操作符通常会和async函数一起搭配使用,用来同步获取一个Promise对象的执行结果, 它比yeild
具有更好的语义。
由于await 操作会阻塞当前操作,因此必须放在async 函数中使用。
总结
我们可以看到,如何解决异步结果分发和处理是JavaScript的异步编程中的核心问题。 搭配 Promise 与 async 函数 我们不仅可以写出更加符合直觉的代码逻辑,同时还能更优雅地处理异常操作。