MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript async/await的使用与实践

2022-10-251.1k 阅读

理解 JavaScript 异步编程

在深入探讨 async/await 之前,我们先来回顾一下 JavaScript 异步编程的发展历程以及为什么它如此重要。

JavaScript 是一门单线程语言,这意味着它在同一时间只能执行一个任务。在浏览器环境中,这是为了避免多个线程同时操作 DOM 导致冲突。然而,许多操作,如网络请求、读取文件等,可能会花费较长时间,如果采用同步方式执行,会阻塞线程,使整个程序变得卡顿。

为了解决这个问题,JavaScript 引入了异步编程。早期,我们通过回调函数来处理异步操作。例如,读取文件的操作:

const fs = require('fs');
fs.readFile('example.txt', 'utf8', function (err, data) {
    if (err) {
        console.error(err);
        return;
    }
    console.log(data);
});

这种方式虽然解决了阻塞问题,但当有多个异步操作相互依赖时,回调函数会层层嵌套,形成所谓的 “回调地狱”,代码的可读性和维护性变得极差。

Promise 的出现

为了解决回调地狱的问题,Promise 被引入。Promise 代表一个异步操作的最终完成(或失败)及其结果值。它有三种状态:pending(进行中)、fulfilled(已成功)和 rejected(已失败)。

一个简单的 Promise 示例:

function delay(ms) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('延迟完成');
        }, ms);
    });
}
delay(1000)
   .then(result => {
        console.log(result);
    })
   .catch(error => {
        console.error(error);
    });

Promise 通过 .then() 方法来处理成功的结果,通过 .catch() 方法来处理失败的情况。这样,多个异步操作可以通过链式调用 .then() 来顺序执行,避免了回调地狱。

例如,连续进行多个异步操作:

function step1() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('步骤 1 完成');
        }, 1000);
    });
}
function step2(result1) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const newResult = result1 + ',步骤 2 完成';
            resolve(newResult);
        }, 1000);
    });
}
function step3(result2) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const newResult = result2 + ',步骤 3 完成';
            resolve(newResult);
        }, 1000);
    });
}
step1()
   .then(result1 => step2(result1))
   .then(result2 => step3(result2))
   .then(finalResult => {
        console.log(finalResult);
    })
   .catch(error => {
        console.error(error);
    });

虽然 Promise 大大改善了异步代码的可读性,但链式调用 .then() 有时还是显得不够直观。这时,async/await 就登场了。

async/await 基础

async 函数是一种异步函数,它返回一个 Promise 对象。如果 async 函数返回一个非 Promise 值,JavaScript 会自动将其包装成已解决状态(resolved)的 Promise。

async function asyncFunction() {
    return '这是一个异步函数的返回值';
}
asyncFunction().then(result => {
    console.log(result);
});

在上面的代码中,asyncFunction 是一个 async 函数,它返回一个字符串。这个字符串会被自动包装成一个已解决状态的 Promise,然后通过 .then() 方法可以获取到返回值。

await 关键字只能在 async 函数内部使用。它用于暂停 async 函数的执行,直到其等待的 Promise 被解决(resolved)或被拒绝(rejected)。

async function asyncFunction() {
    const result = await delay(1000);
    console.log(result);
}
asyncFunction();

在这个例子中,await delay(1000) 会暂停 asyncFunction 的执行,直到 delay 返回的 Promise 被解决(这里是 1 秒后),然后将 Promise 的解决值赋给 result,接着继续执行 asyncFunction 中的后续代码。

使用 async/await 处理多个异步操作

顺序执行多个异步操作

当需要顺序执行多个异步操作时,async/await 提供了非常直观的方式。我们可以将之前通过 Promise 链式调用实现的顺序操作改写为:

async function executeSteps() {
    const result1 = await step1();
    const result2 = await step2(result1);
    const finalResult = await step3(result2);
    console.log(finalResult);
}
executeSteps();

通过 await,每个异步操作按顺序执行,代码看起来就像同步代码一样,极大地提高了可读性。

并行执行多个异步操作

有时,我们希望多个异步操作并行执行,以提高效率。Promise.all 结合 async/await 可以很好地实现这一点。

假设我们有两个异步函数 fetchData1fetchData2

function fetchData1() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('数据 1');
        }, 2000);
    });
}
function fetchData2() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('数据 2');
        }, 1000);
    });
}
async function parallelFetch() {
    const [data1, data2] = await Promise.all([fetchData1(), fetchData2()]);
    console.log(data1, data2);
}
parallelFetch();

parallelFetch 函数中,Promise.all 接受一个 Promise 数组,它会并行执行这些 Promise。await 会等待所有 Promise 都被解决,然后将解决值按顺序放入数组中,通过解构赋值可以方便地获取每个 Promise 的结果。

处理错误

async/await 中处理错误也非常直观。如果 await 的 Promise 被拒绝,async 函数会停止执行,并抛出错误,可以通过 try...catch 块来捕获错误。

async function errorHandling() {
    try {
        const result = await Promise.reject('出错了');
        console.log(result);
    } catch (error) {
        console.error(error);
    }
}
errorHandling();

在这个例子中,await Promise.reject('出错了') 会抛出错误,try...catch 块捕获到这个错误并打印出来。

async/await 在实际项目中的应用

网络请求

在前端开发中,经常需要通过 fetch 进行网络请求。fetch 返回一个 Promise,结合 async/await 可以使代码更简洁。

async function fetchData() {
    try {
        const response = await fetch('https://example.com/api/data');
        if (!response.ok) {
            throw new Error('网络请求失败');
        }
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error(error);
    }
}
fetchData();

在这个例子中,首先通过 await fetch('https://example.com/api/data') 发起网络请求并等待响应。如果响应状态不是 ok,则抛出错误。接着,通过 await response.json() 将响应数据解析为 JSON 格式并等待解析完成,最后处理数据。

数据库操作

在后端开发中,与数据库交互也是常见的异步操作。以 Node.js 结合 MongoDB 为例:

const { MongoClient } = require('mongodb');
async function connectToDatabase() {
    const uri = "mongodb://localhost:27017";
    const client = new MongoClient(uri);
    try {
        await client.connect();
        const database = client.db('test');
        const collection = database.collection('documents');
        const result = await collection.find({}).toArray();
        console.log(result);
    } catch (error) {
        console.error(error);
    } finally {
        await client.close();
    }
}
connectToDatabase();

在这个代码中,await client.connect() 等待连接到 MongoDB 数据库。连接成功后,进行查询操作 await collection.find({}).toArray() 并等待结果。最后,在 finally 块中通过 await client.close() 关闭数据库连接。

性能优化与注意事项

避免不必要的等待

虽然 await 让异步代码看起来像同步代码,但过度使用 await 可能会影响性能。例如,在一些不需要顺序执行的异步操作中,应该使用 Promise.all 并行执行,而不是逐个 await

错误处理的完整性

async 函数中,确保错误处理的完整性非常重要。不仅要在 try...catch 块中捕获 await 可能抛出的错误,还要考虑 async 函数内部其他可能抛出错误的地方。

内存管理

在长时间运行的应用程序中,异步操作可能会占用大量内存。例如,在处理大量数据的网络请求或数据库查询时,要注意合理释放资源,避免内存泄漏。

与其他异步技术的对比

与回调函数对比

回调函数是早期 JavaScript 处理异步的方式,虽然简单直接,但容易陷入回调地狱,代码可读性和维护性差。而 async/await 基于 Promise,通过同步风格的代码实现异步操作,极大地提高了代码的可维护性和可读性。

与 Promise 对比

Promise 通过链式调用 .then() 解决了回调地狱问题,但链式调用有时不够直观。async/await 在 Promise 的基础上,让异步代码更像同步代码,进一步简化了异步操作的编写。然而,async/await 本质上还是基于 Promise,所以在一些需要更细粒度控制 Promise 状态的场景下,可能还需要直接使用 Promise 的一些特性。

浏览器兼容性与 polyfill

async/await 是 ES2017 引入的特性,虽然现代浏览器大多已经支持,但对于一些旧版本浏览器,可能需要使用 polyfill 来实现兼容。Babel 是一个常用的 JavaScript 编译器,可以将 async/await 等新特性转换为旧版本浏览器能理解的代码。

例如,通过 Babel 配置文件 .babelrc 可以添加如下配置:

{
    "presets": [
        [
            "@babel/preset-env",
            {
                "targets": {
                    "browsers": ["ie >= 11"]
                }
            }
        ]
    ]
}

这样,Babel 会根据目标浏览器(这里是 IE 11 及以上)将 async/await 代码转换为兼容的形式。

深入理解 async/await 的执行机制

当一个 async 函数被调用时,它会返回一个 Promise 对象。如果 async 函数内部没有 await 关键字,它会立即执行并返回一个已解决状态的 Promise。

async function simpleAsync() {
    console.log('开始执行');
    return '执行完成';
}
const promise = simpleAsync();
console.log('函数调用后');
promise.then(result => {
    console.log(result);
});

在这个例子中,simpleAsync 函数内部没有 await,它会立即执行,打印出 开始执行,然后返回一个已解决状态的 Promise。接着,console.log('函数调用后') 被执行。最后,Promise 的解决值通过 .then() 被打印出来。

async 函数内部有 await 关键字时,情况会有所不同。await 会暂停 async 函数的执行,直到其等待的 Promise 被解决。

async function asyncWithAwait() {
    console.log('开始执行');
    const result = await delay(1000);
    console.log(result);
    return '最终结果';
}
const awaitPromise = asyncWithAwait();
console.log('函数调用后');
awaitPromise.then(finalResult => {
    console.log(finalResult);
});

在这个例子中,asyncWithAwait 函数在执行到 await delay(1000) 时会暂停,console.log('函数调用后') 会被执行。1 秒后,delay 返回的 Promise 被解决,await 恢复 asyncWithAwait 的执行,打印出 延迟完成,最后返回 最终结果 并通过 .then() 打印出来。

从事件循环的角度来看,async/await 与 JavaScript 的事件循环机制紧密相关。当 await 暂停 async 函数时,控制权交回给事件循环,事件循环可以处理其他任务。当 await 的 Promise 被解决时,async 函数会被重新放入事件队列等待执行。

高级应用场景

异步迭代器

在处理大量异步数据时,异步迭代器结合 async/await 可以实现高效的流处理。

async function* asyncGenerator() {
    yield await delay(1000, '值 1');
    yield await delay(1000, '值 2');
    yield await delay(1000, '值 3');
}
async function consumeAsyncGenerator() {
    const generator = asyncGenerator();
    for await (const value of generator) {
        console.log(value);
    }
}
consumeAsyncGenerator();
function delay(ms, result) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(result);
        }, ms);
    });
}

在这个例子中,asyncGenerator 是一个异步生成器,它通过 yield await 逐个返回异步操作的结果。consumeAsyncGenerator 函数使用 for await...of 循环来消费这些结果,每次等待一个值被生成并打印出来。

异步队列

在一些场景下,需要按照顺序执行一系列异步任务,但又不想阻塞其他代码的执行,这时可以实现一个异步队列。

class AsyncQueue {
    constructor() {
        this.tasks = [];
        this.isRunning = false;
    }
    addTask(task) {
        this.tasks.push(task);
        this.run();
    }
    async run() {
        if (this.isRunning) return;
        this.isRunning = true;
        while (this.tasks.length > 0) {
            const task = this.tasks.shift();
            try {
                await task();
            } catch (error) {
                console.error(error);
            }
        }
        this.isRunning = false;
    }
}
const queue = new AsyncQueue();
queue.addTask(() => delay(1000, '任务 1'));
queue.addTask(() => delay(1000, '任务 2'));
queue.addTask(() => delay(1000, '任务 3'));

在这个 AsyncQueue 类中,addTask 方法用于添加任务到队列,run 方法会按顺序执行队列中的任务。每次执行一个任务时,通过 await 等待任务完成,并且在任务出错时进行错误处理。

最佳实践总结

  • 保持代码简洁:避免在 async 函数中编写过于复杂的逻辑,将复杂逻辑拆分成多个小的异步或同步函数。
  • 合理处理错误:在 async 函数内部始终使用 try...catch 块来捕获错误,确保错误不会被遗漏。
  • 注意性能:根据实际需求选择合适的异步执行方式,如并行执行(Promise.all)或顺序执行(await 逐个等待)。
  • 文档化代码:对于复杂的异步操作,添加详细的注释,使其他开发人员更容易理解代码的逻辑。

通过深入理解和正确使用 async/await,开发人员可以编写出高效、可读且易于维护的异步 JavaScript 代码,无论是在前端还是后端开发中,都能极大地提升开发效率和代码质量。