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

JavaScript中的async/await深入解析

2021-11-125.1k 阅读

1. 异步编程背景

在JavaScript的发展历程中,异步编程始终占据着重要地位。JavaScript最初是为了在浏览器环境中实现简单的交互功能,例如响应用户点击按钮、处理表单提交等操作。由于这些操作往往需要等待外部事件(如网络请求、用户输入等),如果采用同步方式执行,浏览器将会处于阻塞状态,导致页面无响应,严重影响用户体验。

以网络请求为例,当浏览器向服务器发送一个HTTP请求获取数据时,这个过程可能会花费几百毫秒甚至数秒的时间。在同步编程模型下,JavaScript代码会在发送请求后一直等待服务器响应,期间无法执行其他任务。而异步编程则允许JavaScript在发送请求后继续执行后续代码,当服务器响应返回时,通过特定的机制(如回调函数、Promise等)来处理响应数据。

1.1 回调地狱

早期,JavaScript主要通过回调函数来实现异步操作。例如,使用setTimeout函数模拟一个异步任务:

setTimeout(() => {
    console.log('异步任务完成');
}, 1000);

在实际应用中,经常会遇到多个异步任务相互依赖的情况。例如,先读取一个文件,然后将文件内容发送到服务器,接着根据服务器的响应进行下一步操作。在回调函数的方式下,代码可能会写成如下形式:

const fs = require('fs');
const http = require('http');

fs.readFile('example.txt', 'utf8', (err, data) => {
    if (err) {
        console.error('读取文件错误:', err);
        return;
    }

    const options = {
        host: 'example.com',
        port: 80,
        path: '/upload',
        method: 'POST',
        headers: {
            'Content-Type': 'text/plain',
            'Content-Length': Buffer.byteLength(data)
        }
    };

    const req = http.request(options, (res) => {
        let responseData = '';
        res.on('data', (chunk) => {
            responseData += chunk;
        });
        res.on('end', () => {
            console.log('服务器响应:', responseData);
            // 基于服务器响应进行下一步操作
            if (responseData.includes('success')) {
                console.log('操作成功');
            } else {
                console.log('操作失败');
            }
        });
    });

    req.write(data);
    req.end();
});

随着异步操作的嵌套层数增多,代码会变得越来越难以阅读和维护,形成所谓的“回调地狱”。这种嵌套结构使得代码的逻辑变得混乱,难以进行调试和修改。

1.2 Promise的出现

为了解决回调地狱的问题,Promise被引入到JavaScript中。Promise是一种表示异步操作最终完成(或失败)及其结果的对象。一个Promise对象处于以下三种状态之一:

  • Pending(进行中):初始状态,既没有被兑现,也没有被拒绝。
  • Fulfilled(已兑现):意味着操作成功完成,Promise对象会有一个相应的返回值。
  • Rejected(已拒绝):意味着操作失败,Promise对象会有一个表示失败原因的理由。

使用Promise重写上述文件读取并上传的示例:

const fs = require('fs');
const http = require('http');
const { promisify } = require('util');

const readFileAsync = promisify(fs.readFile);

readFileAsync('example.txt', 'utf8')
   .then(data => {
        const options = {
            host: 'example.com',
            port: 80,
            path: '/upload',
            method: 'POST',
            headers: {
                'Content-Type': 'text/plain',
                'Content-Length': Buffer.byteLength(data)
            }
        };

        return new Promise((resolve, reject) => {
            const req = http.request(options, (res) => {
                let responseData = '';
                res.on('data', (chunk) => {
                    responseData += chunk;
                });
                res.on('end', () => {
                    if (res.statusCode >= 200 && res.statusCode < 300) {
                        resolve(responseData);
                    } else {
                        reject(new Error('上传失败'));
                    }
                });
            });

            req.on('error', (err) => {
                reject(err);
            });

            req.write(data);
            req.end();
        });
    })
   .then(responseData => {
        console.log('服务器响应:', responseData);
        if (responseData.includes('success')) {
            console.log('操作成功');
        } else {
            console.log('操作失败');
        }
    })
   .catch(err => {
        console.error('发生错误:', err);
    });

Promise通过链式调用的方式,使得异步操作的逻辑更加清晰,避免了回调地狱的问题。然而,Promise的链式调用在处理多个复杂异步操作时,代码仍然显得有些冗长和繁琐。

2. async/await 基础概念

async/await是ES2017引入的异步编程语法糖,它建立在Promise的基础之上,使得异步代码看起来更像是同步代码,大大提高了代码的可读性和可维护性。

2.1 async函数

async关键字用于定义一个异步函数。异步函数总是返回一个Promise对象。如果异步函数的返回值不是一个Promise对象,JavaScript会自动将其包装成一个已兑现状态(resolved)的Promise对象。

async function asyncFunction() {
    return '异步函数返回值';
}

asyncFunction().then(result => {
    console.log(result); // 输出: 异步函数返回值
});

在上述代码中,asyncFunction是一个异步函数,它返回一个字符串。由于返回值不是Promise对象,JavaScript将其包装成一个已兑现状态的Promise对象,通过.then()方法可以获取到这个返回值。

2.2 await关键字

await关键字只能在async函数内部使用。它用于暂停async函数的执行,等待一个Promise对象的解决(resolved或rejected),然后恢复async函数的执行,并返回Promise对象的解决值。

async function asyncFunction() {
    const promise = new Promise((resolve) => {
        setTimeout(() => {
            resolve('Promise已解决');
        }, 1000);
    });

    const result = await promise;
    console.log(result); // 输出: Promise已解决
}

asyncFunction();

在这段代码中,await promise暂停了asyncFunction的执行,直到promise被解决。当promise被解决后,await表达式返回promise的解决值,这个值被赋给result变量,然后继续执行asyncFunction中的后续代码。

3. async/await 语法详解

3.1 await 与 Promise 的关系

await本质上是对Promise的一种更简洁的处理方式。当一个Promise对象被await时,await会阻塞其所在的async函数的执行,直到该Promise被解决。如果Promise被兑现(resolved),await返回Promise的resolve值;如果Promise被拒绝(rejected),await会抛出一个错误。

async function asyncFunction() {
    try {
        const resolvedPromise = new Promise((resolve) => {
            setTimeout(() => {
                resolve('已兑现的Promise');
            }, 1000);
        });

        const resolvedResult = await resolvedPromise;
        console.log(resolvedResult); // 输出: 已兑现的Promise

        const rejectedPromise = new Promise((_, reject) => {
            setTimeout(() => {
                reject(new Error('Promise被拒绝'));
            }, 1500);
        });

        const rejectedResult = await rejectedPromise;
        console.log(rejectedResult); // 这行代码不会执行
    } catch (error) {
        console.error('捕获到错误:', error.message); // 输出: 捕获到错误: Promise被拒绝
    }
}

asyncFunction();

在上述代码中,await resolvedPromise成功获取到了已兑现Promise的值并输出。而await rejectedPromise由于Promise被拒绝,会抛出错误,这个错误被try...catch块捕获并处理。

3.2 多个 await 操作

async函数中可以有多个await操作,它们按照顺序依次执行。这使得多个异步操作可以像同步操作一样按顺序进行,而不需要使用复杂的Promise链式调用。

async function multipleAwait() {
    const promise1 = new Promise((resolve) => {
        setTimeout(() => {
            resolve('Promise 1 已解决');
        }, 1000);
    });

    const result1 = await promise1;
    console.log(result1); // 输出: Promise 1 已解决

    const promise2 = new Promise((resolve) => {
        setTimeout(() => {
            resolve('Promise 2 已解决');
        }, 1500);
    });

    const result2 = await promise2;
    console.log(result2); // 输出: Promise 2 已解决
}

multipleAwait();

在这个例子中,await promise1先执行,等待promise1被解决后输出结果,然后才执行await promise2,等待promise2被解决并输出结果。整个过程看起来就像同步代码一样顺序执行。

3.3 并发执行多个异步操作

虽然await通常按顺序执行异步操作,但有时我们希望多个异步操作能够并发执行,以提高效率。在这种情况下,可以使用Promise.all结合await来实现。

Promise.all接受一个Promise对象数组作为参数,并返回一个新的Promise对象。这个新的Promise对象在所有输入的Promise对象都被兑现时被兑现,其解决值是一个包含所有输入Promise解决值的数组。如果其中任何一个Promise被拒绝,Promise.all返回的Promise对象会立即被拒绝。

async function concurrentAsync() {
    const promise1 = new Promise((resolve) => {
        setTimeout(() => {
            resolve('Promise 1 已解决');
        }, 1000);
    });

    const promise2 = new Promise((resolve) => {
        setTimeout(() => {
            resolve('Promise 2 已解决');
        }, 1500);
    });

    const [result1, result2] = await Promise.all([promise1, promise2]);
    console.log(result1); // 输出: Promise 1 已解决
    console.log(result2); // 输出: Promise 2 已解决
}

concurrentAsync();

在上述代码中,promise1promise2同时开始执行,await Promise.all([promise1, promise2])会等待两个Promise都被解决,然后将它们的解决值分别赋给result1result2。这样就实现了多个异步操作的并发执行,同时又可以方便地获取每个操作的结果。

4. async/await 错误处理

4.1 try...catch 块处理错误

async函数中,最常用的错误处理方式是使用try...catch块。由于await会抛出Promise被拒绝时的错误,通过try...catch块可以捕获这些错误并进行处理。

async function errorHandling() {
    try {
        const rejectedPromise = new Promise((_, reject) => {
            setTimeout(() => {
                reject(new Error('Promise被拒绝'));
            }, 1000);
        });

        await rejectedPromise;
    } catch (error) {
        console.error('捕获到错误:', error.message); // 输出: 捕获到错误: Promise被拒绝
    }
}

errorHandling();

在这个例子中,await rejectedPromise由于Promise被拒绝而抛出错误,这个错误被try...catch块捕获,在catch块中可以对错误进行适当的处理,比如记录日志、显示错误信息给用户等。

4.2 Promise.catch 处理错误

除了try...catch块,也可以使用Promise.catch方法来处理async函数返回的Promise对象中的错误。

async function errorHandlingWithCatch() {
    const rejectedPromise = new Promise((_, reject) => {
        setTimeout(() => {
            reject(new Error('Promise被拒绝'));
        }, 1000);
    });

    await rejectedPromise;
}

errorHandlingWithCatch().catch(error => {
    console.error('捕获到错误:', error.message); // 输出: 捕获到错误: Promise被拒绝
});

在这种方式下,async函数返回的Promise对象如果被拒绝,通过.catch()方法可以捕获到错误并进行处理。需要注意的是,在async函数内部使用try...catch块可以更细粒度地控制错误处理的范围,而在函数外部使用Promise.catch则是对整个async函数执行过程中的错误进行统一处理。

5. async/await 与其他异步编程方式的比较

5.1 与回调函数的比较

  • 可读性async/await使异步代码看起来像同步代码,大大提高了可读性。而回调函数在处理多个异步操作时容易形成回调地狱,导致代码逻辑混乱,难以阅读和维护。
  • 错误处理:在async/await中,可以使用try...catch块统一处理异步操作中的错误,代码结构清晰。而在回调函数中,错误处理通常需要在每个回调函数内部单独进行,增加了代码的复杂性。

5.2 与 Promise 的比较

  • 语法简洁性async/await是基于Promise的语法糖,它的语法更加简洁,尤其是在处理多个顺序执行的异步操作时,不需要像Promise那样使用链式调用,代码更加直观。
  • 代码执行流程async/await通过暂停和恢复async函数的执行,使得异步操作的执行流程更接近同步代码的执行流程,更容易理解和调试。而Promise的链式调用虽然解决了回调地狱问题,但在处理复杂异步逻辑时,代码的执行流程相对不够直观。

6. async/await 在实际项目中的应用场景

6.1 网络请求

在前端开发中,经常需要与服务器进行数据交互,如发送HTTP请求获取数据或提交数据。async/await可以使网络请求的代码更加简洁和易读。

以下是使用fetch API结合async/await发送GET请求的示例:

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

fetchData();

在这个例子中,await fetch('https://example.com/api/data')发送网络请求并等待响应,await response.json()等待将响应数据解析为JSON格式。如果请求过程中出现错误,可以通过try...catch块进行处理。

6.2 文件操作

在Node.js环境中,async/await也常用于文件操作。例如,读取文件内容、写入文件等操作通常是异步的,使用async/await可以使代码更清晰。

const fs = require('fs');
const { promisify } = require('util');

const readFileAsync = promisify(fs.readFile);
const writeFileAsync = promisify(fs.writeFile);

async function fileOperations() {
    try {
        const data = await readFileAsync('input.txt', 'utf8');
        const processedData = data.toUpperCase();
        await writeFileAsync('output.txt', processedData);
        console.log('文件操作完成');
    } catch (error) {
        console.error('发生错误:', error.message);
    }
}

fileOperations();

在上述代码中,await readFileAsync('input.txt', 'utf8')读取文件内容,await writeFileAsync('output.txt', processedData)将处理后的数据写入新文件。通过async/await,文件操作的异步过程变得如同同步操作一样直观。

6.3 数据库操作

在后端开发中,与数据库进行交互也是常见的场景。许多数据库驱动都支持Promise,结合async/await可以更方便地进行数据库查询、插入、更新等操作。

以使用mysql2库操作MySQL数据库为例:

const mysql = require('mysql2');
const { promisify } = require('util');

const connection = mysql.createConnection({
    host: 'localhost',
    user: 'root',
    password: 'password',
    database: 'test'
});

const queryAsync = promisify(connection.query).bind(connection);

async function databaseOperations() {
    try {
        const [rows] = await queryAsync('SELECT * FROM users');
        console.log('查询结果:', rows);

        const insertResult = await queryAsync('INSERT INTO users (name, age) VALUES (?,?)', ['John', 30]);
        console.log('插入结果:', insertResult);
    } catch (error) {
        console.error('发生错误:', error.message);
    } finally {
        connection.end();
    }
}

databaseOperations();

在这个例子中,await queryAsync('SELECT * FROM users')执行SQL查询语句并等待结果,await queryAsync('INSERT INTO users (name, age) VALUES (?,?)', ['John', 30])执行插入操作。通过async/await,数据库操作的异步流程变得更加清晰,同时通过try...catch块可以处理可能出现的数据库操作错误。

7. async/await 的性能考量

7.1 执行效率

从执行效率的角度来看,async/await本身并不会提升或降低异步操作的实际执行速度。它主要是一种语法糖,用于使异步代码更易读和编写。在底层,async/await仍然依赖于Promise,其执行效率与Promise基本相同。

然而,在某些情况下,由于async/await使代码结构更清晰,可能会间接提高代码的性能。例如,在处理多个异步操作时,合理使用async/await结合Promise.all进行并发执行,可以充分利用系统资源,提高整体的执行效率。

7.2 内存消耗

async/await在内存消耗方面与Promise类似。由于async函数返回的是Promise对象,在处理大量异步任务时,如果不注意合理释放资源,可能会导致内存占用过高。例如,如果在async函数内部创建了大量的临时对象或闭包,并且这些对象在异步操作完成后没有及时释放,就可能会造成内存泄漏。

为了避免内存问题,在编写async函数时,应该尽量减少不必要的临时对象创建,及时释放不再使用的资源。同时,对于长时间运行的异步任务,可以考虑使用setTimeoutprocess.nextTick等方法来控制任务的执行节奏,避免一次性占用过多内存。

8. async/await 的兼容性与 polyfill

8.1 兼容性

async/await是ES2017的特性,现代的浏览器(如Chrome、Firefox、Safari等)和Node.js版本(8.0.0及以上)都对其提供了良好的支持。然而,在一些旧版本的浏览器或Node.js环境中,可能不支持async/await语法。

可以通过Can I Use网站来查看不同浏览器对async/await的支持情况。例如,在IE浏览器中,async/await是完全不支持的。

8.2 polyfill

为了在不支持async/await的环境中使用该特性,可以使用Babel等工具进行转码。Babel是一个JavaScript编译器,它可以将ES2015+的代码转换为ES5代码,以实现更好的兼容性。

首先,需要安装Babel相关的依赖包。在项目目录下执行以下命令:

npm install --save-dev @babel/core @babel/cli @babel/preset-env

然后,在项目根目录下创建一个.babelrc文件,配置如下:

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

最后,通过Babel命令对代码进行转码:

npx babel src -d dist

上述命令会将src目录下的ES2015+代码转换为ES5代码,并输出到dist目录中。这样,在不支持async/await的环境中也可以运行包含async/await语法的代码。

另外,一些第三方库如regenerator-runtime也可以作为async/await的polyfill。在使用Babel转码时,regenerator-runtime会自动被引入并处理async/await的转换。

9. 总结 async/await 的优势与不足

9.1 优势

  • 代码可读性强async/await使异步代码看起来像同步代码,极大地提高了代码的可读性和可维护性。开发人员可以像编写同步代码一样编写异步逻辑,减少了因异步操作带来的复杂性。
  • 错误处理方便:通过try...catch块可以统一处理异步操作中的错误,代码结构清晰,易于理解和调试。相比回调函数和Promise的错误处理方式,async/await的错误处理更加直观和便捷。
  • 与现有异步机制兼容async/await建立在Promise的基础之上,与现有的Promise-based API无缝兼容。这使得在使用现有的异步库和框架时,可以轻松地引入async/await语法,提升代码的质量。

9.2 不足

  • 兼容性问题:虽然现代浏览器和Node.js版本对async/await提供了良好的支持,但在一些旧版本的环境中,可能需要使用转码工具(如Babel)进行兼容性处理,增加了项目的配置和构建复杂度。
  • 性能无直接提升async/await本身并不会提升异步操作的实际执行效率,它主要是一种语法糖。在处理大量异步任务时,如果不注意合理利用并发和资源管理,可能会导致性能问题。同时,由于async/await依赖于Promise,Promise的一些性能特性(如微任务队列的处理机制)也会影响async/await的性能表现。

尽管async/await存在一些不足,但它在异步编程中的优势使其成为JavaScript开发中处理异步操作的首选方式。通过合理使用async/await,结合其他异步编程技术,可以编写出高效、可读且易于维护的JavaScript代码。在实际项目中,需要根据项目的具体需求和运行环境,权衡利弊,充分发挥async/await的优势,同时避免其潜在的问题。