终于到了这一章节了, 今天我们来分享异步编程的终极解决方案 async /await 。 回顾下我们这个小章节异步编程的解决方案从最早的回调函数,到 Deferred 到 Promise 对象,再到 Generator 函数,每次都有所改进,但它们都有额外的复杂性,让人觉得不彻底。
async 函数就是隧道尽头的亮光,甚至可以说它是异步编程的终极解决方案。 这东西目前有多火呢?我给大家看个数据。async 函数是 ES7 (即2017 年) 正式成为了 ECMASCRIPT 标准 , 在 State Of JavaScript 2020 针对语言新特性上做过一个调查。
在过去的一年里对于JavaScript新增的特性Async/Await 听说人数、使用人数拉满。 使用率到达恐怖的95.7%。而另外一个大家耳熟能详的特性Proxy 使用率不到40% 才36.4%。 所以了解并且熟练掌握Async/Await 势在必行。
State Of JavaScript 是国外一团对针对于JavaScript做的问卷调查,以了解现在前端开发者对前端各种技术栈、框架的使用情况与看法。 此调查具有一定的权威性分析里面的数据可以一定程度上了解未来前端的趋势。
State Of JavaScript 2020 网址
async 函数是什么?
一句话,它就是 Generator 函数的语法糖。上节课有个 Generator 函数依次读取两个文件的案例,接下来我们会用async 进行改版。
var fs = require('fs');
var co = require("co");
var readFile = function (fileName){
return new Promise(function (resolve, reject){
fs.readFile(fileName, function(error, data){
if (error) return reject(error);
resolve(data);
});
});
};
var gen = function* () {
var f1 = yield readFile('./etc/fstab.txt');
var f2 = yield readFile('./etc/shells.txt');
console.log(f1.toString());
console.log(f2.toString());
};
co(gen);
接下来我们看下async 版本的。
const asyncReadFile = async function () {
const f1 = await readFile('/etc/fstab');
const f2 = await readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};
细心的同学一比较就会发现,async函数就是将 Generator 函数的星号(*)替换成async,将yield替换成await, 但仅仅是这样的?
接下来我从各个维度总结了async函数和 Generator 函数的不同之处。
- 更好的语义。
async和await,比起星号和yield,语义更清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。
- 内置执行器
Generator 函数的执行必须靠执行器,所以才有了co模块,而async函数自带执行器。也就是说,async函数的执行,与普通函数一模一样,只要一行。
asyncReadFile();
上面的代码调用了asyncReadFile函数,然后它就会自动执行,输出最后结果。这完全不像 Generator 函数,需要调用next方法,或者用co模块,才能真正执行,得到最后结果。
- 更广的适用性
co模块约定,yield命令后面只能是 Thunk 函数或 Promise 对象,而async函数的await命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。
例如:
async function foo() {
await 1
}
等价于
function foo() {
return Promise.resolve(1).then(() => undefined)
}
- 返回值是 Promise
async函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用then方法指定下一步的操作。
例如,如下代码:
async function foo() {
return 1
}
等价于:
function foo() {
return Promise.resolve(1)
}
进一步说,async函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而await命令就是内部then命令的语法糖。
基本用法
async函数可能包含0个或者多个await表达式。await表达式会暂停整个async函数的执行进程并出让其控制权,只有当其等待的基于promise的异步操作被兑现或被拒绝之后才会恢复进程。promise的解决值会被当作该await表达式的返回值。
function timeout(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function asyncPrint(value, ms) {
await timeout(ms);
console.log(value);
}
asyncPrint('hello world', 1000);
console.log('hello async');
上面代码先输出 ‘hello async’ 在输出 ‘hello world’ , 原因就是在执行async 函数中遇到 await 表达式主线程就判定,这里99.99% 大概有个异步操作。 所以就会让出执行控制权,继续执行主线程的同步代码。 而await 的表达式会交由给其他的异步线程去处理,等异步操作完成在恢复async 函数执行权 执行函数体内后面的代码。
这里发生了什么事情呢? 我觉得有必要更大家解释的更加详细一点。
之前我们知道在 await 关键字后面可以是 Promise 对象和原始类型的值,如果是一个原始类型的值也会被转化成状态为resolve 的promise 对象。
await 1;
所以引擎在去执行async 函数遇到了 await 关键字 await 它对应的表达式只有两种结果 “微任务”, “宏任务” 再讲事件循环的时候我们是不是讲过了Promise 就是一个微任务。 遇到微任务会怎么办,把微任务丢到微任务消息队列中去,主线程继续执行同步代码。 等同步代码执行完毕在去微任务消息队列中去取微任务来执行。
现在你就明白了吧! 为什么个async函数执行遇到await就会让其控制权, 没办法它后面是跟随的微任务。 这是JavaScript自古以来的事件循环处理机制, 不已人的意志为转移。
那可能有同学就会问了如果说await 后面是个宏任务呢?
async function asyncPrint(value) {
await setTimeout(function(){console.log(1122)},0);
console.log(value);
await 2;
console.log(3344);
}
asyncPrint('hello world');
console.log("hello async");
把代码改造下,这里的输出顺序是什么?
大家还记得我们之前讲过的事件循环吗? 当主线程同步任务执行完毕,会去全局的微任务消息队列中看有没有微任务。 全局的微任务消息队列执行完毕再去看全局的宏任务消息队列。
调用函数也是一样,在函数的执行栈中同样的分布 同步代码 微任务 宏任务, 他们的执行顺序 都是 同步先于>微任务>宏任务。
所以上面代码执行”hello async” ‘hello world’ 3344 1122。
最后我们来讲讲注意事项:
await命令后面的Promise对象,运行结果可能是rejected,所以最好把await命令放在try…catch代码块中。
async function myFunction() {
try {
await somethingThatReturnsAPromise();
} catch (err) {
console.log(err);
}
}
// 另一种写法
async function myFunction() {
await somethingThatReturnsAPromise()
.catch(function (err) {
console.log(err);
});
}
多个await命令后面的异步操作,如果不存在继发(依赖)关系,最好让它们同时触发。
let foo = await getFoo();
let bar = await getBar();
上面代码中,getFoo和getBar是两个独立的异步操作(即互不依赖),被写成继发关系。这样比较耗时,因为只有getFoo完成以后,才会执行getBar,完全可以让它们同时触发。
// 写法一
let [foo, bar] = await Promise.all([getFoo(), getBar()]);
// 写法二
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;
上面两种写法,getFoo和getBar都是同时触发,这样就会缩短程序的执行时间。
第三点,await命令只能用在async函数之中,如果用在普通函数,就会报错。
async function dbFuc(db) {
let docs = [{}, {}, {}];
// 报错
docs.forEach(function (doc) {
await db.post(doc);
});
}
第四点,async 函数可以保留运行堆栈。
const a = () => {
b().then(() => c());
};
上面代码中,函数a内部运行了一个异步任务b()。当b()运行的时候,函数a()不会中断,而是继续执行。等到b()运行结束,可能a()早就运行结束了,b()所在的上下文环境已经消失了。如果b()或c()报错,错误堆栈将不包括a()。
现在将这个例子改成async函数。
const a = async () => {
await b();
c();
};
上面代码中,b()运行的时候,a()是暂停执行,上下文环境都保存着。一旦b()或c()报错,错误堆栈将包括a()。
async/await提供了一种很好的,简化的方法来编写更易于阅读和维护的异步代码。但无论是现在还是将来,都值得学习和考虑使用。
参考链接:
《JavaScript存在并发执行吗?》
《async和await:让异步编程更简单》
《聊聊 JavaScript 的并发、异步和事件循环
》
《阮一峰:async》