JavaScript程序设计中的异步处理:Promise与async/await

星座1小时前发布 jdkjadmin
0 00
JavaScript程序设计中的异步处理:Promise与async/await

JavaScript程序设计中的异步处理:Promise与async/await

请求接口、读取文件、等待定时器或执行数据库操作时,程序往往需要在结果尚未准备好时继续运行。JavaScript 用异步机制处理这类工作:发起任务后,代码可以先执行其他事情,等任务完成再处理结果。

Promise 是 JavaScript 表示异步结果的标准对象;async/await 则建立在 Promise 之上,让异步流程更接近顺序代码。理解二者的状态、错误传播和调度方式,能让代码既易读,也不容易在异常情况下失控。

一、异步处理解决什么问题

如果一个操作可能需要等待,直接阻塞整个程序通常并不理想。例如浏览器等待网络响应时,仍需要处理用户输入和页面绘制;Node.js 等待文件或网络操作时,也希望继续处理其他任务。

旧式代码常用回调表达“完成后再做什么”。回调本身并没有错,但多个依赖步骤嵌套后,会形成层层缩进;错误也可能需要在每一层重复传递。Promise 将异步结果统一表示为一个对象,让后续操作和错误处理可以组合起来。

异步并不等于并行,也不意味着 CPU 密集型工作自动变快。Promise 主要表达“未来会有结果”,不会自动把同步计算移到后台线程。大量循环、图像计算等重任务仍会占用当前线程,需要考虑 Web Worker、Node.js Worker Threads 或其他适合的计算方案。

二、Promise 的状态与基本用法

Promise 只有三种状态:

  • pending:待定,操作尚未完成。
  • fulfilled:已兑现,操作成功并提供结果。
  • rejected:已拒绝,操作失败并提供原因。

Promise 一旦从待定状态变为兑现或拒绝,就称为已敲定(settled),之后不能再改变状态。兑现和拒绝都属于已敲定状态。实际业务里通常不需要手动创建 Promise,调用浏览器 API、Node.js API 或第三方库时,往往已经拿到了 Promise。

下面的示例可以直接在浏览器控制台或 Node.js 中运行。Promise 构造函数的执行器会在创建时同步执行;定时器回调稍后运行。

const task = new Promise((resolve, reject) => {
  setTimeout(() => {
    const succeeded = true;

    if (succeeded) {
      resolve({ message: "操作成功" });
    } else {
      reject(new Error("操作失败"));
    }
  }, 300);
});

task.then((value) => {
  console.log(value.message);
}).catch((error) => {
  console.error(error.message);
});

resolve 和 reject 负责敲定 Promise。执行器中抛出的同步异常会让这个 Promise 变为拒绝状态,因此下面的写法也能被 catch 捕获:

const task = new Promise(() => {
  throw new Error("执行器出错");
});

task.catch((error) => {
  console.error(error.message);
});

不过,很多场景并不需要自行包装 Promise。Promise 构造函数适合封装只提供回调接口的 API,或实现确实需要手动控制兑现、拒绝的操作。为已有 Promise 再套一层构造函数,容易造成重复逻辑甚至遗漏错误处理。

三、then、catch 与 finally:把异步步骤连起来

then(onFulfilled, onRejected) 为 Promise 注册处理函数,并且会返回一个新的 Promise。后续步骤关注的是这个新 Promise,而不是原来的 Promise。这一点是链式处理和错误传播的基础。

Promise.resolve(4)
  .then((value) => {
    return value * 2;
  })
  .then((value) => {
    console.log(value); // 8
  });

每个 then 回调返回的值,会成为下一步的兑现值。如果返回的是另一个 Promise,后续步骤会等待它完成,并采用它的结果。如果回调抛出异常,当前链条会转为拒绝状态。

Promise.resolve("输入")
  .then((value) => {
    throw new Error(`处理 ${value} 时发生错误`);
  })
  .then((value) => {
    console.log("这一步不会执行", value);
  })
  .catch((error) => {
    console.error(error.message);
  });

catch(handler) 本质上相当于 then(undefined, handler)。它会处理前面链条中尚未处理的拒绝。若错误处理函数正常返回一个值,链条会恢复为兑现状态;若它再次抛错或返回拒绝的 Promise,错误会继续向后传播。因此,catch 不是简单的“打印错误”,它也会改变链条结果。

下面的写法容易让人误以为第二步会等第一步完成,但实际上没有建立依赖关系:

loadUser().then((user) => {
  loadOrders(user.id); // 忘记 return
}).then((orders) => {
  console.log(orders); // orders 通常是 undefined
});

应返回内部的 Promise,使外层链条等待它:

loadUser()
  .then((user) => {
    return loadOrders(user.id);
  })
  .then((orders) => {
    console.log(orders);
  });

finally 适合清理不依赖成功或失败结果的状态,例如关闭加载提示。它通常不会改变原来的兑现值或拒绝原因,但如果 finally 回调本身抛出异常,或返回一个拒绝的 Promise,整个链条会以新的错误拒绝。它也不会接收原结果参数。

showLoading();

fetchData()
  .then((data) => {
    render(data);
  })
  .catch((error) => {
    showError(error.message);
  })
  .finally(() => {
    hideLoading();
  });

四、async/await:用同步风格书写异步流程

在函数声明前加上 async,函数就会返回 Promise。函数返回普通值时,结果会被包装为兑现的 Promise;函数抛出异常时,返回的 Promise 会被拒绝。

async function getNumber() {
  return 7;
}

getNumber().then((value) => {
  console.log(value); // 7
});

await 通常用于 async 函数内部。它等待一个 Promise 的结果:兑现时得到其值,拒绝时在当前函数中抛出相应错误。非 Promise 值也可以被 await,会按已兑现结果处理。

function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function main() {
  console.log("开始");
  await wait(300);
  console.log("等待结束");
}

main().catch((error) => {
  console.error(error);
});

await 会暂停当前 async 函数后续代码的执行,但不会阻塞整个 JavaScript 线程。等待期间,其他任务仍有机会运行。需要注意的是,await 后面的表达式在执行到该行时会先被求值;若其中包含同步耗时计算,仍会阻塞当前线程。

五、错误处理:拒绝最终必须有人负责

在 async 函数中,Promise 拒绝会表现为 await 处抛出异常,因此可用 try/catch 处理。若错误无法在当前函数中恢复,应继续抛出,让调用方决定如何处理。

async function loadProfile() {
  try {
    const response = await fetch("/api/profile");

    if (!response.ok) {
      throw new Error(`HTTP 状态异常:${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error("读取个人资料失败:", error);
    throw error;
  }
}

loadProfile().catch((error) => {
  showError(error.message);
});

这里对 HTTP 状态码作了显式检查。浏览器的 fetch 在网络错误等情况下通常会拒绝,但收到 404 或 500 响应时,Promise 通常仍会兑现为一个 Response。因此,不能只依赖 catch 判断 HTTP 请求是否成功。

如果某个步骤允许失败并有默认值,可以在合适的位置恢复:

async function loadSettings() {
  try {
    const response = await fetch("/api/settings");

    if (!response.ok) {
      throw new Error("设置读取失败");
    }

    return await response.json();
  } catch (error) {
    console.warn("使用默认设置:", error.message);
    return { theme: "light" };
  }
}

恢复错误意味着调用方拿到的是正常兑现的 Promise,不能再依赖上层 catch 得知原操作曾经失败。若需要记录失败状态,可返回明确的数据结构,或在恢复前进行适当记录。

常见错误之一是在异步函数中启动操作,却没有返回或等待它:

async function saveAndNotify(data) {
  saveData(data); // 没有 await 或 return
  return "已提交";
}

这个函数只保证自己返回的 Promise 在返回“已提交”后兑现,并不保证 saveData 已经完成。若后续流程依赖保存结果,应写成 await saveData(data);若要把保存操作交给调用方等待,也可以直接 return saveData(data)。

六、顺序执行与并发:不要无意中等待两次

当第二个任务依赖第一个任务的结果时,应顺序等待。例如先取用户信息,再用用户 ID 获取订单:

async function loadUserAndOrders() {
  const user = await fetchUser();
  const orders = await fetchOrders(user.id);

  return { user, orders };
}

如果两个任务互不依赖,却分别写成连续的 await,它们会先后开始,增加总等待时间:

async function loadDashboardSequentially() {
  const profile = await fetchProfile();
  const notices = await fetchNotices();

  return { profile, notices };
}

可以先启动两个任务,再用 Promise.all 等待它们:

async function loadDashboard() {
  const profilePromise = fetchProfile();
  const noticesPromise = fetchNotices();

  const [profile, notices] = await Promise.all([
    profilePromise,
    noticesPromise
  ]);

  return { profile, notices };
}

Promise.all(iterable) 接受可迭代对象,常见参数是数组。所有成员兑现后,它兑现为按输入顺序排列的结果数组;任一成员拒绝时,它会立即以该原因拒绝。它不会自动取消其他已启动的任务,所以其余操作仍可能在后台继续运行。

其他组合方法适用于不同需求:

  • Promise.allSettled(iterable) 等待全部成员敲定,不因单个拒绝而提前结束,结果数组中包含每项的状态和结果或原因。适合批量操作后逐项汇总。
  • Promise.race(iterable) 返回最先敲定的成员结果,无论兑现还是拒绝。它不会取消落后的操作,适合竞速选择结果,但不能单独实现真正的超时取消。
  • Promise.any(iterable) 在第一个成员兑现时兑现;如果全部拒绝,则以 AggregateError 拒绝。它适合尝试多个候选源、任一成功即可的场景。旧运行环境可能不支持此方法。

这些方法接收的是可迭代对象,不是一个必须为数组的特定参数类型。空输入也有明确差异:Promise.all([]) 和 Promise.allSettled([]) 会兑现为空数组;Promise.race([]) 会保持待定;Promise.any([]) 会以空的 AggregateError 拒绝。使用前应确认目标运行环境支持所需方法。

Promise.all 适合并发等待,但不能不加限制地同时启动成千上万个任务。大量并发请求可能触发服务端限流、耗尽连接或占用过多资源。任务量大时,应分批处理或使用并发数受控的队列;并发数应依据服务限制、资源预算和实际测量确定,而不是假定存在通用的固定值。

七、超时与取消:Promise 本身不会取消任务

Promise 是结果的表示,不是通用的取消机制。调用 Promise.race 与定时器竞速后,即使计时器先赢,原请求也可能继续占用网络或其他资源。若 API 支持取消,应使用该 API 提供的机制。

例如浏览器的 fetch 可以接收 AbortSignal。以下函数在达到指定时长后调用 abort,使支持该信号的请求被中止。超时时间应是有限、非负的合理数值;不同运行环境对异常类型和选项的支持可能有差异。

async function fetchWithTimeout(url, timeoutMs) {
  const controller = new AbortController();
  const timer = setTimeout(() => {
    controller.abort();
  }, timeoutMs);

  try {
    return await fetch(url, {
      signal: controller.signal
    });
  } finally {
    clearTimeout(timer);
  }
}

fetchWithTimeout("/api/data", 5000)
  .then(async (response) => {
    if (!response.ok) {
      throw new Error(`HTTP 状态异常:${response.status}`);
    }

    return response.json();
  })
  .then((data) => console.log(data))
  .catch((error) => console.error(error));

AbortController 能否中止任务,取决于对应 API 是否支持并遵守传入的信号。调用 abort() 后,fetch 通常会以拒绝结束;它不会自动撤销服务端已经完成的操作,也不会取消任意一个不识别 AbortSignal 的 Promise。

八、调度细节:为什么回调不是立即执行

对已兑现的 Promise 调用 then,处理函数也不会在当前同步代码中立即执行,而会进入微任务队列。常见情况下,微任务会在当前同步代码结束后、下一轮计时器任务处理前运行。await 后续代码也遵循 Promise 的异步调度语义。

console.log("A");

Promise.resolve().then(() => {
  console.log("C");
});

console.log("B");

// 输出顺序:A、B、C

依赖“某个回调恰好比另一个回调早执行”的代码容易出现竞态问题。应通过显式的 Promise 依赖、状态管理或同步机制表达先后关系,而不是依靠偶然的时间安排。

九、常见错误与排查方法

1. 忘记 await 或 return

这是异步流程最常见的问题之一。函数可能在子任务完成前就返回,异常也可能脱离预期的处理链。检查每个异步调用:调用方是否需要等待结果?若不等待,是否明确接受它在后台继续运行,并安排了错误处理?

2. 把循环中的任务误写成串行或无限并发

在普通 for...of 循环中使用 await 会逐项等待,适合任务之间有依赖或需要控制节奏的情况。如果各项相互独立,可使用 Promise.all(items.map(...)) 并发等待;但任务量很大时应限制并发,不要直接启动全部任务。

async function processInOrder(items) {
  const results = [];

  for (const item of items) {
    results.push(await processItem(item));
  }

  return results;
}

async function processTogether(items) {
  return Promise.all(items.map((item) => processItem(item)));
}

不要把 forEach 当作可等待的异步循环:forEach 不会等待回调返回的 Promise。需要顺序执行时使用 for...of;需要并发时使用 map 收集 Promise,再决定采用 all、allSettled 或其他策略。

3. 以为 catch 会自动处理所有错误

只有接入当前 Promise 链、并且最终被观察到的拒绝,才会由相应的 catch 处理。独立启动但未返回的 Promise 不会因为外层链条有 catch 就自动被接管。对于“刻意不等待”的后台任务,也要用明确的方式记录或处理失败。

4. 在 Promise 构造函数执行器中写异步等待

执行器参数中的 resolve、reject 用于控制新 Promise,但执行器本身不是一个需要 await 的异步函数。若确实需要异步步骤,通常直接写 async 函数即可,不要为了使用 await 而再套 Promise 构造函数。

5. 把网络响应对象当作业务数据

fetch 返回的是 Response,还需要根据响应状态判断是否成功,再调用 json()、text() 等读取响应体的方法。这些方法本身也会返回 Promise。读取响应体通常是一次性操作,不应对同一响应体重复消费。

6. 只在终端打印错误,却没有定义恢复策略

日志有助于排查,但不能代替业务决策。用户提示、重试、默认值、回滚或向上抛出,取决于操作的重要性和调用方责任。重试也不应对所有错误一概而论:认证失败、参数错误通常需要先修正原因;短暂网络故障是否重试,需结合服务约定,并考虑次数限制与退避策略。

十、可靠性与安全边界

Promise 和 async/await 只负责异步流程表达,不提供输入校验、访问控制、数据加密或安全隔离。参数是否安全,取决于接收它们的 API 及其规范。例如,超时时长应进行合理范围校验;用户提供的 URL 不应因为放进 Promise 或传给 fetch 就自动变得安全。

处理不可信 URL 时,应按业务需要限制协议、主机和端口,避免服务端请求伪造等风险;具体规则应结合运行环境与网络边界设计。服务端尤其不能把用户输入的地址直接用于任意出站请求。传给数据库、命令行或 HTML 渲染接口的数据,也应使用相应场景的安全 API、参数化查询或输出编码,而不是依赖异步封装。

还要谨慎处理拒绝原因:不要将密钥、令牌、完整敏感响应内容写入日志或直接返回给用户。可以向用户提供稳定、有限的错误信息,同时在受控日志中记录必要的诊断上下文。不要假定第三方 Promise 一定会以 Error 对象拒绝;编写通用边界代码时,应考虑拒绝值可能不是标准错误对象,并避免直接读取不存在的属性。

十一、何时选哪种写法

  • 单个异步操作,或多个具有先后依赖的步骤:优先用 async/await,配合明确的 try/catch。
  • 多个独立操作需要同时完成:先启动任务,再用 Promise.all;如果要收集每项成败,用 Promise.allSettled。
  • 多个候选任务中任意一个成功即可:考虑 Promise.any,同时评估运行环境兼容性。
  • 需要适配回调 API:可以用 Promise 封装,但要确保成功、失败、同步异常都能正确处理。
  • 需要取消或超时:优先使用底层 API 支持的取消信号;不要误把 Promise.race 当作取消工具。
  • 任务数量巨大或耗时计算很重:进一步考虑并发限流、任务队列或工作线程,而不只是改写语法。

掌握 Promise 的关键,不是记住多少个方法,而是明确每一步依赖什么结果、拒绝由谁处理、并发任务是否需要等待,以及取消是否真的能作用于底层操作。async/await 能让流程更容易阅读,但它不会改变这些边界。把这些问题纳入设计,异步代码才会在成功、失败和资源受限时都保持可预测。

© 版权声明

相关文章