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中极其常见。
自然地,你也可以使用回调编写完全异步的代码。例如,让我们编写一些代码,每秒从数组中取出下一个用户并记录他们的名字:
- const users = [
- { name: 'Ivan', age: 27 },
- { name: 'Olena', age: 32 },
- { name: 'Mike', age: 18 },
- ];
- const timer = setInterval(() => {
- const nextUser = users.shift();
- console.log(`下一个用户: ${nextUser.name}`);
- if (users.length === 0) {
- clearInterval(timer); // 完成后停止
- }
- }, 1000);
在这个例子中,创建了一个定时器。每秒,Node.js或浏览器的Web API将执行代码,将回调放入队列,然后事件循环将其推送到调用栈上运行。这基本上就是异步回调的工作方式:我们等待一个事件,Node.js或Web API处理该事件并获取回调(我们传入的函数——这里是一个箭头函数),将其放入队列,然后事件循环最终在调用栈上执行它。
使用回调的异步文件读取
这是使用Node.js和fs(文件系统)模块的一个稍微不同的例子:
- const fs = require('node:fs');
- fs.readFile('/Users/joe/test.txt', 'utf8', handleUserFile);
- function handleUserFile(err, data) {
- if (err) {
- console.error('错误:', err);
- return;
- }
- console.log(data);
- }
这是一个异步文件读取操作。handleUserFile回调稍后被调用,在文件被读取后接收参数。如果你有大量函数,可以使用命名函数使代码更易读。事实上,你可以为回调函数使用任何你喜欢的结构,只要满足回调约定(它期望的签名)。
回调还通过闭包开启了许多可能性。例如,让我们将文件名存储在变量中,然后记录结果。这样,我们将文件名保存在闭包中,可以在稍后的回调中使用它:
- const fs = require('node:fs');
- const fileName = '/Users/joe/test.txt';
- const logFile = (fileName) => (err, data) => {
- console.log({ fileName, err, data });
- };
- fs.readFile(fileName, 'utf8', logFile(fileName));
这里,logFile返回我们的回调函数,该函数被传递给readFile。感谢闭包,fileName在该回调内部可用。
回调地狱
当然,如果我们讨论回调,我们必须提到"回调地狱"。让我们快速说明一下:
- function fetchUserData(userId, callback) {
- console.log('(1) 获取用户数据...');
- setTimeout(() => {
- if (userId === 1) {
- console.log('(1) 用户数据已获取');
- callback({ id: 1, name: 'John Doe', role: 'admin' }, null);
- } else {
- callback(null, '用户未找到');
- }
- }, 1000);
- }
- function fetchUserPermissions(role, callback) {
- console.log('(2) 获取角色权限:', role);
- setTimeout(() => {
- if (role === 'admin') {
- console.log('(2) 权限已获取');
- callback(['read', 'write', 'delete'], null);
- } else {
- callback(null, '此角色未找到权限');
- }
- }, 1000);
- }
- function logAccessAttempt(user, permissions, callback) {
- console.log('(3) 记录访问尝试...');
- setTimeout(() => {
- if (permissions.includes('write')) {
- console.log(`(3) 用户访问已记录: ${user.name}`);
- callback('访问记录成功', null);
- } else {
- callback(null, '访问记录权限不足');
- }
- }, 1000);
- }
- fetchUserData(1, (user, err) => {
- if (err) return console.log(err);
- fetchUserPermissions(user.role, (permissions, err) => {
- if (err) return console.log(err);
- logAccessAttempt(user, permissions, (result, err) => {
- if (err) return console.log(err);
- console.log(result);
- });
- });
- });
这里,函数向右移动得更深,使代码难以阅读,甚至更难扩展。
缓解这个问题的一种方法是使用命名函数:
- const handleLogAccessAttempt = (result, err) => {
- if (err) {
- console.log(err);
- return;
- }
- console.log(result);
- };
- const handleUserPermissions = (user) => (permissions, err) => {
- if (err) {
- console.log(err);
- return;
- }
- logAccessAttempt(user, permissions, handleLogAccessAttempt);
- };
- const handleUser = (user, err) => {
- if (err) {
- console.log(err);
- return;
- }
- fetchUserPermissions(user.role, handleUserPermissions(user));
- };
- fetchUserData(1, handleUser);
虽然这更清洁,但我们可以看到不同的问题:逻辑流程可能不会以最自然的顺序读取。一种解决方法是在类中使用:
- class UserService {
- constructor() {
- this.user = null;
- this.permissions = null;
- }
- handleLogAccessAttempt(result, err) {
- if (err) {
- console.log(err);
- return;
- }
- console.log(result);
- }
- handleUserPermissions(permissions, err) {
- if (err) {
- console.log(err);
- return;
- }
- this.permissions = permissions;
- logAccessAttempt(this.user, this.permissions, this.handleLogAccessAttempt.bind(this));
- }
- handleUser(user, err) {
- if (err) {
- console.log(err);
- return;
- }
- this.user = user;
- fetchUserPermissions(user.role, this.handleUserPermissions.bind(this));
- }
- }
- const userService = new UserService();
- fetchUserData(1, userService.handleUser.bind(userService));
Promise:更结构化的方法
Promise是处理异步操作的一种更现代、更结构化的方法。它们提供了一种更清晰的方式来处理异步代码,避免了回调地狱。
Promise的基本概念
Promise是一个表示异步操作最终完成或失败的对象。它有三种状态:
pending(待定):初始状态,既不是成功也不是失败
fulfilled(已实现):操作成功完成
rejected(已拒绝):操作失败
创建Promise
- const myPromise = new Promise((resolve, reject) => {
- // 异步操作
- setTimeout(() => {
- const randomNumber = Math.random();
- if (randomNumber > 0.5) {
- resolve(`成功!数字是: ${randomNumber}`);
- } else {
- reject(`失败!数字太小: ${randomNumber}`);
- }
- }, 1000);
- });
使用Promise
- myPromise
- .then((result) => {
- console.log('成功:', result);
- })
- .catch((error) => {
- console.log('错误:', error);
- })
- .finally(() => {
- console.log('操作完成');
- });
Promise链式调用
Promise的真正力量在于链式调用:
- function fetchUserData(userId) {
- return new Promise((resolve, reject) => {
- setTimeout(() => {
- if (userId === 1) {
- resolve({ id: 1, name: 'John Doe', role: 'admin' });
- } else {
- reject('用户未找到');
- }
- }, 1000);
- });
- }
- function fetchUserPermissions(role) {
- return new Promise((resolve, reject) => {
- setTimeout(() => {
- if (role === 'admin') {
- resolve(['read', 'write', 'delete']);
- } else {
- reject('此角色未找到权限');
- }
- }, 1000);
- });
- }
- function logAccessAttempt(user, permissions) {
- return new Promise((resolve, reject) => {
- setTimeout(() => {
- if (permissions.includes('write')) {
- resolve(`用户访问已记录: ${user.name}`);
- } else {
- reject('访问记录权限不足');
- }
- }, 1000);
- });
- }
- // 使用Promise链
- fetchUserData(1)
- .then((user) => {
- console.log('用户数据:', user);
- return fetchUserPermissions(user.role);
- })
- .then((permissions) => {
- console.log('权限:', permissions);
- return logAccessAttempt(user, permissions);
- })
- .then((result) => {
- console.log('结果:', result);
- })
- .catch((error) => {
- console.log('错误:', error);
- });
Promise组合器
JavaScript提供了几个有用的Promise组合器:
Promise.all()
等待所有Promise完成,如果任何一个失败,整个Promise就失败:
- const promises = [
- fetchUserData(1),
- fetchUserData(2),
- fetchUserData(3)
- ];
- Promise.all(promises)
- .then((results) => {
- console.log('所有用户数据:', results);
- })
- .catch((error) => {
- console.log('至少一个请求失败:', error);
- });
Promise.race()
返回第一个完成的Promise(无论成功还是失败):
- const promises = [
- fetchUserData(1),
- new Promise((resolve) => setTimeout(() => resolve('超时'), 5000))
- ];
- Promise.race(promises)
- .then((result) => {
- console.log('第一个完成的结果:', result);
- });
Promise.allSettled()
等待所有Promise完成,无论成功还是失败:
- const promises = [
- fetchUserData(1),
- fetchUserData(999), // 这个会失败
- fetchUserData(2)
- ];
- Promise.allSettled(promises)
- .then((results) => {
- results.forEach((result, index) => {
- if (result.status === 'fulfilled') {
- console.log(`Promise ${index} 成功:`, result.value);
- } else {
- console.log(`Promise ${index} 失败:`, result.reason);
- }
- });
- });
高级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()
- function promiseAll(iterable) {
- return new Promise((resolve, reject) => {
- const results = [];
- let fulfilledCount = 0;
- // 步骤1 — 处理空输入
- if (iterable.length === 0) {
- resolve([]);
- return;
- }
- // 步骤2 — 遍历所有值
- iterable.forEach((item, index) => {
- // 始终将值标准化为Promise
- Promise.resolve(item)
- .then((value) => {
- results[index] = value;
- fulfilledCount++;
- // 步骤3 — 当所有Promise都实现时解析
- if (fulfilledCount === iterable.length) {
- resolve(results);
- }
- })
- .catch(reject); // 步骤4 — 在第一次拒绝时立即拒绝
- });
- });
- }
解释:
我们使用Promise.resolve()标准化所有值,以处理Promise和非Promise输入。
使用results[index]保持结果的顺序。
我们使用fulfilledCount跟踪已实现的Promise。
在第一次拒绝时,我们立即调用reject()。
任务2 — 实现 promiseRace()
- function promiseRace(iterable) {
- return new Promise((resolve, reject) => {
- // 步骤1 — 处理空输入
- if (iterable.length === 0) {
- return;
- }
- // 步骤2 — 遍历并标准化每个输入
- iterable.forEach((item) => {
- Promise.resolve(item).then(resolve, reject);
- });
- });
- }
解释:
我们不需要在这里计算Promise,因为race一旦一个解决就完成。
使用.then(resolve, reject)确保正确处理已实现和已拒绝的Promise。
重要的是不要在这里使用.catch(),因为它的延迟调度行为。
示例:
- promiseRace([
- Promise.reject(42),
- Promise.resolve(2)
- ]);
如果使用了.catch(),第二个Promise可能首先解析,因为.then()立即运行,而.catch()被安排稍后运行。
任务3 — 实现 promiseAllSettled()
- function promiseAllSettled(iterable) {
- return new Promise((resolve) => {
- const results = [];
- let settledCount = 0;
- // 步骤1 — 处理空输入
- if (iterable.length === 0) {
- resolve([]);
- return;
- }
- // 步骤2 — 遍历所有值
- iterable.forEach((item, index) => {
- Promise.resolve(item)
- .then((value) => {
- results[index] = { status: 'fulfilled', value };
- })
- .catch((reason) => {
- results[index] = { status: 'rejected', reason };
- })
- .finally(() => {
- settledCount++;
- // 步骤3 — 当所有Promise都解决时解析
- if (settledCount === iterable.length) {
- resolve(results);
- }
- });
- });
- });
- }
解释:
每个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,你已经在真实面试中领先于许多候选人了。
