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

JavaScript异步编程:掌握Promise和Async/Await

2023-01-242.1k 阅读

JavaScript 异步编程的背景

在 JavaScript 中,由于其单线程的特性,执行环境一次只能执行一个任务。如果遇到一个耗时较长的操作,比如网络请求、读取大文件等,同步执行会导致主线程阻塞,使得页面失去响应,用户体验变差。为了解决这个问题,异步编程应运而生。异步操作允许 JavaScript 在等待某个操作完成的同时,继续执行其他代码,不会阻塞主线程。

异步编程的早期方案

  1. 回调函数(Callback) 回调函数是 JavaScript 中实现异步操作最基本的方式。以读取文件为例,在 Node.js 中,fs.readFile 函数就是一个异步操作,它接受一个回调函数作为参数。
const fs = require('fs');
fs.readFile('example.txt', 'utf8', function (err, data) {
    if (err) {
        console.error(err);
        return;
    }
    console.log(data);
});

在上述代码中,fs.readFile 开始读取文件,在读取过程中,JavaScript 继续执行其他代码(如果有的话)。当文件读取完成后,会调用传入的回调函数,并将错误(如果有)和读取到的数据作为参数传递进去。

然而,回调函数存在一些问题,其中最突出的就是回调地狱(Callback Hell)。当多个异步操作相互依赖,层层嵌套回调函数时,代码会变得难以阅读和维护。

fs.readFile('file1.txt', 'utf8', function (err, data1) {
    if (err) {
        console.error(err);
        return;
    }
    fs.readFile('file2.txt', 'utf8', function (err, data2) {
        if (err) {
            console.error(err);
            return;
        }
        fs.readFile('file3.txt', 'utf8', function (err, data3) {
            if (err) {
                console.error(err);
                return;
            }
            console.log(data1 + data2 + data3);
        });
    });
});

这种层层嵌套的代码结构,被称为回调地狱,它极大地降低了代码的可读性和可维护性。

  1. 事件监听(Event Listener) 另一种早期的异步方案是事件监听。通过为特定事件绑定监听器,当事件触发时,相应的回调函数会被执行。例如,在浏览器中监听 DOMContentLoaded 事件,当页面的 DOM 加载完成后,会触发该事件。
<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title>Event Listener Example</title>
</head>

<body>
    <script>
        document.addEventListener('DOMContentLoaded', function () {
            console.log('Page DOM is fully loaded');
        });
    </script>
</body>

</html>

事件监听的优点是代码结构相对清晰,适合处理多个独立的异步事件。但对于复杂的异步流程控制,单纯的事件监听也会显得力不从心。

Promise 的出现

  1. Promise 的概念 Promise 是 JavaScript 对异步操作的一种标准化解决方案,它代表了一个异步操作的最终完成(或失败)及其结果值。Promise 有三种状态:

    • Pending(进行中):初始状态,既没有被兑现,也没有被拒绝。
    • Fulfilled(已兑现):意味着操作成功完成,此时 Promise 有一个 resolved 值。
    • Rejected(已拒绝):意味着操作失败,此时 Promise 有一个 reason(错误信息)。 一旦 Promise 的状态从 Pending 变为 FulfilledRejected,就不会再改变。
  2. 创建 Promise 可以通过 new Promise() 构造函数来创建一个 Promise 对象。构造函数接受一个执行器函数作为参数,执行器函数有两个参数,分别是 resolvereject

const myPromise = new Promise((resolve, reject) => {
    // 模拟异步操作
    setTimeout(() => {
        const success = true;
        if (success) {
            resolve('Operation successful');
        } else {
            reject('Operation failed');
        }
    }, 1000);
});

在上述代码中,通过 setTimeout 模拟了一个异步操作,1 秒后根据 success 的值决定是调用 resolve 还是 reject

  1. 处理 Promise 可以使用 .then() 方法来处理 Promise 被 resolve 的情况,使用 .catch() 方法来处理 Promise 被 reject 的情况。
myPromise
   .then((result) => {
        console.log(result); // 输出 'Operation successful'
    })
   .catch((error) => {
        console.error(error);
    });

.then() 方法接受一个回调函数作为参数,该回调函数会在 Promise 被 resolve 时被调用,并且会将 resolve 的值作为参数传递给这个回调函数。.catch() 方法同样接受一个回调函数,在 Promise 被 reject 时被调用,reject 的值(错误信息)会作为参数传递给这个回调函数。

  1. 链式调用 Promise 的一个强大特性是可以进行链式调用。当 .then() 方法中的回调函数返回一个新的 Promise 时,就可以继续在这个新的 Promise 上调用 .then() 方法。
const promise1 = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(1);
    }, 1000);
});

const promise2 = promise1
   .then((value) => {
        console.log(value); // 输出 1
        return value * 2;
    })
   .then((newValue) => {
        console.log(newValue); // 输出 2
        return newValue + 3;
    })
   .then((finalValue) => {
        console.log(finalValue); // 输出 5
    });

在上述代码中,promise1resolve 后,第一个 .then() 方法中的回调函数将 resolve 的值乘以 2 并返回一个新的值,这个新值会作为第二个 .then() 方法回调函数的参数,依此类推。

  1. Promise.all() Promise.all() 方法用于将多个 Promise 实例包装成一个新的 Promise 实例。新的 Promise 实例在所有传入的 Promise 都被 resolve 时才会被 resolve,只要有一个 Promise 被 reject,新的 Promise 就会被 reject
const promiseA = new Promise((resolve) => {
    setTimeout(() => {
        resolve('A');
    }, 1000);
});

const promiseB = new Promise((resolve) => {
    setTimeout(() => {
        resolve('B');
    }, 1500);
});

Promise.all([promiseA, promiseB])
   .then((results) => {
        console.log(results); // 输出 ['A', 'B']
    })
   .catch((error) => {
        console.error(error);
    });

在上述代码中,Promise.all() 接受一个包含 promiseApromiseB 的数组,当 promiseApromiseB 都被 resolve 后,新的 Promise 被 resolveresolve 的值是一个包含 promiseApromiseB 各自 resolve 值的数组。

  1. Promise.race() Promise.race() 方法同样接受一个 Promise 数组,它返回一个新的 Promise。这个新的 Promise 会在数组中任何一个 Promise 被 resolvereject 时,立刻被 resolvereject,其 resolvereject 的值就是第一个被 resolvereject 的 Promise 的值。
const promiseC = new Promise((resolve) => {
    setTimeout(() => {
        resolve('C');
    }, 1500);
});

const promiseD = new Promise((resolve) => {
    setTimeout(() => {
        resolve('D');
    }, 1000);
});

Promise.race([promiseC, promiseD])
   .then((result) => {
        console.log(result); // 输出 'D'
    })
   .catch((error) => {
        console.error(error);
    });

在上述代码中,promiseD 先于 promiseCresolve,所以 Promise.race() 返回的新 Promise 会以 promiseDresolve 值被 resolve

Async/Await 的神奇之处

  1. Async 函数的定义 async 函数是一种异步函数,它返回一个 Promise 对象。async 函数内部可以使用 await 关键字来暂停函数的执行,等待一个 Promise 被 resolvereject
async function asyncFunction() {
    return 'Hello, async!';
}

asyncFunction()
   .then((result) => {
        console.log(result); // 输出 'Hello, async!'
    });

在上述代码中,asyncFunction 是一个 async 函数,它返回一个被 resolve'Hello, async!' 的 Promise。

  1. Await 关键字的使用 await 只能在 async 函数内部使用,它用于等待一个 Promise 完成。当 await 一个 Promise 时,async 函数会暂停执行,直到这个 Promise 被 resolvereject,然后 await 表达式会返回这个 Promise 的 resolve 值(如果被 resolve)或抛出 reject 的值(如果被 reject)。
function delay(ms) {
    return new Promise((resolve) => {
        setTimeout(resolve, ms);
    });
}

async function main() {
    console.log('Start');
    await delay(1000);
    console.log('After delay');
}

main();

在上述代码中,delay 函数返回一个 Promise,在 main 函数中,await delay(1000) 会暂停 main 函数的执行,直到 delay 返回的 Promise 被 resolve,1 秒后,await 表达式返回 resolve 的值(这里 resolve 没有传递值,所以返回 undefined),main 函数继续执行并输出 'After delay'

  1. 处理错误async 函数中,可以使用传统的 try...catch 块来处理 await 的 Promise 被 reject 的情况。
async function asyncError() {
    try {
        await Promise.reject('Error occurred');
    } catch (error) {
        console.error(error); // 输出 'Error occurred'
    }
}

asyncError();

在上述代码中,await 一个被 reject 的 Promise,try...catch 块捕获到这个错误并输出错误信息。

  1. 与 Promise 链式调用的对比 使用 async/await 可以使异步代码看起来更像同步代码,大大提高了代码的可读性。对比之前用 Promise 链式调用实现的多个异步操作,用 async/await 改写如下:
const fs = require('fs');
const util = require('util');

const readFileAsync = util.promisify(fs.readFile);

async function readFiles() {
    try {
        const data1 = await readFileAsync('file1.txt', 'utf8');
        const data2 = await readFileAsync('file2.txt', 'utf8');
        const data3 = await readFileAsync('file3.txt', 'utf8');
        console.log(data1 + data2 + data3);
    } catch (err) {
        console.error(err);
    }
}

readFiles();

在上述代码中,util.promisifyfs.readFile 这个基于回调的异步函数转换为返回 Promise 的函数。readFiles 函数中使用 await 依次等待每个文件读取操作完成,代码结构清晰,避免了回调地狱和复杂的 Promise 链式调用。

  1. 结合 Promise.all() async/awaitPromise.all() 结合可以方便地处理多个并行的异步操作。
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function parallelTasks() {
    const task1 = delay(1000);
    const task2 = delay(1500);
    const task3 = delay(2000);

    const results = await Promise.all([task1, task2, task3]);
    console.log(results);
}

parallelTasks();

在上述代码中,task1task2task3 是三个并行的异步任务,Promise.all() 等待所有任务完成,await 等待 Promise.all() 返回的 Promise 被 resolveresolve 的值是一个包含所有任务 resolve 值的数组(这里因为 delay 函数的 resolve 没有传递值,所以数组元素都是 undefined)。

深入理解 Promise 和 Async/Await 的原理

  1. Promise 的内部原理 Promise 的实现基于一种状态机的概念。当创建一个 Promise 时,它处于 Pending 状态。执行器函数立即执行,在执行器函数内部,通过调用 resolvereject 来改变 Promise 的状态。一旦状态改变,就不会再变。

.then().catch() 方法实际上是在 Promise 状态改变时注册回调函数。当 Promise 从 Pending 变为 Fulfilled 时,会调用 .then() 注册的回调函数;当变为 Rejected 时,会调用 .catch() 注册的回调函数。

在链式调用中,每个 .then() 方法返回一个新的 Promise。如果 .then() 回调函数返回一个值,新的 Promise 会被 resolve 为这个值;如果返回一个 Promise,新的 Promise 会“跟随”这个返回的 Promise 的状态。

  1. Async/Await 的底层机制 async 函数返回的 Promise 是由 JavaScript 引擎自动生成和管理的。当 async 函数遇到 await 时,它会暂停执行,并将 await 后的 Promise 返回给 JavaScript 引擎。引擎会继续执行其他代码,直到 await 的 Promise 状态改变。

await 的 Promise 被 resolve 时,await 表达式会返回 resolve 的值,async 函数继续执行。如果 Promise 被 rejectasync 函数会抛出这个错误,可以通过 try...catch 块捕获。

从本质上讲,async/await 是对 Promise 的一种语法糖,它基于生成器(Generator)和迭代器(Iterator)实现。生成器函数可以暂停和恢复执行,async/await 利用这一特性,通过自动迭代生成器,实现了看起来像同步的异步代码结构。

在实际项目中的应用场景

  1. 网络请求 在前端开发中,经常需要进行网络请求,如获取 API 数据。使用 async/await 结合 fetch API 可以使代码简洁明了。
async function getData() {
    try {
        const response = await fetch('https://example.com/api/data');
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error(error);
    }
}

getData();

在上述代码中,fetch 发起网络请求返回一个 Promise,await 等待响应返回,然后将响应数据解析为 JSON 格式。

  1. 文件操作(Node.js) 在 Node.js 中进行文件操作时,async/await 可以使代码更易读。例如读取多个文件并合并内容。
const fs = require('fs');
const util = require('util');

const readFileAsync = util.promisify(fs.readFile);

async function mergeFiles() {
    try {
        const file1 = await readFileAsync('file1.txt', 'utf8');
        const file2 = await readFileAsync('file2.txt', 'utf8');
        const merged = file1 + file2;
        await util.promisify(fs.writeFile)('merged.txt', merged);
        console.log('Files merged successfully');
    } catch (err) {
        console.error(err);
    }
}

mergeFiles();

在上述代码中,先读取两个文件的内容,然后将它们合并并写入一个新文件。

  1. 并发控制 在处理多个异步任务时,有时需要控制并发数量,避免资源耗尽。可以结合 Promiseasync/await 实现简单的并发控制。
function task(id) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log(`Task ${id} completed`);
            resolve(id);
        }, Math.floor(Math.random() * 2000));
    });
}

async function concurrentTasks() {
    const taskCount = 10;
    const concurrency = 3;
    let completed = 0;
    const results = [];
    const queue = [];

    function runTask() {
        if (queue.length === 0 && completed === taskCount) {
            console.log(results);
            return;
        }
        while (queue.length < concurrency && completed < taskCount) {
            const id = completed + 1;
            const promise = task(id);
            queue.push(promise);
            completed++;
            promise.then((result) => {
                results.push(result);
                queue.shift();
                runTask();
            });
        }
    }

    runTask();
}

concurrentTasks();

在上述代码中,task 模拟一个异步任务,concurrentTasks 函数通过控制任务队列的长度来实现并发控制,最多同时执行 concurrency 个任务。

性能考虑

  1. Promise 与 Async/Await 的性能对比 从性能角度来看,async/await 本身并不会带来额外的性能开销,因为它本质上是基于 Promise 的语法糖。在执行效率上,两者基本相同。然而,代码结构的不同可能会影响到性能调优的难度。

Promise 的链式调用在某些情况下可能会导致性能问题,尤其是当链式调用过长且每个 .then() 回调函数都有复杂计算时。因为每个 .then() 都会返回一个新的 Promise,增加了内存开销和执行时间。

async/await 由于代码结构更接近同步代码,在性能调优时更容易分析和理解,开发人员可以更直观地对每个异步操作进行优化。

  1. 异步操作的性能优化 在进行异步编程时,有几个方面可以进行性能优化:
    • 减少不必要的异步操作:尽量合并一些可以同步执行的操作,避免过多的异步调用。
    • 合理控制并发数量:如前面提到的并发控制示例,避免同时发起过多的异步请求,防止资源耗尽和网络拥塞。
    • 优化异步操作本身:例如在网络请求中,合理设置缓存,减少不必要的重复请求;在文件操作中,使用高效的文件读写方式。

常见问题及解决方案

  1. 未处理的 Promise 拒绝 在使用 Promise 时,如果没有为被 reject 的 Promise 注册 .catch() 处理函数,控制台会抛出一个未处理的 Promise 拒绝错误。这可能导致应用程序出现难以排查的问题。
new Promise((resolve, reject) => {
    reject('Unhandled rejection');
});
// 控制台会输出 UnhandledPromiseRejectionWarning

解决方案是始终为 Promise 注册 .catch() 处理函数,或者使用全局的 process.on('unhandledRejection',...)(在 Node.js 中)来捕获未处理的 Promise 拒绝。

new Promise((resolve, reject) => {
    reject('Unhandled rejection');
})
   .catch((error) => {
        console.error('Handled rejection:', error);
    });

async 函数中,使用 try...catch 块可以有效地捕获 await 的 Promise 被 reject 的情况,避免未处理的拒绝。

  1. Promise 内存泄漏 当 Promise 链过长且每个 .then() 回调函数都持有对外部大对象的引用时,可能会导致内存泄漏。因为只要 Promise 链没有结束,这些引用就不会被垃圾回收机制回收。
function createLongChain() {
    const largeObject = { /* 一个很大的对象 */ };
    let promise = Promise.resolve();
    for (let i = 0; i < 10000; i++) {
        promise = promise.then(() => {
            // 这里持有 largeObject 的引用
            return largeObject;
        });
    }
    return promise;
}

解决方案是尽量避免在 .then() 回调函数中持有不必要的大对象引用,或者在适当的时候手动释放引用。

  1. Async/Await 中的同步错误async 函数中,如果出现同步错误,try...catch 块无法捕获,因为这些错误不是由 await 的 Promise 抛出的。
async function syncError() {
    try {
        throw new Error('Sync error');
        await Promise.resolve();
    } catch (error) {
        console.error('This will not catch the sync error');
    }
}

syncError();
// 会抛出错误,try...catch 块无法捕获

解决方案是确保在 async 函数中,所有可能抛出同步错误的代码都在 try...catch 块内。

async function fixedSyncError() {
    try {
        try {
            throw new Error('Sync error');
        } catch (syncError) {
            console.error('Caught sync error:', syncError);
        }
        await Promise.resolve();
    } catch (error) {
        console.error('Caught async error:', error);
    }
}

fixedSyncError();

通过深入理解和掌握 Promise 和 Async/Await,开发人员可以更高效地编写异步代码,提高应用程序的性能和可维护性,避免常见的问题,在 JavaScript 异步编程领域游刃有余。无论是前端开发还是后端开发,这两种技术都是处理异步操作的核心工具。