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

Promise对象与异步操作的最佳实践

2024-05-012.2k 阅读

JavaScript 中的异步操作

在深入探讨 Promise 对象之前,我们先来了解一下 JavaScript 中的异步操作。JavaScript 是单线程语言,这意味着它在同一时间只能执行一个任务。然而,在实际应用中,我们经常会遇到一些操作可能需要较长时间才能完成,比如网络请求、读取文件等。如果这些操作是同步执行的,那么在操作完成之前,JavaScript 线程会被阻塞,导致页面无法响应用户的交互。

为了解决这个问题,JavaScript 引入了异步操作。异步操作允许 JavaScript 在执行耗时操作时不会阻塞主线程,从而提高应用的响应性。常见的异步操作包括:

  1. 回调函数(Callbacks):这是 JavaScript 中最基本的异步处理方式。例如,在读取文件时,我们可以传递一个回调函数,当文件读取完成后,这个回调函数会被调用。
const fs = require('fs');
fs.readFile('example.txt', 'utf8', (err, data) => {
    if (err) {
        console.error(err);
    } else {
        console.log(data);
    }
});

这种方式虽然简单,但当有多个异步操作相互依赖时,就会出现回调地狱(Callback Hell)的问题。比如,在一个网络请求的回调中再发起另一个网络请求,然后在这个新的回调中再发起一个请求,代码会变得非常难以阅读和维护。

getData((data1) => {
    processData1(data1, (data2) => {
        processData2(data2, (data3) => {
            processData3(data3, (result) => {
                console.log(result);
            });
        });
    });
});
  1. 事件监听(Event Listeners):许多 JavaScript 环境提供了事件监听机制,比如 DOM 事件。我们可以为某个元素添加一个事件监听器,当事件触发时,相应的回调函数会被执行。
const button = document.getElementById('myButton');
button.addEventListener('click', () => {
    console.log('Button clicked!');
});
  1. 定时器(Timers)setTimeoutsetInterval 是 JavaScript 提供的定时器函数。setTimeout 会在指定的延迟时间后执行一次回调函数,而 setInterval 会每隔指定的时间间隔重复执行回调函数。
setTimeout(() => {
    console.log('This message will be printed after 1 second');
}, 1000);

let count = 0;
const intervalId = setInterval(() => {
    console.log(`Count: ${count++}`);
    if (count === 5) {
        clearInterval(intervalId);
    }
}, 1000);

Promise 对象基础

Promise 是 JavaScript 中处理异步操作的一种更优雅的方式。它代表了一个异步操作的最终完成(或失败)及其结果值。Promise 有三种状态:

  1. Pending(进行中):初始状态,既不是已成功,也不是已失败。
  2. Fulfilled(已成功):意味着操作成功完成,Promise 会有一个 resolved 值。
  3. Rejected(已失败):意味着操作失败,Promise 会有一个 rejection 原因。

一旦 Promise 的状态从 Pending 变为 Fulfilled 或 Rejected,它就不会再改变,这被称为 Promise 的“已敲定(Settled)”状态。

创建 Promise

我们可以使用 new Promise() 构造函数来创建一个 Promise 对象。构造函数接受一个执行器函数作为参数,这个执行器函数会立即执行。执行器函数接受两个参数:resolvereject。当异步操作成功时,调用 resolve 并传入结果值;当异步操作失败时,调用 reject 并传入失败原因。

const myPromise = new Promise((resolve, reject) => {
    setTimeout(() => {
        const success = true;
        if (success) {
            resolve('Operation successful');
        } else {
            reject('Operation failed');
        }
    }, 1000);
});

处理 Promise

Promise 对象提供了 then()catch()finally() 方法来处理异步操作的结果。

  1. then() 方法then() 方法接受两个回调函数作为参数,第一个回调函数在 Promise 被 resolved 时调用,第二个回调函数在 Promise 被 rejected 时调用(第二个回调函数是可选的)。
myPromise.then((value) => {
    console.log(value); // 'Operation successful'
}, (error) => {
    console.error(error);
});

我们可以链式调用 then() 方法,每个 then() 方法返回一个新的 Promise,这样可以在异步操作成功完成后继续执行其他异步操作。

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

promise1
   .then((value) => {
        console.log(value); // 1
        return value + 1;
    })
   .then((newValue) => {
        console.log(newValue); // 2
        return newValue * 2;
    })
   .then((finalValue) => {
        console.log(finalValue); // 4
    });
  1. catch() 方法catch() 方法用于捕获 Promise 链中任何一个环节抛出的错误。它相当于 then(null, onRejected) 的语法糖。
const promise2 = new Promise((resolve, reject) => {
    setTimeout(() => {
        reject('Error occurred');
    }, 1000);
});

promise2
   .then((value) => {
        console.log(value);
    })
   .catch((error) => {
        console.error(error); // 'Error occurred'
    });
  1. finally() 方法finally() 方法会在 Promise 敲定(无论是 resolved 还是 rejected)后执行,并且不接受任何参数。它通常用于执行一些清理操作,比如关闭网络连接、释放资源等。
const promise3 = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('Success');
    }, 1000);
});

promise3
   .then((value) => {
        console.log(value); // 'Success'
    })
   .catch((error) => {
        console.error(error);
    })
   .finally(() => {
        console.log('This will always be printed');
    });

Promise 对象的高级应用

Promise.all()

Promise.all() 方法接受一个 Promise 数组作为参数,并返回一个新的 Promise。这个新的 Promise 只有在所有传入的 Promise 都被 resolved 时才会被 resolved,并且它的 resolved 值是一个包含所有传入 Promise resolved 值的数组。如果其中任何一个 Promise 被 rejected,那么新的 Promise 会立即被 rejected,并且 rejection 的原因就是第一个被 rejected 的 Promise 的原因。

const promise4 = new Promise((resolve) => {
    setTimeout(() => {
        resolve(1);
    }, 1000);
});

const promise5 = new Promise((resolve) => {
    setTimeout(() => {
        resolve(2);
    }, 1500);
});

const promise6 = new Promise((resolve) => {
    setTimeout(() => {
        resolve(3);
    }, 2000);
});

Promise.all([promise4, promise5, promise6])
   .then((values) => {
        console.log(values); // [1, 2, 3]
    })
   .catch((error) => {
        console.error(error);
    });

Promise.race()

Promise.race() 方法同样接受一个 Promise 数组作为参数,并返回一个新的 Promise。这个新的 Promise 会在第一个传入的 Promise 被 resolved 或 rejected 时就被敲定,并且它的状态和值与第一个被敲定的 Promise 相同。

const promise7 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 7 resolved');
    }, 2000);
});

const promise8 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 8 resolved');
    }, 1000);
});

Promise.race([promise7, promise8])
   .then((value) => {
        console.log(value); // 'Promise 8 resolved'
    })
   .catch((error) => {
        console.error(error);
    });

Promise.allSettled()

Promise.allSettled() 方法接受一个 Promise 数组作为参数,并返回一个新的 Promise。这个新的 Promise 在所有传入的 Promise 都被敲定(无论是 resolved 还是 rejected)后才会被 resolved,它的 resolved 值是一个包含每个 Promise 结果的对象数组,每个对象有 status('fulfilled' 或'rejected')和 value(resolved 值或 rejection 原因)属性。

const promise9 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 9 resolved');
    }, 1000);
});

const promise10 = new Promise((_, reject) => {
    setTimeout(() => {
        reject('Promise 10 rejected');
    }, 1500);
});

Promise.allSettled([promise9, promise10])
   .then((results) => {
        console.log(results);
        // [
        //     { status: 'fulfilled', value: 'Promise 9 resolved' },
        //     { status:'rejected', reason: 'Promise 10 rejected' }
        // ]
    })
   .catch((error) => {
        console.error(error);
    });

Promise.any()

Promise.any() 方法接受一个 Promise 数组作为参数,并返回一个新的 Promise。这个新的 Promise 会在任何一个传入的 Promise 被 resolved 时就被 resolved,其 resolved 值就是第一个被 resolved 的 Promise 的值。只有当所有传入的 Promise 都被 rejected 时,新的 Promise 才会被 rejected,并且 rejection 的原因是一个包含所有 rejection 原因的 AggregateError

const promise11 = new Promise((_, reject) => {
    setTimeout(() => {
        reject('Promise 11 rejected');
    }, 1000);
});

const promise12 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise 12 resolved');
    }, 1500);
});

const promise13 = new Promise((_, reject) => {
    setTimeout(() => {
        reject('Promise 13 rejected');
    }, 2000);
});

Promise.any([promise11, promise12, promise13])
   .then((value) => {
        console.log(value); // 'Promise 12 resolved'
    })
   .catch((error) => {
        console.error(error);
    });

在异步操作中使用 Promise 的最佳实践

  1. 避免回调地狱:正如前面提到的,使用 Promise 可以有效避免回调地狱。通过链式调用 then() 方法,我们可以将多个异步操作串联起来,使代码更具可读性和维护性。
// 回调地狱示例
getData((data1) => {
    processData1(data1, (data2) => {
        processData2(data2, (data3) => {
            processData3(data3, (result) => {
                console.log(result);
            });
        });
    });
});

// 使用 Promise 改写
getData()
   .then(processData1)
   .then(processData2)
   .then(processData3)
   .then((result) => {
        console.log(result);
    })
   .catch((error) => {
        console.error(error);
    });
  1. 错误处理:在 Promise 链中,始终使用 catch() 方法来捕获可能出现的错误。这样可以确保错误不会在异步操作中被忽略,并且可以统一处理整个 Promise 链中的错误。
fetchData()
   .then(processData)
   .then(saveData)
   .catch((error) => {
        console.error('An error occurred:', error);
        // 可以在这里进行错误日志记录、显示用户友好的错误信息等操作
    });
  1. 合理使用 Promise 组合方法:根据具体的业务需求,选择合适的 Promise 组合方法,如 Promise.all()Promise.race()Promise.allSettled()Promise.any()。例如,如果需要等待多个异步操作都完成后再继续,可以使用 Promise.all();如果只关心第一个完成的异步操作,可以使用 Promise.race()
// 假设我们有多个 API 请求,需要等待所有请求都完成后再处理数据
const apiCalls = [fetch('api1'), fetch('api2'), fetch('api3')];
Promise.all(apiCalls)
   .then((responses) => {
        // 处理所有响应
        const data = responses.map((response) => response.json());
        return Promise.all(data);
    })
   .then((results) => {
        console.log(results);
    })
   .catch((error) => {
        console.error(error);
    });
  1. 避免不必要的 Promise 创建:虽然 Promise 提供了强大的异步处理能力,但不要在不需要异步操作的地方创建 Promise。例如,如果一个函数只是简单地返回一个值,就没有必要将其包装在 Promise 中。
// 不必要的 Promise 创建
function unnecessaryPromise() {
    return new Promise((resolve) => {
        resolve(42);
    });
}

// 直接返回值
function simpleReturn() {
    return 42;
}
  1. 与 async/await 结合使用async/await 是基于 Promise 构建的更简洁的异步编程语法糖。async 函数总是返回一个 Promise,await 只能在 async 函数内部使用,它会暂停函数的执行,直到 Promise 被 resolved 或 rejected。
async function asyncFunction() {
    try {
        const response = await fetch('https://example.com/api');
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error(error);
    }
}
asyncFunction();
  1. 注意内存管理:在处理大量异步操作时,要注意内存管理。例如,如果使用 Promise.race() 并且有大量的 Promise 同时运行,即使第一个 Promise 已经完成,其他未完成的 Promise 可能仍然占用内存。在某些情况下,需要手动取消或清理这些未完成的 Promise。
// 假设我们有一个数组包含大量 Promise
const manyPromises = Array.from({ length: 1000 }, (_, i) => {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(i);
        }, Math.random() * 1000);
    });
});

// 使用 Promise.race() 但不处理其他未完成的 Promise
Promise.race(manyPromises)
   .then((value) => {
        console.log(value);
    });

// 更好的方式,在获取到结果后取消其他 Promise(这里只是示例思路,实际取消需要更复杂的逻辑)
const controller = new AbortController();
const { signal } = controller;

const manyPromisesWithAbort = Array.from({ length: 1000 }, (_, i) => {
    return new Promise((resolve, reject) => {
        const timeoutId = setTimeout(() => {
            if (signal.aborted) {
                reject(new DOMException('Aborted', 'AbortError'));
            } else {
                resolve(i);
            }
        }, Math.random() * 1000);
        signal.addEventListener('abort', () => clearTimeout(timeoutId));
    });
});

Promise.race(manyPromisesWithAbort)
   .then((value) => {
        console.log(value);
        controller.abort();
    })
   .catch((error) => {
        if (error.name === 'AbortError') {
            // 处理取消情况
        } else {
            console.error(error);
        }
    });
  1. 测试异步代码:在测试使用 Promise 的异步代码时,要使用合适的测试框架和方法。例如,在 Jest 中,可以使用 async/await 结合 expect 来测试 Promise 的结果。
// 被测试的异步函数
async function asyncAdd(a, b) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(a + b);
        }, 1000);
    });
}

test('asyncAdd should add two numbers correctly', async () => {
    const result = await asyncAdd(2, 3);
    expect(result).toBe(5);
});

实际应用场景

  1. 网络请求:在现代 Web 开发中,网络请求是最常见的异步操作之一。使用 Promise 可以更好地管理多个网络请求之间的依赖关系,并且可以统一处理请求过程中的错误。
// 使用 Fetch API 进行网络请求
fetch('https://example.com/api/data')
   .then((response) => {
        if (!response.ok) {
            throw new Error('Network response was not ok');
        }
        return response.json();
    })
   .then((data) => {
        console.log(data);
    })
   .catch((error) => {
        console.error(error);
    });
  1. 文件操作:在 Node.js 中,文件操作也是异步的。通过使用 Promise 可以使文件操作的代码更简洁、易读。
const fs = require('fs').promises;

async function readFileContent() {
    try {
        const data = await fs.readFile('example.txt', 'utf8');
        console.log(data);
    } catch (error) {
        console.error(error);
    }
}

readFileContent();
  1. 动画和 DOM 操作:在前端开发中,动画和某些 DOM 操作可能需要异步执行。例如,在动画完成后执行一些回调操作,可以使用 Promise 来管理这个过程。
function animateElement(element, duration) {
    return new Promise((resolve) => {
        element.style.animation = `myAnimation ${duration}s ease-in-out forwards`;
        element.addEventListener('animationend', resolve);
    });
}

const myElement = document.getElementById('myElement');
animateElement(myElement, 2)
   .then(() => {
        // 动画完成后执行的操作
        myElement.textContent = 'Animation completed';
    });

与其他异步处理方式的比较

  1. 与回调函数的比较
    • 可读性:Promise 通过链式调用 then() 方法,使异步操作的流程更清晰,避免了回调地狱,提高了代码的可读性。而回调函数在多个异步操作嵌套时,代码会变得非常混乱。
    • 错误处理:Promise 提供了统一的 catch() 方法来捕获整个 Promise 链中的错误,而回调函数需要在每个回调中单独处理错误,容易遗漏。
    • 可维护性:由于 Promise 的链式调用结构,在添加或修改异步操作时更容易,而回调函数的嵌套结构使得代码的维护成本较高。
  2. 与 async/await 的比较
    • 语法糖async/await 是基于 Promise 构建的语法糖,它的语法更接近同步代码,使得异步代码看起来更简洁、直观。例如,await 可以暂停函数执行,直到 Promise 被 resolved,这使得代码的执行顺序更符合人类的思维方式。
    • 功能一致性:本质上,async/await 还是基于 Promise 来处理异步操作,所以它们在功能上是一致的。async 函数返回的也是一个 Promise,可以使用 then()catch() 等方法来处理。
    • 使用场景async/await 更适合在函数内部处理异步操作,而 Promise 更适合在需要组合多个异步操作或者在不支持 async/await 的环境中使用。例如,在处理多个 Promise 的并发操作时,Promise.all() 等方法在使用 async/await 的代码中同样适用。

常见问题及解决方法

  1. 未处理的 Promise 拒绝:如果在 Promise 链中没有使用 catch() 方法来捕获错误,未处理的 Promise 拒绝可能会导致运行时错误,并且这些错误可能很难调试。为了避免这种情况,始终在 Promise 链的末尾添加 catch() 方法,或者在 async 函数内部使用 try...catch 块。
// 未处理的 Promise 拒绝示例
fetch('https://nonexistenturl.com/api')
   .then((response) => response.json());

// 正确处理错误
fetch('https://nonexistenturl.com/api')
   .then((response) => response.json())
   .catch((error) => {
        console.error('Error fetching data:', error);
    });
  1. Promise 内存泄漏:在某些情况下,例如使用 Promise.race() 且有大量 Promise 同时运行时,可能会出现内存泄漏问题。解决方法是在不需要其他未完成的 Promise 时,手动取消或清理它们。可以使用 AbortController 来实现这一点,如前面内存管理部分的示例所示。
  2. Promise 链过长:虽然 Promise 可以有效避免回调地狱,但如果 Promise 链过长,代码的可读性和维护性也会受到影响。在这种情况下,可以考虑将部分逻辑封装成单独的函数,或者使用 async/await 来简化代码结构。
// 过长的 Promise 链
fetch('api1')
   .then((response1) => response1.json())
   .then((data1) => processData1(data1))
   .then((result1) => fetch('api2', { body: result1 }))
   .then((response2) => response2.json())
   .then((data2) => processData2(data2))
   .then((result2) => fetch('api3', { body: result2 }))
   .then((response3) => response3.json())
   .then((data3) => processData3(data3))
   .catch((error) => console.error(error));

// 使用 async/await 简化
async function fetchData() {
    try {
        const response1 = await fetch('api1');
        const data1 = await response1.json();
        const result1 = processData1(data1);

        const response2 = await fetch('api2', { body: result1 });
        const data2 = await response2.json();
        const result2 = processData2(data2);

        const response3 = await fetch('api3', { body: result2 });
        const data3 = await response3.json();
        const result3 = processData3(data3);

        return result3;
    } catch (error) {
        console.error(error);
    }
}

fetchData();

总结 Promise 在异步操作中的重要性

Promise 在 JavaScript 的异步编程中扮演着至关重要的角色。它为我们提供了一种优雅、高效的方式来处理异步操作,避免了回调地狱等问题,提高了代码的可读性、可维护性和可测试性。通过合理使用 Promise 的各种方法,如 then()catch()finally() 以及 Promise.all()Promise.race() 等组合方法,我们可以更好地管理复杂的异步流程。同时,结合 async/await 语法糖,使得异步代码更加简洁直观,更符合我们对同步代码的思维习惯。在实际开发中,无论是网络请求、文件操作还是动画处理等各种异步场景,Promise 都成为了不可或缺的工具。掌握 Promise 的最佳实践,对于提升 JavaScript 开发技能和构建高质量的应用程序具有重要意义。在未来的 JavaScript 开发中,随着异步操作的复杂性不断增加,Promise 的重要性将会愈发凸显。因此,深入理解和熟练运用 Promise 是每个 JavaScript 开发者都应该掌握的核心技能之一。