解锁Promise:从链式调用到All、Race的异步编程魔法

235 阅读18分钟

Promise 初印象

在 JavaScript 的异步编程世界里,Promise 就像是一位优雅的指挥家,让复杂的异步操作变得有条不紊。它的出现,有效解决了传统回调函数带来的 “回调地狱” 问题,让代码变得更加简洁、易读和可维护。无论是处理网络请求、文件读取,还是定时器等异步任务,Promise 都能大显身手。

本文将深入探讨 Promise 的链式调用以及Promise.all和Promise.race的用法,通过实际案例和详细解析,帮助你更好地掌握 Promise,提升异步编程的能力。

一、Promise 链式调用:优雅的异步编排

(一)链式调用基础

在传统的异步编程中,当需要顺序执行多个异步操作时,我们常常会陷入回调函数嵌套的困境,也就是所谓的 “回调地狱”。例如,下面这段代码展示了使用回调函数实现的顺序异步操作:

asyncFunction1((result1) => {
    asyncFunction2(result1, (result2) => {
        asyncFunction3(result2, (result3) => {
            //...
        });
    });
});

随着异步操作的增多,代码的缩进会越来越深,可读性和维护性也会急剧下降。

而 Promise 的链式调用则为我们提供了一种更为优雅的解决方案。通过链式调用,我们可以将多个异步操作以一种更为直观、线性的方式进行编排,使代码的逻辑更加清晰。

(二)链式调用原理

Promise 的链式调用依赖于其内部的状态机和微任务队列机制。每个 Promise 对象都有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。初始状态为pending,当异步操作成功时,状态会转变为fulfilled,并调用resolve函数;当异步操作失败时,状态会转变为rejected,并调用reject函数。

在链式调用中,每次调用then方法都会返回一个新的 Promise 对象。这个新的 Promise 对象的状态和值取决于上一个 Promise 对象的状态以及then方法中回调函数的返回值。具体来说:

  • 如果上一个 Promise 对象的状态为fulfilled,并且then方法中的回调函数返回一个普通值,那么新的 Promise 对象会立即进入fulfilled状态,其值为回调函数的返回值。

  • 如果上一个 Promise 对象的状态为fulfilled,并且then方法中的回调函数返回一个 Promise 对象,那么新的 Promise 对象会等待这个返回的 Promise 对象的状态改变,然后根据其状态进入相应的状态。

  • 如果上一个 Promise 对象的状态为rejected,并且then方法中提供了错误处理回调函数,那么新的 Promise 对象会执行这个错误处理回调函数,并根据其返回值决定新 Promise 对象的状态。

  • 如果上一个 Promise 对象的状态为rejected,并且then方法中没有提供错误处理回调函数,那么新的 Promise 对象会直接进入rejected状态,其值为上一个 Promise 对象的错误信息。

此外,Promise 的回调函数是通过微任务队列来进行调度的。当 Promise 的状态发生改变时,其对应的回调函数会被放入微任务队列中,等待当前调用栈清空后执行。这就保证了 Promise 的回调函数会在当前同步代码执行完毕后,以异步的方式执行,从而避免阻塞主线程。

(三)链式调用示例与应用场景

下面通过一个具体的代码示例来展示 Promise 链式调用的用法。假设我们有三个异步任务,每个任务都依赖于前一个任务的结果,我们可以使用 Promise 链式调用来实现:

function asyncTask1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('异步任务1完成');
            resolve('结果1');
        }, 1000);
    });
}

function asyncTask2(result1) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('异步任务2完成,依赖结果:', result1);
            const result2 = result1 + '处理后';
            resolve(result2);
        }, 1000);
    });
}

function asyncTask3(result2) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('异步任务3完成,依赖结果:', result2);
            const result3 = result2 + '再次处理';
            resolve(result3);
        }, 1000);
    });
}

asyncTask1()
 .then(asyncTask2)
 .then(asyncTask3)
 .then((finalResult) => {
        console.log('最终结果:', finalResult);
    })
 .catch((error) => {
        console.error('发生错误:', error);
    });

在这个示例中,asyncTask1执行完成后,会将结果传递给asyncTask2,asyncTask2执行完成后,又会将新的结果传递给asyncTask3,最后asyncTask3的结果会在最后的then回调中进行处理。如果在任何一个环节出现错误,都会被catch捕获并处理。

这种链式调用的方式在实际开发中非常常见,例如在处理网络请求时,我们可能需要先发送一个登录请求获取用户的令牌,然后使用这个令牌发送其他请求获取用户信息、订单信息等。通过 Promise 链式调用,我们可以轻松地实现这些顺序异步操作,使代码更加简洁和易读。

二、Promise.all:汇聚异步力量

(一)Promise.all 基本用法

Promise.all是 Promise 的一个静态方法,它接收一个包含多个 Promise 对象的可迭代对象(如数组)作为参数,并返回一个新的 Promise。这个新的 Promise 会在所有传入的 Promise 都成功完成(即状态变为fulfilled)时才会成功,其结果是一个包含所有传入 Promise 成功结果的数组,且顺序与传入的 Promise 顺序一致。如果其中任何一个 Promise 失败(即状态变为rejected),Promise.all返回的 Promise 会立即失败,其失败原因就是第一个失败的 Promise 的原因。

Promise.all的基本语法如下:

Promise.all(iterable);

其中,iterable是一个可迭代对象,通常是一个包含多个 Promise 对象的数组。

下面是一个简单的示例,展示了如何使用Promise.all来并发执行多个异步任务:

function task1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务1完成');
            resolve('任务1结果');
        }, 1000);
    });
}

function task2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务2完成');
            resolve('任务2结果');
        }, 2000);
    });
}

function task3() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务3完成');
            resolve('任务3结果');
        }, 1500);
    });
}

Promise.all([task1(), task2(), task3()])
.then((results) => {
        console.log('所有任务完成,结果:', results);
    })
.catch((error) => {
        console.error('有任务失败:', error);
    });

在这个示例中,task1、task2和task3是三个异步任务,它们会并发执行。Promise.all会等待这三个任务都完成后,将它们的结果以数组的形式传递给then回调函数。如果其中任何一个任务失败,Promise.all返回的 Promise 会立即失败,catch回调函数会捕获到错误。

(二)Promise.all 工作原理

Promise.all的工作原理可以分为以下几个步骤:

  1. 输入验证:首先,Promise.all会检查传入的参数是否为可迭代对象。如果不是,会抛出一个TypeError错误。

  2. 创建新 Promise:Promise.all返回一个新的 Promise 对象,这个新 Promise 将用于汇总所有传入 Promise 的结果。

  3. 迭代处理:遍历传入的可迭代对象,对于每个元素,如果它是一个 Promise 对象,则将其加入到内部的处理队列中;如果不是 Promise 对象,则会将其转换为一个已成功的 Promise(通过Promise.resolve方法)。

  4. 结果收集:为每个 Promise 对象添加成功和失败的回调函数。当一个 Promise 成功时,将其结果按照顺序存储在一个数组中,并检查是否所有 Promise 都已成功。如果所有 Promise 都已成功,将存储结果的数组作为参数调用新 Promise 的resolve方法,从而使Promise.all返回的 Promise 进入成功状态。

  5. 快速失败:如果在处理过程中,任何一个 Promise 失败,Promise.all返回的 Promise 会立即进入失败状态,并将第一个失败的 Promise 的错误信息作为参数调用reject方法,不再继续等待其他 Promise 的结果。

(三)Promise.all 应用场景

Promise.all在实际开发中有很多应用场景,以下是一些常见的例子:

  • 并发请求多个 API:在前端开发中,经常需要从多个不同的 API 接口获取数据,然后将这些数据进行整合展示。使用Promise.all可以并发发送这些请求,大大提高数据获取的效率。例如:
const promise1 = fetch('https://api.example.com/data1');
const promise2 = fetch('https://api.example.com/data2');
const promise3 = fetch('https://api.example.com/data3');

Promise.all([promise1, promise2, promise3])
.then((responses) => {
        // 处理每个响应
        return Promise.all(responses.map(response => response.json()));
    })
.then((dataArrays) => {
        const [data1, data2, data3] = dataArrays;
        console.log(data1, data2, data3);
        // 在这里进行数据整合和展示
    })
.catch((error) => {
        console.error('请求失败:', error);
    });
  • 批量文件操作:在 Node.js 中,如果需要同时读取或写入多个文件,可以使用Promise.all来并发执行这些文件操作。例如,读取多个文件的内容:
const fs = require('fs').promises;

const fileNames = ['file1.txt', 'file2.txt', 'file3.txt'];
const readFilePromises = fileNames.map(fileName => fs.readFile(fileName, 'utf8'));

Promise.all(readFilePromises)
.then((contents) => {
        for (const content of contents) {
            console.log(content);
        }
    })
.catch((err) => {
        console.error('读取文件时出错', err);
    });
  • 等待多个异步任务完成后进行下一步操作:在一些复杂的业务逻辑中,可能需要多个异步任务都完成后才能进行下一步操作。例如,在一个电商应用中,需要同时获取用户信息、商品信息和购物车信息,然后根据这些信息生成订单。使用Promise.all可以确保所有这些信息都获取完成后再进行订单生成的操作。

三、Promise.race:异步竞赛,谁与争锋

(一)Promise.race 基本用法

Promise.race同样是 Promise 的一个静态方法,它的名字 “race” 很好地诠释了其功能,就像一场赛跑,多个 Promise 对象同时出发,Promise.race会返回最先完成(无论是成功还是失败)的那个 Promise 的结果。

Promise.race的基本语法如下:

Promise.race(iterable);

其中,iterable是一个可迭代对象,通常是一个包含多个 Promise 对象的数组。

例如,我们有两个异步任务,一个任务会在 1 秒后成功完成,另一个任务会在 2 秒后成功完成:

function taskA() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务A完成');
            resolve('任务A结果');
        }, 1000);
    });
}

function taskB() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务B完成');
            resolve('任务B结果');
        }, 2000);
    });
}

Promise.race([taskA(), taskB()])
.then((result) => {
        console.log('最先完成的任务结果:', result);
    })
.catch((error) => {
        console.error('有任务失败:', error);
    });

在这个例子中,taskA会先于taskB完成,所以Promise.race返回的 Promise 会在 1 秒后成功,其结果为任务A结果。

如果将taskB中的resolve改为reject,使其在 2 秒后失败:

function taskB() {
    return new Promise((_, reject) => {
        setTimeout(() => {
            console.log('任务B失败');
            reject('任务B失败原因');
        }, 2000);
    });
}

由于taskA仍然会在 1 秒后成功完成,所以Promise.race返回的 Promise 依然会在 1 秒后成功,结果为任务A结果。但如果taskA在 1 秒后失败,taskB在 2 秒后成功,那么Promise.race返回的 Promise 会在 1 秒后失败,原因是taskA的失败原因。

(二)Promise.race 工作原理

Promise.race的工作原理相对简单直接:

  1. 输入验证:和Promise.all一样,首先会检查传入的参数是否为可迭代对象。如果不是,会抛出一个TypeError错误。

  2. 创建新 Promise:返回一个新的 Promise 对象,这个新 Promise 将用于接收最先完成的 Promise 的结果。

  3. 迭代处理:遍历传入的可迭代对象,对于每个元素,如果它是一个 Promise 对象,则为其添加成功和失败的回调函数;如果不是 Promise 对象,则会将其转换为一个已成功的 Promise(通过Promise.resolve方法)。

  4. 结果处理:当其中任何一个 Promise 对象的状态变为fulfilled或rejected时,Promise.race返回的 Promise 对象也会立即变为相应的状态,并将该 Promise 的结果或错误作为自己的结果或错误返回。此时,其他未完成的 Promise 对象的结果将被忽略,不再进行后续处理。

(三)Promise.race 应用场景

Promise.race在实际开发中有着独特的应用场景:

  • 设置异步操作的超时时间:这是Promise.race非常常见的一个应用场景。通过将一个定时器 Promise 和实际的异步操作 Promise 放在一起进行 “赛跑”,如果定时器 Promise 先完成,就说明异步操作超时了。例如:
function asyncOperation() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('异步操作完成');
            resolve('异步操作结果');
        }, 3000);
    });
}

function timeout(duration) {
    return new Promise((_, reject) => {
        setTimeout(() => {
            console.log('超时');
            reject('操作超时');
        }, duration);
    });
}

Promise.race([asyncOperation(), timeout(2000)])
.then((result) => {
        console.log('操作结果:', result);
    })
.catch((error) => {
        console.error('错误:', error);
    });

在这个例子中,asyncOperation是实际的异步操作,timeout(2000)是一个 2 秒后触发的定时器 Promise。如果asyncOperation在 2 秒内没有完成,timeout这个 Promise 会先完成并进入rejected状态,从而导致Promise.race返回的 Promise 也进入rejected状态,提示操作超时。

  • 从多个数据源获取数据:在某些情况下,我们可能有多个数据源可以获取相同的数据,为了提高效率,可以同时从多个数据源发起请求,然后使用Promise.race获取最先返回的数据。例如,在一个电商应用中,可能有主服务器和备用服务器,我们可以同时向两个服务器发送商品信息请求,使用Promise.race获取最先返回的结果,这样可以减少用户等待时间,提高用户体验。
function fetchFromPrimaryServer() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('从主服务器获取数据');
            resolve('主服务器数据');
        }, 3000);
    });
}

function fetchFromBackupServer() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('从备用服务器获取数据');
            resolve('备用服务器数据');
        }, 1000);
    });
}

Promise.race([fetchFromPrimaryServer(), fetchFromBackupServer()])
.then((data) => {
        console.log('获取到的数据:', data);
    })
.catch((error) => {
        console.error('获取数据失败:', error);
    });

在这个例子中,fetchFromBackupServer会先于fetchFromPrimaryServer完成,所以Promise.race返回的 Promise 会在 1 秒后成功,其结果为备用服务器数据。

四、综合运用与最佳实践

(一)链式调用与 All/Race 的结合

在实际项目中,链式调用、Promise.all和Promise.race常常需要结合使用,以实现复杂的异步逻辑。

比如,在一个电商应用中,我们可能需要先获取用户的购物车信息,然后根据购物车中的商品 ID 并发获取每个商品的详细信息,最后将这些信息进行汇总展示。这里就可以结合链式调用和Promise.all来实现:

function getCart() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('获取购物车信息');
            const cart = [1, 2, 3]; // 假设购物车中有三个商品的ID
            resolve(cart);
        }, 1000);
    });
}

function getProductDetails(productId) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log(`获取商品 ${productId} 的详细信息`);
            const productDetails = { id: productId, name: `商品${productId}` };
            resolve(productDetails);
        }, 1500);
    });
}

getCart()
  .then((cart) => {
        const productPromises = cart.map((productId) => getProductDetails(productId));
        return Promise.all(productPromises);
    })
  .then((productDetailsList) => {
        console.log('所有商品的详细信息:', productDetailsList);
        // 在这里进行商品信息的汇总展示等后续操作
    })
  .catch((error) => {
        console.error('发生错误:', error);
    });

在这个例子中,首先通过getCart获取购物车信息,然后使用map方法将每个商品 ID 转化为获取商品详细信息的 Promise,并通过Promise.all并发执行这些 Promise,最后在then回调中处理所有商品的详细信息。

再比如,在一个实时数据监控系统中,我们可能需要从多个数据源获取数据,但只关心最先返回的数据。这时可以结合链式调用和Promise.race来实现:

function fetchDataFromSource1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('从数据源1获取数据');
            const data = '数据源1的数据';
            resolve(data);
        }, 3000);
    });
}

function fetchDataFromSource2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('从数据源2获取数据');
            const data = '数据源2的数据';
            resolve(data);
        }, 1000);
    });
}

function processData(data) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('处理数据:', data);
            const processedData = data + '处理后';
            resolve(processedData);
        }, 1000);
    });
}

Promise.race([fetchDataFromSource1(), fetchDataFromSource2()])
  .then((data) => processData(data))
  .then((processedData) => {
        console.log('最终处理结果:', processedData);
    })
  .catch((error) => {
        console.error('发生错误:', error);
    });

在这个例子中,Promise.race会返回最先获取到数据的 Promise 结果,然后通过链式调用将这个数据传递给processData进行处理。

(二)错误处理与异常捕获

在使用 Promise 时,进行错误处理是非常重要的,否则未捕获的错误可能会导致程序崩溃或出现难以调试的问题。

对于单个 Promise,可以通过catch方法来捕获错误。例如:

function asyncTask() {
    return new Promise((_, reject) => {
        setTimeout(() => {
            reject(new Error('异步任务失败'));
        }, 1000);
    });
}

asyncTask()
  .then((result) => {
        console.log('任务结果:', result);
    })
  .catch((error) => {
        console.error('捕获到错误:', error.message);
    });

在这个例子中,asyncTask返回的 Promise 会在 1 秒后失败,catch方法会捕获到这个错误并进行处理。

在链式调用中,错误会自动向下传递,直到被catch捕获。例如:

function task1() {
    return new Promise((_, reject) => {
        setTimeout(() => {
            reject(new Error('任务1失败'));
        }, 1000);
    });
}

function task2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('任务2完成');
            resolve('任务2结果');
        }, 1000);
    });
}

task1()
  .then(() => task2())
  .then((result) => {
        console.log('最终结果:', result);
    })
  .catch((error) => {
        console.error('捕获到错误:', error.message);
    });

在这个例子中,task1失败后,错误会传递到catch方法,task2不会被执行。

当使用Promise.all和Promise.race时,错误处理也很关键。Promise.all只要有一个 Promise 失败,就会立即失败,并将第一个失败的原因传递给catch;Promise.race则会返回第一个完成(无论成功还是失败)的 Promise 的结果,所以需要在then和catch中分别处理成功和失败的情况。例如:

const promise1 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('结果1');
    }, 1000);
});

const promise2 = new Promise((_, reject) => {
    setTimeout(() => {
        reject(new Error('任务2失败'));
    }, 1500);
});

Promise.all([promise1, promise2])
  .then((results) => {
        console.log('所有任务结果:', results);
    })
  .catch((error) => {
        console.error('Promise.all 捕获到错误:', error.message);
    });

Promise.race([promise1, promise2])
  .then((result) => {
        console.log('最先完成的任务结果:', result);
    })
  .catch((error) => {
        console.error('Promise.race 捕获到错误:', error.message);
    });

在Promise.all的例子中,由于promise2失败,Promise.all会立即失败并捕获到错误;在Promise.race的例子中,promise1会先完成,所以会输出最先完成的任务结果: 结果1,如果promise1也失败,那么catch会捕获到错误。

此外,在异步函数中使用async/await时,可以使用try...catch来捕获错误,这也是一种非常常见且直观的错误处理方式。例如:

async function fetchData() {
    try {
        const response = await fetch('https://api.example.com/data');
        if (!response.ok) {
            throw new Error('网络请求失败');
        }
        const data = await response.json();
        console.log('获取到的数据:', data);
    } catch (error) {
        console.error('捕获到错误:', error.message);
    }
}

fetchData();

在这个例子中,await会等待fetch请求完成,如果请求失败或响应状态码不是2xx,就会抛出错误并被catch捕获。

(三)性能优化与注意事项

在使用 Promise 时,以下是一些性能优化建议和需要注意的事项:

  • 避免不必要的 Promise 创建:在循环中创建大量的 Promise 对象可能会导致性能问题和内存占用过高。如果可以,尽量将多个异步操作合并为一个 Promise,或者使用Promise.all来处理多个相关的 Promise。例如,不要在循环中每次都创建新的 Promise 进行网络请求,而是将所有请求的 Promise 放入数组中,通过Promise.all一次性处理。
// 不好的做法
for (let i = 0; i < 1000; i++) {
    new Promise((resolve) => {
        setTimeout(() => {
            resolve(i);
        }, 100);
    }).then((result) => {
        console.log(result);
    });
}

// 好的做法
const promises = [];
for (let i = 0; i < 1000; i++) {
    promises.push(new Promise((resolve) => {
        setTimeout(() => {
            resolve(i);
        }, 100);
    }));
}

Promise.all(promises).then((results) => {
    results.forEach((result) => {
        console.log(result);
    });
});
  • 合理使用并发和并行:虽然Promise.all和Promise.race可以并发执行多个 Promise,但也要注意不要过度并发,以免对服务器或其他资源造成过大压力。在处理大量并发请求时,可以考虑使用限制并发数量的方法,比如使用async/await结合队列来实现有限并发。

  • 注意微任务和宏任务的执行顺序:Promise 的回调函数是通过微任务队列来执行的,这意味着它们会在当前调用栈清空后,下一个宏任务执行之前执行。理解微任务和宏任务的执行顺序对于正确处理异步操作和避免潜在的问题非常重要。例如,以下代码展示了微任务和宏任务的执行顺序:

console.log('开始');
Promise.resolve().then(() => {
    console.log('Promise 微任务');
});
setTimeout(() => {
    console.log('setTimeout 宏任务');
}, 0);
console.log('结束');
// 输出顺序:开始 -> 结束 -> Promise 微任务 -> setTimeout 宏任务
  • 避免 Promise 链过长:虽然 Promise 链式调用很方便,但如果链条过长,可能会导致代码难以维护和调试。可以考虑将复杂的异步逻辑拆分成多个独立的函数,每个函数返回一个 Promise,然后再通过链式调用组合起来,这样可以提高代码的可读性和可维护性。

  • 注意内存泄漏:如果在 Promise 中使用了定时器、事件监听器等资源,要确保在 Promise 完成或失败后及时清理这些资源,以避免内存泄漏。例如,在使用setTimeout时,要确保在 Promise 处理完成后清除定时器。

function asyncTask() {
    return new Promise((resolve, reject) => {
        const timer = setTimeout(() => {
            resolve('任务完成');
        }, 1000);
        // 可以在Promise完成或失败时清除定时器
        return () => clearTimeout(timer);
    });
}

const cancelTask = asyncTask();
// 当不需要这个任务时,可以调用取消函数
// cancelTask(); 

总结与展望

通过本文的学习,我们深入了解了 Promise 链式调用、Promise.all和Promise.race的用法和原理。Promise 链式调用让异步操作的编排更加优雅,Promise.all实现了多个异步任务的并发处理和结果汇总,Promise.race则用于获取最先完成的异步任务结果。

在实际项目中,灵活运用这些知识,可以显著提升异步编程的效率和代码质量。无论是前端开发中的网络请求处理,还是后端开发中的文件操作、数据库查询等异步任务,Promise 都能成为我们的得力助手。

希望读者在今后的开发中,不断实践和探索 Promise 的更多用法,在异步编程的世界里游刃有余,创造出更加高效、稳定的应用程序。