异步函数

async 与 await 关键字用于创建和使用异步函数,它们最早是 ECMAScript 6 中 Promise 对象的一种应用方式,然后又作为一种规范增加到 ECMAScript 8 中。通过异步函数,我们能够让以同步方式编写的代码异步执行。简单来说,异步函数是基于 Promise 对象的语法糖,能够进一步提高代码的可读性和可维护性。

使用异步函数来处理异步任务是目前较好的异步编程模式。

Promise对象的局限性

虽然 Promise 对象解决了回调灾难的问题,使代码只纵向增长,但是它是一种比较特殊的代码结构。当连续的异步操作过多时,会存在太多 then/catch 调用链,这在一定程度上影响了代码的可读性。

每个 then() 方法的函数都拥有各自独立的作用域,这使得操作起来不够方便。例如,以下代码中有 4 个 then() 方法和 1 个 catch() 方法,它们都有各自的传入参数(body1~4,err 参数)和变量(varible1~4),但各个 then/catch 代码块只能访问自己的传入参数和变量,无法访问其他 then/catch 代码块中的传入参数和变量,这也提高了处理的复杂度。为了访问这些参数和变量,你必须将其他代码放到对应的 then/catch 代码块中。

GetHttpResponse("http://xxx.api/product")
    .then(body1 => {
        let varible1 = 1;
        //……
    })
    .then(body2 => {
        let varible2 = 2;
        //...
    })
    .then(body3 => {
        let varible3 = 3;
        //...
    })
    .then(body4 => {
        let varible4 = 4;
        //...
    })
    .catch(err => {
         //...
});

如果不得不向下一个 then 代码块传递某个临时变量,那么要么将临时变量声明到外层作用域,要么修改 Promise 对象的封装异步操作的自定义函数,让它支持向 resolve() 内置函数,同时传递异步处理结果和某个自定义变量,示例代码如下。

//方法1:将临时变量声明到外层作用域
let product: any = {};
GetHttpResponse("http://xxx.api/product").then(body => {
    product.mainInfo = JSON.parse(body as string);
    return GetHttpResponse("http://xxx.api/product/reviews");
}).then(body => {
    product.reviews = JSON.parse(body as string);
    return GetHttpResponse("http://xxx.api/product/promotinon");
})
...

//方法2:修改封装异步操作的自定义函数
import * as request from 'request'

function GetHttpResponse(url, tempPara?) {
    return new Promise((resolve, reject) => {
        request(url, function (error, response, body) {
            if (!error) {
                if (response.statusCode == 200) {
                    resolve({ "body": body, "tempPara": tempPara });
                }
                else {
                    reject(`http status code is ${response.statusCode}`)
                }
            }
            else {
                reject(error);
            }
        });
    });
};


GetHttpResponse("http://xxx.api/product").then((res: any) => {
    let product: any = {};
    product.mainInfo = JSON.parse(res.body);
    return GetHttpResponse("http://xxx.api/product/reviews", product);
}).then((res: any) => {
    res.tempPara.reviews = JSON.parse(res.body);
    return GetHttpResponse("http://xxx.api/product/promotinon", res.tempPara);
})
...

ECMAScript 8 中新增了两个关键字 async 与 await,它们用于解决异步任务的代码组织问题。

使用async创建异步函数

async 关键字用于声明异步函数。它可以用在普通函数声明、函数表达式、箭头函数和方法上。

例如,以下代码通过 async 关键字声明了一个异步函数 hello(),然后就可以调用该函数了,调用方式和普通函数一样。

async function hello() { return "hello world" };
console.log(hello());

代码执行后,并没有输出预期的 hello world 字符串,而输出以下结果。

> Promise {<resolved>: "hello"}

异步函数是基于 Promise 对象的语法糖,以上代码实际上类似于以下代码。

function hello() { return Promise.resolve("hello world"); };

如果要输出函数的返回值,则可以像调用 Promise 对象一样使用 then() 方法,示例代码如下。

hello().then(p => console.log(p));

以上代码并没有体现出异步函数的优势,只演示了 async 关键字的用法。只有当它与 await 关键字一起使用时,异步函数的真正优势才能体现出来。

通过await使用异步函数

await 关键字必须在由 async 关键字标记的异步函数中使用,或者在模块的顶层代码中使用。当执行到 await 关键字所在的语句时,代码会暂停在此处,直到它后面的 Promise 对象返回结果(转变为 resolved 或 rejected 状态),然后再执行后续代码。这里的暂停并非真正的暂停,仅仅暂停异步函数中后续代码的执行,异步函数之外的同步代码在暂停期间依旧会向下执行。

例如,在以下代码中,除 hello() 异步函数之外,还声明了一个 printHello() 异步函数。在 printHello() 异步函数内部调用 hello() 异步函数,并使用 await() 函数进行等待,再把等待后的计算结果赋给变量 str,然后输出 str 的值,再以同步语法调用 printHello() 异步函数,接着输出字符串 “executed”。

async function hello() { return "hello world" };

async function printHello() {
    let str = await hello();
    console.log(str); //输出hello world
}

printHello();
console.log("executed");

注意,虽然本例中似乎以同步语法调用了 printHello() 异步函数,但是异步函数是 Promise 对象的语法糖,调用 printHello() 异步函数本质上只返回一个新的 Promise 对象,因此整个函数依然是异步执行的。基于前面介绍的异步任务运行机制,本段代码的执行结果如下。

> executed
> hello world

另外还需要注意,await 关键字必须在由 async 关键字标记的异步函数中使用,或者在模块的顶层代码中使用。如果在普通函数中使用 await 关键字,或在非模块的顶层代码中使用,均无法通过编译。例如,以下代码将引起编译错误。

async function hello() { return "hello world" };

function printHelloSync() {
//编译错误:仅允许在异步函数和模块的顶层代码中使用 "await" 表达式。ts(1308)
    let str = await hello();
    console.log(str);
}

//编译错误:只有当文件是模块时,才允许在该文件的顶层使用 "await" 表达式,但此文件没有导入或导出
//请考虑添加空的 "export {}" 来将此文件变为模块。ts(1375)
await hello();

在模块的顶层代码中也可以使用 await 关键字。例如,以下代码通过空导出语法将整个文件标记为模块,然后就在顶层代码中使用 await 关键字。

export { }
async function hello() { return "hello world" };

console.log(await hello());
console.log("executed");

输出结果如下。

> hello world
> executed

可以看到,使用 await 关键字后,整个异步任务可以按照同步语法的形式编写。

以异步函数优化Promise对象

前面介绍了 Promise 对象的局限,并举了几个例子,接下来可以用异步函数改写这些例子。

首先,引用前面用 Promise 对象封装后的 Request 框架代码。

import * as request from 'request'

function GetHttpResponse(url) {
    return new Promise((resolve, reject) => {
        request(url, function (error, response, body) {
            if (!error) {
                if (response.statusCode == 200) {
                    resolve(body);
                }
                else {
                    reject(`http status code is ${response.statusCode}`)
                }
            }
            else {
                reject(error);
            }
        });
    });
};

用异步函数的形式调用 GetHttpResponse() 函数,先后请求 3 个网址,并将返回的内容存到 allBodys 对象的各个属性中。

async function GetAllApiResponse() {
    try {
        let allBodys: any = {};
        allBodys.baiduBody = await GetHttpResponse("http://www.baidu.com");
        console.log(allBodys.baiduBody);
        allBodys.bingBody = await GetHttpResponse("http://www.bing.com");
        console.log(allBodys.bingBody);
        allBodys.sogouBody = await GetHttpResponse("http://www.sogou.com");
        console.log(allBodys.sogouBody);

    } catch (err) {
        console.log("there is something wrong!");
        console.log(err);
    } finally {
        console.log("call api done!")
    }
}

GetAllApiResponse();

可以看到,现在的写法与同步代码的写法没有区别。异步函数解决了异步任务的代码组织问题,前面在使用 Promise 对象时,各个 then/catch 代码块拥有各自的作用域导致变量无法相互访问的问题终于得以解决,同时公共的临时变量也无须放到外层作用域中。如果要处理异常,可以直接使用通用的 try/catch/finally 语句,代码整体上变得清晰、明了,代码的可读性极大地提高。