ECMAScript 6 入门:async 函数完全指南(语法、错误处理、实现原理与顶层 await)
2026/9/20 6:35:01 网站建设 项目流程
  • 教程
  • 前端

【免费下载链接】es6tutorial

《ECMAScript 6入门》是一本开源的 JavaScript 语言教程,全面介绍 ECMAScript 6 新增的语法特性。

项目地址:https://gitcode.com/gh_mirrors/es/es6tutorial
点击查看免费下载

导读

async函数是 ES2017 标准引入的异步编程解决方案,也是 JavaScript 异步编程发展史上的一次重要简化。本文以《ECMAScript 6入门》项目(本仓库)中的 docs/async.md 为骨架,结合仓库中 Generator 函数的语法、Generator 函数的异步应用 与 Promise 对象 等章节的源码级讲解,系统梳理 async 函数的内置执行器机制、await命令的详细语义、错误处理的最佳实践、spawn 自动执行器的实现原理,以及 ES2022 顶层await的模块加载场景。读完本文,你将能够从零理解 async 函数"是什么、为什么、怎么用",并能在真实项目中正确写出高效且健壮的异步代码。


一、含义:Generator 函数的语法糖

ES2017 标准引入了 async 函数,使得异步操作变得更加方便。用一句话概括:async 函数就是 Generator 函数的语法糖

仓库 docs/generator.md 详细介绍了 Generator 函数:它通过function*声明,用yield表达式定义内部状态,调用后返回一个遍历器对象,必须靠next方法分段执行。下面的代码用 Generator 依次读取两个文件:

const fs = require('fs'); const readFile = function (fileName) { return new Promise(function (resolve, reject) { fs.readFile(fileName, function(error, data) { if (error) return reject(error); resolve(data); }); }); }; const gen = function* () { const f1 = yield readFile('/etc/fstab'); const f2 = yield readFile('/etc/shells'); console.log(f1.toString()); console.log(f2.toString()); };

同样的逻辑,用 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 函数的四点改进

(1)内置执行器。Generator 函数的执行必须靠执行器,所以才有了co模块;而 async 函数自带执行器。也就是说,async 函数的执行与普通函数一模一样,只要一行:

asyncReadFile();

这完全不像 Generator 函数,需要调用next方法,或用co模块才能真正执行。仓库 docs/generator-async.md 中详细解释了co模块:它将基于 Thunk 函数和基于 Promise 对象的两种自动执行器包装成一个模块,前提是yield命令后面只能是 Thunk 函数或 Promise 对象。而 async 函数把这个执行器内建在语言层面,用户无需任何额外依赖。

(2)更好的语义。asyncawait,比起星号和yield,语义更清楚:async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。

(3)更广的适用性。co模块约定yield命令后面只能是 Thunk 函数或 Promise 对象;而 async 函数的await命令后面可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,这时会自动转成立即 resolved 的 Promise 对象)。

(4)返回值是 Promise。async 函数的返回值是 Promise 对象,这比 Generator 函数返回 Iterator 对象方便多了——可以直接用then方法指定下一步的操作。进一步说,async 函数完全可以看作多个异步操作包装成的一个 Promise 对象,而await命令就是内部then命令的语法糖。


二、基本用法

async 函数返回一个 Promise 对象,可以使用then方法添加回调函数。当函数执行的时候,一旦遇到await就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。

async function getStockPriceByName(name) { const symbol = await getStockSymbol(name); const stockPrice = await getStockPrice(symbol); return stockPrice; } getStockPriceByName('goog').then(function (result) { console.log(result); });

上面是一个获取股票报价的函数,async关键字表明该函数内部有异步操作。调用该函数时,会立即返回一个 Promise 对象。

下面是一个指定多少毫秒后输出一个值的例子:

function timeout(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } async function asyncPrint(value, ms) { await timeout(ms); console.log(value); } asyncPrint('hello world', 50);

由于 async 函数返回的是 Promise 对象,可以作为await命令的参数,所以上面的例子也可以写成:

async function timeout(ms) { await new Promise((resolve) => { setTimeout(resolve, ms); }); } async function asyncPrint(value, ms) { await timeout(ms); console.log(value); } asyncPrint('hello world', 50);

async 函数的多种使用形式

// 函数声明 async function foo() {} // 函数表达式 const foo = async function () {}; // 对象的方法 let obj = { async foo() {} }; obj.foo().then(...) // Class 的方法 class Storage { constructor() { this.cachePromise = caches.open('avatars'); } async getAvatar(name) { const cache = await this.cachePromise; return cache.match(`/avatars/${name}.jpg`); } } const storage = new Storage(); storage.getAvatar('jake').then(…); // 箭头函数 const foo = async () => {};

其中 Class 方法示例展示了 await 与缓存的结合:caches.open('avatars')返回的 Promise 在构造函数中就被发起,之后每次getAvatar直接await同一个 Promise,避免重复打开缓存。


三、语法详解

async 函数的语法规则总体上比较简单,难点是错误处理机制

3.1 返回 Promise 对象

async 函数返回一个 Promise 对象。内部return语句返回的值,会成为then方法回调函数的参数:

async function f() { return 'hello world'; } f().then(v => console.log(v)) // "hello world"

async 函数内部抛出错误,会导致返回的 Promise 对象变为reject状态,抛出的错误对象会被catch方法(或then的第二个参数)回调函数接收到:

async function f() { throw new Error('出错了'); } f().then( v => console.log('resolve', v), e => console.log('reject', e) ) //reject Error: 出错了

3.2 Promise 对象的状态变化

async 函数返回的 Promise 对象,必须等到内部所有await命令后面的 Promise 对象执行完,才会发生状态改变,除非遇到return语句或抛出错误。也就是说,只有 async 函数内部的异步操作全部执行完,才会执行then方法指定的回调函数。

async function getTitle(url) { let response = await fetch(url); let html = await response.text(); return html.match(/<title>([\s\S]+)<\/title>/i)[1]; } getTitle('https://tc39.github.io/ecma262/').then(console.log) // "ECMAScript 2017 Language Specification"

上面函数内部有三个操作:抓取网页、取出文本、匹配页面标题。只有这三个操作全部完成,才会执行then里面的console.log

3.3 await 命令

正常情况下,await命令后面是一个 Promise 对象,返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值:

async function f() { // 等同于 // return 123; return await 123; } f().then(v => console.log(v)) // 123

另一种情况是,await命令后面是一个thenable 对象(即定义了then方法的对象),那么await会将其等同于 Promise 对象处理:

class Sleep { constructor(timeout) { this.timeout = timeout; } then(resolve, reject) { const startTime = Date.now(); setTimeout( () => resolve(Date.now() - startTime), this.timeout ); } } (async () => { const sleepTime = await new Sleep(1000); console.log(sleepTime); })(); // 1000

Sleep的实例不是 Promise 对象,但因为定义了then方法,await会将其视为 Promise 处理。这个例子还演示了如何实现休眠效果——JavaScript 一直没有休眠语法,但借助await可以让程序停顿指定时间。下面是一个简化的sleep实现:

function sleep(interval) { return new Promise(resolve => { setTimeout(resolve, interval); }) } // 用法 async function one2FiveInAsync() { for(let i = 1; i <= 5; i++) { console.log(i); await sleep(1000); } } one2FiveInAsync();

await命令后面的 Promise 对象如果变为reject状态,则reject的参数会被catch方法的回调函数接收到:

async function f() { await Promise.reject('出错了'); } f() .then(v => console.log(v)) .catch(e => console.log(e)) // 出错了

注意:上面await语句前面没有return,但reject方法的参数依然传入了catch的回调。这里加上return效果是一样的。

任何一个await语句后面的 Promise 对象变为reject状态,那么整个 async 函数都会中断执行

async function f() { await Promise.reject('出错了'); await Promise.resolve('hello world'); // 不会执行 }

第二个await不会执行,因为第一个await状态变成了reject

有时我们希望前一个异步操作失败也不要中断后续操作。方法一是把第一个await放在try...catch结构里:

async function f() { try { await Promise.reject('出错了'); } catch(e) { } return await Promise.resolve('hello world'); } f() .then(v => console.log(v)) // hello world

方法二是让await后面的 Promise 对象再跟一个catch方法,就地处理可能出现的错误:

async function f() { await Promise.reject('出错了') .catch(e => console.log(e)); return await Promise.resolve('hello world'); } f() .then(v => console.log(v)) // 出错了 // hello world

3.4 错误处理

如果await后面的异步操作出错,那么等同于 async 函数返回的 Promise 对象被reject

async function f() { await new Promise(function (resolve, reject) { throw new Error('出错了'); }); } f() .then(v => console.log(v)) .catch(e => console.log(e)) // Error:出错了

具体的执行机制可参考后文"async 函数的实现原理"——错误会被spawn执行器捕获并交给外层 Promise 的reject

防止出错的方法同样是将其放在try...catch代码块之中:

async function f() { try { await new Promise(function (resolve, reject) { throw new Error('出错了'); }); } catch(e) { } return await('hello world'); }

如果有多个await命令,可以统一放在try...catch结构中:

async function main() { try { const val1 = await firstStep(); const val2 = await secondStep(val1); const val3 = await thirdStep(val1, val2); console.log('Final: ', val3); } catch (err) { console.error(err); } }

下面的例子用try...catch结构实现多次重复尝试(重试机制):

const superagent = require('superagent'); const NUM_RETRIES = 3; async function test() { let i; for (i = 0; i < NUM_RETRIES; ++i) { try { await superagent.get('http://google.com/this-throws-an-error'); break; } catch(err) {} } console.log(i); // 3 } test();

如果await操作成功,就使用break退出循环;如果失败,被catch捕捉后进入下一轮循环。重试逻辑写起来非常自然,这正是 async 函数对比 Promise 链的一大优势。


四、使用注意点(最佳实践)

4.1 注意点一:await 建议配合 try...catch

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); }); }

4.2 注意点二:互不依赖的异步操作应同时触发

多个await后面的异步操作如果不存在继发关系,最好让它们同时触发。下面这种写法getFoogetBar是继发关系,比较耗时——只有getFoo完成以后才会执行getBar

let foo = await getFoo(); let bar = await getBar();

完全可以并发触发:

// 写法一 let [foo, bar] = await Promise.all([getFoo(), getBar()]); // 写法二 let fooPromise = getFoo(); let barPromise = getBar(); let foo = await fooPromise; let bar = await barPromise;

两种写法中getFoogetBar都是同时触发,缩短程序执行时间。关于Promise.all的详细语义,可参考仓库 docs/promise.md:它把多个 Promise 实例包装成一个新 Promise,只有全部成员fulfilled或其中有一个rejected时才触发回调。

4.3 注意点三:await 只能用在 async 函数之中

await命令用在普通函数中会直接报错:

async function dbFuc(db) { let docs = [{}, {}, {}]; // 报错 docs.forEach(function (doc) { await db.post(doc); }); }

forEach的参数改成 async 函数也有问题——三个db.post()并发执行而不是继发执行,可能得到错误结果:

function dbFuc(db) { //这里不需要 async let docs = [{}, {}, {}]; // 可能得到错误结果 docs.forEach(async function (doc) { await db.post(doc); }); }

正确的写法是采用for循环(for...of同样适用):

async function dbFuc(db) { let docs = [{}, {}, {}]; for (let doc of docs) { await db.post(doc); } }

另一种方法是使用数组的reduce()方法:

async function dbFuc(db) { let docs = [{}, {}, {}]; await docs.reduce(async (_, doc) => { await _; await db.post(doc); }, undefined); }

这里reduce()的第一个参数是 async 函数,导致该函数的第一个参数是前一步操作返回的 Promise 对象,所以必须await等待它操作结束。reduce()返回的是docs数组最后一个成员 async 函数的执行结果,也是一个 Promise 对象,因此前面也必须加await。参数函数里没有return语句——它的主要目的是db.post()操作而非返回值,而且 async 函数不管有没有return语句总是返回 Promise 对象,所以return是不必要的。

如果确实希望多个请求并发执行,可以使用Promise.all。当三个请求都会resolved时,下面两种写法效果相同:

async function dbFuc(db) { let docs = [{}, {}, {}]; let promises = docs.map((doc) => db.post(doc)); let results = await Promise.all(promises); console.log(results); } // 或者使用下面的写法 async function dbFuc(db) { let docs = [{}, {}, {}]; let promises = docs.map((doc) => db.post(doc)); let results = []; for (let promise of promises) { results.push(await promise); } console.log(results); }

4.4 注意点四: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 函数的实现原理:spawn 自动执行器

async 函数的实现原理,就是将Generator 函数和自动执行器包装在一个函数里

async function fn(args) { // ... } // 等同于 function fn(args) { return spawn(function* () { // ... }); }

所有 async 函数都可以写成第二种形式,其中的spawn函数就是自动执行器。下面是spawn函数的完整实现,基本就是仓库 docs/generator-async.md 中自动执行器的翻版(文中先给出了手动用then层层回调的写法,再抽象出run自动执行器,最后扩展到co模块):

function spawn(genF) { return new Promise(function(resolve, reject) { const gen = genF(); function step(nextF) { let next; try { next = nextF(); } catch(e) { return reject(e); } if(next.done) { return resolve(next.value); } Promise.resolve(next.value).then(function(v) { step(function() { return gen.next(v); }); }, function(e) { step(function() { return gen.throw(e); }); }); } step(function() { return gen.next(undefined); }); }); }

这个spawn函数完整演示了 async 函数的底层机制:

  • 外层new Promise负责把整个执行过程包装成 Promise 返回,与"async 函数返回值是 Promise"对应;
  • step函数用try/catch包裹nextF(),一旦执行出错立刻reject(e),与"async 函数内部抛出错误会导致返回的 Promise 变为 reject"对应;
  • 每次next.done === false时,用Promise.resolve(next.value).then(...)等待当前异步操作,成功则gen.next(v)把结果传回函数体(这就是await拿到返回值的过程),失败则gen.throw(e)把错误抛进 Generator 内部(这就是await出错能被try...catch捕获的原因);
  • gen.next(undefined)启动整个流程。

理解了spawn,就能理解 async 函数为什么"自带执行器":它把用户从手写corun自动执行器的工作中解放出来,全部交给语言内置。


六、与其他异步处理方法的比较

通过一个例子来看 async 函数与 Promise、Generator 的对比。假定某个 DOM 元素上部署了一系列动画,前一个动画结束才能开始后一个;如果中间有一个动画出错,就不再往下执行,返回上一个成功执行的动画的返回值。

首先看 Promise 的写法:

function chainAnimationsPromise(elem, animations) { // 变量ret用来保存上一个动画的返回值 let ret = null; // 新建一个空的Promise let p = Promise.resolve(); // 使用then方法,添加所有动画 for(let anim of animations) { p = p.then(function(val) { ret = val; return anim(elem); }); } // 返回一个部署了错误捕捉机制的Promise return p.catch(function(e) { /* 忽略错误,继续执行 */ }).then(function() { return ret; }); }

Promise 写法比回调函数大大改进,但一眼看上去全是 Promise 的 API(thencatch等等),操作本身的语义反而不容易看出来。

接着看 Generator 函数的写法:

function chainAnimationsGenerator(elem, animations) { return spawn(function*() { let ret = null; try { for(let anim of animations) { ret = yield anim(elem); } } catch(e) { /* 忽略错误,继续执行 */ } return ret; }); }

Generator 写法语义比 Promise 更清晰,用户定义的操作全部出现在spawn函数内部。问题在于必须有一个任务运行器自动执行 Generator 函数,而且yield后面的表达式必须返回 Promise。

最后看 async 函数的写法:

async function chainAnimationsAsync(elem, animations) { let ret = null; try { for(let anim of animations) { ret = await anim(elem); } } catch(e) { /* 忽略错误,继续执行 */ } return ret; }

async 函数的实现最简洁、最符合语义,几乎没有语义不相关的代码。它将 Generator 写法中的自动执行器改在语言层面提供,不暴露给用户,因此代码量最少。如果使用 Generator 写法,自动执行器需要用户自己提供(即前文的spawn)。


七、实例:按顺序完成异步操作

实际开发中经常遇到一组需要按顺序完成的异步操作,比如依次远程读取一组 URL,然后按读取的顺序输出结果。

Promise 的写法:

function logInOrder(urls) { // 远程读取所有URL const textPromises = urls.map(url => { return fetch(url).then(response => response.text()); }); // 按次序输出 textPromises.reduce((chain, textPromise) => { return chain.then(() => textPromise) .then(text => console.log(text)); }, Promise.resolve()); }

上面代码用fetch同时远程读取一组 URL,每个fetch返回一个 Promise 对象放入textPromises数组;然后reduce依次处理每个 Promise,用then把它们连起来,从而依次输出结果。这种写法不太直观,可读性较差。

async 函数的第一版实现:

async function logInOrder(urls) { for (const url of urls) { const response = await fetch(url); console.log(await response.text()); } }

代码大大简化,但问题是所有远程操作都是继发的——只有前一个 URL 返回结果才会读取下一个,效率很差。需要的是并发发出远程请求、再按顺序输出

async function logInOrder(urls) { // 并发读取远程URL const textPromises = urls.map(async url => { const response = await fetch(url); return response.text(); }); // 按次序输出 for (const textPromise of textPromises) { console.log(await textPromise); } }

虽然map的参数是 async 函数,但它是并发执行的——因为只有 async 函数内部是继发执行,外部不受影响;后面的for...of循环内部使用await,实现了按顺序输出。这个模式("并发发起 + 逐个 await")就是 4.2 节"互不依赖的异步操作同时触发"原则的实战应用。


八、顶层 await

8.1 从"报错"到 ES2022 的正式支持

早期的语法规定:await命令只能出现在 async 函数内部,否则都会报错。

// 报错 const data = await fetch('https://api.example.com');

从 ES2022 开始,允许在模块的顶层独立使用await命令,上面那行代码不再报错。它的主要目的是用await解决模块异步加载的问题。

8.2 顶层 await 解决的痛点

在没有顶层 await 之前,如果模块的输出值依赖异步操作,只能这样写:

// awaiting.js let output; async function main() { const dynamic = await import(someMission); const data = await fetch(url); output = someProcess(dynamic.default, data); } main(); export { output };

模块awaiting.js的输出值output取决于异步操作。异步操作被包装在 async 函数里,只有等里面的异步操作都执行完,output才有值,否则返回undefined

加载这个模块时,结果完全取决于执行时机:

// usage.js import { output } from "./awaiting.js"; function outputPlusValue(value) { return output + value } console.log(outputPlusValue(100)); setTimeout(() => console.log(outputPlusValue(100)), 1000);

如果awaiting.js里的异步操作没执行完,加载进来的output就是undefined

目前的解决方法,是让原始模块额外输出一个 Promise 对象,从它判断异步操作是否结束:

// awaiting.js let output; export default (async function main() { const dynamic = await import(someMission); const data = await fetch(url); output = someProcess(dynamic.default, data); })(); export { output };

加载端也必须配合这个"额外协议":

// usage.js import promise, { output } from "./awaiting.js"; function outputPlusValue(value) { return output + value } promise.then(() => { console.log(outputPlusValue(100)); setTimeout(() => console.log(outputPlusValue(100)), 1000); });

这种写法很麻烦:要求模块使用者遵守额外的使用协议。一旦忘了用 Promise 加载,依赖这个模块的代码就可能出错;如果usage.js又有对外输出,依赖链上的所有模块都要改用 Promise 加载。

顶层的await命令就是为了解决这个问题——它保证只有异步操作完成,模块才会输出值:

// awaiting.js const dynamic = import(someMission); const data = fetch(url); export const output = someProcess((await dynamic).default, await data);

两个异步操作在输出时都加上了await,只有等到异步操作完成,模块才会输出值。加载端写法与普通模块完全一样:

// usage.js import { output } from "./awaiting.js"; function outputPlusValue(value) { return output + value } console.log(outputPlusValue(100)); setTimeout(() => console.log(outputPlusValue(100)), 1000);

模块的使用者完全不用关心依赖模块内部有没有异步操作,正常加载即可。模块加载会等待依赖模块的异步操作完成才执行后面的代码,有点像"暂停在那里",所以总是能拿到正确的output

8.3 顶层 await 的适用范围与使用场景

注意:顶层await只能用在 ES6 模块(ES Module),不能用在 CommonJS 模块。因为 CommonJS 模块的require()是同步加载,如果有顶层await,就没法处理加载了。

下面是顶层await的一些典型使用场景:

// import() 方法加载 const strings = await import(`/i18n/${navigator.language}`); // 数据库操作 const connection = await dbConnector(); // 依赖回滚 let jQuery; try { jQuery = await import('https://cdn-a.com/jQuery'); } catch { jQuery = await import('https://cdn-b.com/jQuery'); }

第三个"依赖回滚"示例展示了结合try...catch的 CDN 容灾加载模式:主 CDN 加载失败时自动回退到备用 CDN,这在模块加载层面即可完成。

8.4 多模块加载的同步语义

如果加载多个包含顶层await命令的模块,加载命令是同步执行的:

// x.js console.log("X1"); await new Promise(r => setTimeout(r, 1000)); console.log("X2"); // y.js console.log("Y"); // z.js import "./x.js"; import "./y.js"; console.log("Z");

三个模块中,z.js加载x.jsy.js,打印结果是X1YX2Z。这说明z.js并没有等待x.js加载完成再去加载y.js——顶层await像交出代码执行权给其他模块加载,等异步操作完成后,再拿回执行权继续向下执行。


九、小结

维度说明
本质async 函数是 Generator 函数的语法糖,自带执行器
返回值始终返回 Promise 对象,return的值成为 resolve 参数
await 语义后面跟 Promise 返回其结果;跟原始值直接返回;跟 thenable 对象视同 Promise
错误处理await出错即 reject 整个函数;用try...catch.catch()就地消化
性能要点互不依赖的操作用Promise.all并发;for/for...of实现继发
实现原理等价于spawn包装 Generator:gen.next(v)传值、gen.throw(e)抛错
顶层 awaitES2022 起在 ES Module 顶层可用,解决模块异步加载问题

async 函数是 JavaScript 异步编程的最终形态之一,值得把它从"会用的语法"升级为"理解原理的武器"。本仓库中与之配套的 Promise 对象、Generator 函数的语法、Generator 函数的异步应用 章节构成了完整的异步编程知识链,建议按此顺序阅读以建立体系化认知。

  • 教程
  • 前端

【免费下载链接】es6tutorial

《ECMAScript 6入门》是一本开源的 JavaScript 语言教程,全面介绍 ECMAScript 6 新增的语法特性。

项目地址:https://gitcode.com/gh_mirrors/es/es6tutorial
点击查看免费下载
上一篇:DLSSTweaks配置工具使用教程:从安装到高级设置的完整指南
下一篇:解决Burnout 3在PCSX2模拟器中的DX11渲染崩溃问题

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询