JavaScript异步编程详解:模式、陷阱和最佳实践

引言
这个系列文章的想法来自于大量的面试经历,其中甚至有些拥有10年以上前端经验的人无法回答关于await可以与实现then()方法的对象一起工作,或者标准对象或类是否可以实现then方法等简单问题。
也就是说,人们正在从前端开发转向狭隘的框架,其中框架抽象完全替代了与原生异步JS模型的工作。
JavaScript是单线程的,这意味着它一次处理一个操作。然而,许多任务——如网络请求、文件操作或定时器——需要时间来完成,如果同步处理会阻塞执行。
虽然JavaScript传统上被认为是单线程语言,但随着Web Workers和SharedArrayBuffer的出现,更准确的说法是它有一个单一的主执行线程,但可以通过workers运行额外的线程。每个worker都有自己的执行栈和内存空间。然而,在每个线程内,JavaScript仍然是单线程的。
在本文中,我邀请你探索原生JavaScript如何处理异步任务。这不仅有助于理解原生JS在底层如何工作,还有助于准备技术面试,并看到语言中已经内置的一些有趣概念和方法。
为了高效处理此类任务,JavaScript依赖于异步编程。引擎不会等待慢操作完成,而是继续执行其他代码,稍后处理结果。
在这个文章系列中,我们将研究以下主题:
回调函数(最古老的方法)
Promise(更结构化的替代方案)
在后续文章中:
高级Promise控制
Async/Await(Promise的语法糖)
事件发射器和流(用于事件驱动的工作流)
高级模式(Worker线程、Actor、响应式编程)
虽然事件循环(Node.js)和Web API(浏览器)管理异步任务的执行顺序,但本文专注于实用模式和最佳实践,而不是底层运行时机制。
在本文中,我们将专注于回调和Promise,这样文章不会变得过于庞大。其他方法将在后续文章中描述。
回调函数:经典方法
在本节中,我们将看看回调函数——一个基本简单的概念。回调不必是异步的;你可能已经见过很多同步回调,比如传递给数组方法如.filter().map()的回调。这种约定在JavaScript中极其常见。
自然地,你也可以使用回调编写完全异步的代码。例如,让我们编写一些代码,每秒从数组中取出下一个用户并记录他们的名字:
  1. const users = [
  2. { name: 'Ivan', age: 27 },
  3. { name: 'Olena', age: 32 },
  4. { name: 'Mike', age: 18 },
  5. ];

  6. const timer = setInterval(() => {
  7. const nextUser = users.shift();
  8. console.log(`下一个用户: ${nextUser.name}`);

  9. if (users.length === 0) {
  10. clearInterval(timer); // 完成后停止
  11. }
  12. }, 1000);
javascript
在这个例子中,创建了一个定时器。每秒,Node.js或浏览器的Web API将执行代码,将回调放入队列,然后事件循环将其推送到调用栈上运行。这基本上就是异步回调的工作方式:我们等待一个事件,Node.js或Web API处理该事件并获取回调(我们传入的函数——这里是一个箭头函数),将其放入队列,然后事件循环最终在调用栈上执行它。
使用回调的异步文件读取
这是使用Node.js和fs(文件系统)模块的一个稍微不同的例子:
  1. const fs = require('node:fs');

  2. fs.readFile('/Users/joe/test.txt', 'utf8', handleUserFile);

  3. function handleUserFile(err, data) {
  4. if (err) {
  5. console.error('错误:', err);
  6. return;
  7. }
  8. console.log(data);
  9. }
javascript
这是一个异步文件读取操作。handleUserFile回调稍后被调用,在文件被读取后接收参数。如果你有大量函数,可以使用命名函数使代码更易读。事实上,你可以为回调函数使用任何你喜欢的结构,只要满足回调约定(它期望的签名)。
回调还通过闭包开启了许多可能性。例如,让我们将文件名存储在变量中,然后记录结果。这样,我们将文件名保存在闭包中,可以在稍后的回调中使用它:
  1. const fs = require('node:fs');

  2. const fileName = '/Users/joe/test.txt';

  3. const logFile = (fileName) => (err, data) => {
  4. console.log({ fileName, err, data });
  5. };

  6. fs.readFile(fileName, 'utf8', logFile(fileName));
javascript
这里,logFile返回我们的回调函数,该函数被传递给readFile。感谢闭包fileName在该回调内部可用。
回调地狱
当然,如果我们讨论回调,我们必须提到"回调地狱"。让我们快速说明一下:
  1. function fetchUserData(userId, callback) {
  2. console.log('(1) 获取用户数据...');
  3. setTimeout(() => {
  4. if (userId === 1) {
  5. console.log('(1) 用户数据已获取');
  6. callback({ id: 1, name: 'John Doe', role: 'admin' }, null);
  7. } else {
  8. callback(null, '用户未找到');
  9. }
  10. }, 1000);
  11. }

  12. function fetchUserPermissions(role, callback) {
  13. console.log('(2) 获取角色权限:', role);
  14. setTimeout(() => {
  15. if (role === 'admin') {
  16. console.log('(2) 权限已获取');
  17. callback(['read', 'write', 'delete'], null);
  18. } else {
  19. callback(null, '此角色未找到权限');
  20. }
  21. }, 1000);
  22. }

  23. function logAccessAttempt(user, permissions, callback) {
  24. console.log('(3) 记录访问尝试...');
  25. setTimeout(() => {
  26. if (permissions.includes('write')) {
  27. console.log(`(3) 用户访问已记录: ${user.name}`);
  28. callback('访问记录成功', null);
  29. } else {
  30. callback(null, '访问记录权限不足');
  31. }
  32. }, 1000);
  33. }

  34. fetchUserData(1, (user, err) => {
  35. if (err) return console.log(err);
  36. fetchUserPermissions(user.role, (permissions, err) => {
  37. if (err) return console.log(err);
  38. logAccessAttempt(user, permissions, (result, err) => {
  39. if (err) return console.log(err);
  40. console.log(result);
  41. });
  42. });
  43. });
javascript
这里,函数向右移动得更深,使代码难以阅读,甚至更难扩展。
缓解这个问题的一种方法是使用命名函数:
  1. const handleLogAccessAttempt = (result, err) => {
  2. if (err) {
  3. console.log(err);
  4. return;
  5. }
  6. console.log(result);
  7. };

  8. const handleUserPermissions = (user) => (permissions, err) => {
  9. if (err) {
  10. console.log(err);
  11. return;
  12. }
  13. logAccessAttempt(user, permissions, handleLogAccessAttempt);
  14. };

  15. const handleUser = (user, err) => {
  16. if (err) {
  17. console.log(err);
  18. return;
  19. }
  20. fetchUserPermissions(user.role, handleUserPermissions(user));
  21. };

  22. fetchUserData(1, handleUser);
javascript
虽然这更清洁,但我们可以看到不同的问题:逻辑流程可能不会以最自然的顺序读取。一种解决方法是在类中使用:
  1. class UserService {
  2. constructor() {
  3. this.user = null;
  4. this.permissions = null;
  5. }

  6. handleLogAccessAttempt(result, err) {
  7. if (err) {
  8. console.log(err);
  9. return;
  10. }
  11. console.log(result);
  12. }

  13. handleUserPermissions(permissions, err) {
  14. if (err) {
  15. console.log(err);
  16. return;
  17. }
  18. this.permissions = permissions;
  19. logAccessAttempt(this.user, this.permissions, this.handleLogAccessAttempt.bind(this));
  20. }

  21. handleUser(user, err) {
  22. if (err) {
  23. console.log(err);
  24. return;
  25. }
  26. this.user = user;
  27. fetchUserPermissions(user.role, this.handleUserPermissions.bind(this));
  28. }
  29. }

  30. const userService = new UserService();
  31. fetchUserData(1, userService.handleUser.bind(userService));
javascript
Promise:更结构化的方法
Promise是处理异步操作的一种更现代、更结构化的方法。它们提供了一种更清晰的方式来处理异步代码,避免了回调地狱。
Promise的基本概念
Promise是一个表示异步操作最终完成或失败的对象。它有三种状态:
pending(待定):初始状态,既不是成功也不是失败
fulfilled(已实现):操作成功完成
rejected(已拒绝):操作失败
创建Promise
  1. const myPromise = new Promise((resolve, reject) => {
  2. // 异步操作
  3. setTimeout(() => {
  4. const randomNumber = Math.random();
  5. if (randomNumber > 0.5) {
  6. resolve(`成功!数字是: ${randomNumber}`);
  7. } else {
  8. reject(`失败!数字太小: ${randomNumber}`);
  9. }
  10. }, 1000);
  11. });
javascript
使用Promise
  1. myPromise
  2. .then((result) => {
  3. console.log('成功:', result);
  4. })
  5. .catch((error) => {
  6. console.log('错误:', error);
  7. })
  8. .finally(() => {
  9. console.log('操作完成');
  10. });
javascript
Promise链式调用
Promise的真正力量在于链式调用:
  1. function fetchUserData(userId) {
  2. return new Promise((resolve, reject) => {
  3. setTimeout(() => {
  4. if (userId === 1) {
  5. resolve({ id: 1, name: 'John Doe', role: 'admin' });
  6. } else {
  7. reject('用户未找到');
  8. }
  9. }, 1000);
  10. });
  11. }

  12. function fetchUserPermissions(role) {
  13. return new Promise((resolve, reject) => {
  14. setTimeout(() => {
  15. if (role === 'admin') {
  16. resolve(['read', 'write', 'delete']);
  17. } else {
  18. reject('此角色未找到权限');
  19. }
  20. }, 1000);
  21. });
  22. }

  23. function logAccessAttempt(user, permissions) {
  24. return new Promise((resolve, reject) => {
  25. setTimeout(() => {
  26. if (permissions.includes('write')) {
  27. resolve(`用户访问已记录: ${user.name}`);
  28. } else {
  29. reject('访问记录权限不足');
  30. }
  31. }, 1000);
  32. });
  33. }

  34. // 使用Promise链
  35. fetchUserData(1)
  36. .then((user) => {
  37. console.log('用户数据:', user);
  38. return fetchUserPermissions(user.role);
  39. })
  40. .then((permissions) => {
  41. console.log('权限:', permissions);
  42. return logAccessAttempt(user, permissions);
  43. })
  44. .then((result) => {
  45. console.log('结果:', result);
  46. })
  47. .catch((error) => {
  48. console.log('错误:', error);
  49. });
javascript
Promise组合器
JavaScript提供了几个有用的Promise组合器:
Promise.all()
等待所有Promise完成,如果任何一个失败,整个Promise就失败:
  1. const promises = [
  2. fetchUserData(1),
  3. fetchUserData(2),
  4. fetchUserData(3)
  5. ];

  6. Promise.all(promises)
  7. .then((results) => {
  8. console.log('所有用户数据:', results);
  9. })
  10. .catch((error) => {
  11. console.log('至少一个请求失败:', error);
  12. });
javascript
Promise.race()
返回第一个完成的Promise(无论成功还是失败):
  1. const promises = [
  2. fetchUserData(1),
  3. new Promise((resolve) => setTimeout(() => resolve('超时'), 5000))
  4. ];

  5. Promise.race(promises)
  6. .then((result) => {
  7. console.log('第一个完成的结果:', result);
  8. });
javascript
Promise.allSettled()
等待所有Promise完成,无论成功还是失败:
  1. const promises = [
  2. fetchUserData(1),
  3. fetchUserData(999), // 这个会失败
  4. fetchUserData(2)
  5. ];

  6. Promise.allSettled(promises)
  7. .then((results) => {
  8. results.forEach((result, index) => {
  9. if (result.status === 'fulfilled') {
  10. console.log(`Promise ${index} 成功:`, result.value);
  11. } else {
  12. console.log(`Promise ${index} 失败:`, result.reason);
  13. }
  14. });
  15. });
javascript
高级Promise模式
实现Promise组合器
作为面试准备,让我们重新实现内置的Promise组合器。
这些任务测试你对以下内容的理解:
控制多个异步流程
Promise解析和拒绝如何在内部工作
正确处理边缘情况,如空数组、同步值和立即解决的Promise
问题概述
让我们简要回顾每个内置Promise方法的工作原理:
方法
行为总结
Promise.all()
等待所有Promise完成。在第一次拒绝时立即拒绝。用值数组解析。
Promise.race()
一旦任何Promise解决(无论是实现还是拒绝)就解决。返回第一个结果。
Promise.allSettled()
等待所有Promise解决。始终用{status, value}或{status, reason}对象数组解析。
对于所有这些,任何非Promise值都会使用Promise.resolve()自动转换。
边缘情况
Promise.all()
如果输入数组为空,立即解析为空数组。
如果任何Promise拒绝,返回的Promise立即以该原因拒绝。
非Promise值简单地被视为已实现的值。
Promise.race()
如果输入数组为空,Promise永远保持待定状态。
如果输入包含非Promise值,Promise.race()解析为遇到的第一个值。
使用.then(resolve, reject)而不是.catch().catch()的调度发生在.then()之后,当Promise已经解决时可能导致不正确的行为。
任务1 实现 promiseAll()
  1. function promiseAll(iterable) {
  2. return new Promise((resolve, reject) => {
  3. const results = [];
  4. let fulfilledCount = 0;

  5. // 步骤1 — 处理空输入
  6. if (iterable.length === 0) {
  7. resolve([]);
  8. return;
  9. }

  10. // 步骤2 — 遍历所有值
  11. iterable.forEach((item, index) => {
  12. // 始终将值标准化为Promise
  13. Promise.resolve(item)
  14. .then((value) => {
  15. results[index] = value;
  16. fulfilledCount++;

  17. // 步骤3 — 当所有Promise都实现时解析
  18. if (fulfilledCount === iterable.length) {
  19. resolve(results);
  20. }
  21. })
  22. .catch(reject); // 步骤4 — 在第一次拒绝时立即拒绝
  23. });
  24. });
  25. }
javascript
解释:
我们使用Promise.resolve()标准化所有值,以处理Promise和非Promise输入。
使用results[index]保持结果的顺序。
我们使用fulfilledCount跟踪已实现的Promise。
在第一次拒绝时,我们立即调用reject()
任务2 实现 promiseRace()
  1. function promiseRace(iterable) {
  2. return new Promise((resolve, reject) => {
  3. // 步骤1 — 处理空输入
  4. if (iterable.length === 0) {
  5. return;
  6. }

  7. // 步骤2 — 遍历并标准化每个输入
  8. iterable.forEach((item) => {
  9. Promise.resolve(item).then(resolve, reject);
  10. });
  11. });
  12. }
javascript
解释:
我们不需要在这里计算Promise,因为race一旦一个解决就完成。
使用.then(resolve, reject)确保正确处理已实现和已拒绝的Promise。
重要的是不要在这里使用.catch(),因为它的延迟调度行为。
示例:
  1. promiseRace([
  2. Promise.reject(42),
  3. Promise.resolve(2)
  4. ]);
javascript
如果使用了.catch(),第二个Promise可能首先解析,因为.then()立即运行,而.catch()被安排稍后运行。
任务3 实现 promiseAllSettled()
  1. function promiseAllSettled(iterable) {
  2. return new Promise((resolve) => {
  3. const results = [];
  4. let settledCount = 0;

  5. // 步骤1 — 处理空输入
  6. if (iterable.length === 0) {
  7. resolve([]);
  8. return;
  9. }

  10. // 步骤2 — 遍历所有值
  11. iterable.forEach((item, index) => {
  12. Promise.resolve(item)
  13. .then((value) => {
  14. results[index] = { status: 'fulfilled', value };
  15. })
  16. .catch((reason) => {
  17. results[index] = { status: 'rejected', reason };
  18. })
  19. .finally(() => {
  20. settledCount++;

  21. // 步骤3 — 当所有Promise都解决时解析
  22. if (settledCount === iterable.length) {
  23. resolve(results);
  24. }
  25. });
  26. });
  27. });
  28. }
javascript
解释:
每个Promise都独立处理——实现或拒绝。
没有Promise可以阻止其他Promise被处理。
结果始终是所有Promise状态的完整数组。
保持输入的原始顺序。
总结:为什么这些高级Promise任务很重要
重新实现Promise.all()Promise.race()Promise.allSettled()是一个非常流行的面试主题,特别是在Google、Amazon、Microsoft或TikTok等公司的中高级面试中。
这些问题测试:
你对Promise机制的实用知识。
你处理并发、排序和异步状态管理的能力。
你对边缘情况的关注(空输入、非Promise值、同步vs异步解析)。
你对微任务调度和正确回调放置的理解。
即使你没有直接被要求实现这些确切的函数,它们背后的推理模式——计算完成、保持顺序、早期解析或标准化输入——对于出现在任何技术级别的许多异步问题都是极其可转移的。
如果你对这些任务有信心,你就为大多数现实世界的Promise相关面试问题做好了更好的准备。
总结
我们在本文中涵盖了很多内容——从基本回调到Promise如何在内部工作,最后到许多公司喜欢问的现实世界面试任务。
到现在,你应该不仅对在日常工作中使用Promise有信心,而且完全理解它们在底层的行为:
Promise状态变化如何工作(pending fulfilled / rejected)。
链式调用、.then().catch().finally()实际上如何操作。
如何使用promisify()转换基于回调的API。
如何重新实现核心Promise组合器,如Promise.all()Promise.race()Promise.allSettled()——以及为什么这些模式在现实世界的异步问题中一次又一次地出现。
这些模式不仅仅是"学术面试问题"。你在这里练习的逻辑——处理异步流程、管理并发、尊重执行顺序、捕获边缘情况——将直接帮助你编写更好的生产代码,并自信地处理更复杂的系统。
最重要的是:如果你能深入理解Promise,你已经在真实面试中领先于许多候选人了。