JS 异步编程入门:理解 Promise 和 async/await

星座4天前发布 jdkjadmin
1 00
JS 异步编程入门:理解 Promise 和 async/await

JS 异步编程入门:理解 Promise 和 async/await

读取文件、请求接口、等待定时器,都是程序要等待某件事完成后才能继续的操作。如果等待期间整个 JavaScript 程序都停下来,页面会卡住,服务器也无法及时处理其他请求。JavaScript 的异步编程提供了一种处理等待的方式:先发起任务,继续做其他工作,任务完成后再处理结果。

Promise 是表达异步结果的对象,async/await 则提供了更接近同步代码的写法。两者并非两套互不相关的机制:async/await 建立在 Promise 之上。理解 Promise 的状态、错误传播和并发行为,才能在使用 await 时避免误判。

一、什么情况下需要异步编程

异步编程常用于等待外部操作完成,例如:

  • 向服务器发送 HTTP 请求,等待响应;
  • 在浏览器中读取文件或使用设备能力;
  • 在 Node.js 中读取文件、访问数据库或调用网络服务;
  • 延迟执行任务,或等待其他异步操作完成。

异步不等于“自动开一个线程”。浏览器和 Node.js 通常会把网络、定时器等操作交给运行环境处理;操作完成后,JavaScript 才会在合适的时机执行对应回调。JavaScript 主线程上的同步代码仍然一次执行一段,长时间计算仍可能阻塞页面或事件循环。

下面是一个最简单的等待示例。在浏览器控制台或 Node.js 中都可以运行:

console.log("开始");

setTimeout(() => {
  console.log("定时器回调");
}, 0);

console.log("结束");

通常输出顺序是“开始”“结束”“定时器回调”。即使延迟设为 0,回调也不会插入当前同步代码中间;它要等当前代码执行完成后才有机会运行。定时器的延迟表示最早可执行的时间,不保证回调恰好在指定毫秒执行。

二、Promise 表示一个未来的结果

Promise 可以理解为一个代表异步操作最终结果的对象。它有三种状态:

  • pending:等待中;
  • fulfilled:已成功,携带一个结果值;
  • rejected:已失败,携带一个拒绝原因,通常是错误对象。

Promise 一旦从等待状态变为成功或失败,就不会再改变状态。调用 resolve 或 reject 多次,只有第一次有效。需要注意的是,Promise 的状态不等于任务已经取消或停止;取消通常需要底层 API 自己提供机制。

下面用定时器构造一个 Promise。保存为 JavaScript 文件后,可在 Node.js 中运行:

function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(() => resolve("等待完成"), ms);
  });
}

wait(300).then((message) => {
  console.log(message);
});

new Promise 的执行器函数会立即同步执行,并不是等到 then 时才执行。Promise 通常用来包装一个正在进行或稍后完成的操作,不适合把同步计算仅仅包一层就称为异步计算。

console.log("A");

const promise = new Promise((resolve) => {
  console.log("执行器");
  resolve(1);
});

promise.then((value) => {
  console.log("then:", value);
});

console.log("B");

输出顺序是“A”“执行器”“B”“then: 1”。即使 Promise 已经成功,then 注册的处理函数也会异步执行,不会在当前调用栈中立即运行。Promise 的处理函数属于微任务;微任务通常会在当前同步代码结束后、下一轮定时器等任务执行前处理。

三、用 then、catch 和 finally 处理结果

then 为成功结果注册处理函数,也可以接收第二个参数处理拒绝;catch 是处理拒绝的常用写法;finally 用于清理工作,例如结束加载提示。它们都会返回新的 Promise,因此可以串成链。

function divideAsync(a, b) {
  return new Promise((resolve, reject) => {
    if (b === 0) {
      reject(new Error("除数不能为 0"));
      return;
    }

    resolve(a / b);
  });
}

divideAsync(10, 2)
  .then((result) => {
    console.log("结果:", result);
    return result * 2;
  })
  .then((value) => {
    console.log("加倍:", value);
  })
  .catch((error) => {
    console.error("操作失败:", error.message);
  })
  .finally(() => {
    console.log("流程结束");
  });

在 then 中返回普通值,下一个 then 会收到这个值;返回 Promise,下一个处理函数会等待它完成;抛出异常或返回拒绝的 Promise,错误会沿链向下传递,直到遇到能够处理它的 catch。

catch 处理错误后,如果正常返回一个值,链条会转为成功状态。这在“记录错误后使用备用值”时很有用,但如果只是打印错误然后继续,可能会让调用方误以为操作成功。确实需要让上层知道失败时,应在处理后重新抛出错误。

loadData()
  .catch((error) => {
    console.error("加载数据失败", error);
    throw error;
  })
  .then((data) => {
    // 只有成功时才会执行
    render(data);
  });

finally 通常不接收成功值或拒绝原因,其返回值也不会替换原结果。不过,如果 finally 内部抛出异常或返回拒绝的 Promise,最终结果会变成拒绝。因此,不要在清理代码中无意抛错,否则可能遮盖原来的错误。

四、async/await:用顺序写法组织异步流程

在函数前加上 async,函数就会始终返回 Promise。函数中 return value 等价于让返回的 Promise 成功得到这个值;函数内未捕获的异常会让返回的 Promise 失败。

async function getNumber() {
  return 42;
}

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

await 通常写在 async 函数内部。它会等待一个 Promise 成功,并取出结果;如果 Promise 拒绝,就会在该位置抛出异常。await 也可以接收普通值,此时效果相当于等待一个已成功的 Promise。

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 不会阻塞整个 JavaScript 线程。它暂停的是当前 async 函数后续部分的执行;运行环境仍可处理其他事件。等待完成后,函数从暂停处继续。调用 async 函数本身也不会自动等待它结束,调用方仍需使用 await、then,或明确处理其拒绝。

可以用 try/catch/finally 组织错误处理和清理逻辑:

async function loadProfile() {
  showLoading();

  try {
    const profile = await fetchProfile();
    renderProfile(profile);
    return profile;
  } catch (error) {
    console.error("读取个人资料失败", error);
    showError("暂时无法加载");
    throw error;
  } finally {
    hideLoading();
  }
}

loadProfile().catch(() => {
  // 在调用边界处理或上报错误
});

如果 catch 中不重新抛出异常,loadProfile 返回的 Promise 会以成功状态结束,即使请求实际上失败了。是否重新抛出,取决于调用方是否还需要知道失败,以及错误是否已经被完整处理。

五、串行还是并行:别让 await 意外降低效率

当第二个操作必须使用第一个操作的结果时,应当串行等待。例如先获取用户,再根据用户编号读取订单:

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

如果两个操作彼此独立,逐个 await 会让它们依次开始,整体耗时通常接近两者耗时之和。可以先启动两项工作,再用 Promise.all 等待:

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

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

  return { profile, notices };
}

Promise.all 接收可迭代对象,例如数组,结果按输入顺序排列,而不是按完成先后排列。所有输入成功时,它成功返回结果数组;任意一个输入拒绝时,返回的 Promise 就会拒绝。它不会因此自动取消其他仍在进行的操作。传入空数组时,它会成功得到空数组。写错参数类型时,可能抛出同步异常,因此仍应使用正确的可迭代参数。

当需要收集每项任务的成功或失败,而不是遇到第一个失败就整体拒绝,可以使用 Promise.allSettled:

const outcomes = await Promise.allSettled([
  fetchProfile(),
  fetchNotices()
]);

for (const outcome of outcomes) {
  if (outcome.status === "fulfilled") {
    console.log("成功:", outcome.value);
  } else {
    console.log("失败:", outcome.reason);
  }
}

Promise.race 会随最先完成(无论成功或失败)的输入确定结果;Promise.any 会在任意一个输入成功时成功,只有全部拒绝时才拒绝。这些方法解决的是“结果如何组合”,不是“如何限制同时运行的任务数”。如果要批量处理大量任务,应设计并发上限,避免同时发起过多请求或占用过多资源。

六、用 fetch 请求数据时的边界

浏览器中的 fetch 返回 Promise,常见写法如下:

async function loadJson(url) {
  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`HTTP 错误:${response.status}`);
  }

  return response.json();
}

loadJson("/api/items")
  .then((data) => console.log(data))
  .catch((error) => console.error("请求或解析失败:", error));

一个常见误解是:服务器返回 404 或 500,fetch 就会自动拒绝。通常不会。只要请求顺利得到 HTTP 响应,fetch 就会兑现为 Response;应检查 response.ok 或状态码。网络错误、请求被中止等情况则可能导致 Promise 拒绝。调用 response.json() 也是异步操作,如果响应内容不是有效 JSON,它会失败。

请求失败时是否重试、重试哪些状态码、是否展示响应正文,都需要由应用根据接口约定决定。不要无条件无限重试:服务端可能仍不可用,请求也可能产生重复副作用。对于会创建订单、提交付款等有副作用的操作,重试策略尤其需要与服务端的幂等设计配合。

七、超时和取消不是一回事

让 Promise 在规定时间后拒绝,可以让调用方停止等待;但这本身不会取消底层工作。对于支持 AbortSignal 的 API,例如浏览器 fetch,可以使用 AbortController 请求中止:

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

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

fetchWithTimeout("/api/items", 5000)
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 错误:${response.status}`);
    }
    return response.json();
  })
  .then((data) => console.log(data))
  .catch((error) => console.error("请求失败或被中止:", error));

这里的计时器负责调用 abort(),真正的中止依赖目标 API 是否支持传入的信号。即便本地请求被中止,也不能据此推断服务器没有收到请求或没有开始处理;它只表示客户端请求中止,不保证远端操作回滚。其他异步 API 若不支持取消机制,就无法仅靠 Promise 强行终止。

八、常见错误及其原因

1. 忘记返回 Promise

在 Promise 链中,如果启动异步操作却不返回它,外层链条就无法等待其完成,也无法接住它的拒绝:

// 不推荐:外层链条不会等待 saveData()
function saveAndRefresh(data) {
  return validate(data)
    .then((valid) => {
      saveData(valid);
    })
    .then(() => {
      refreshView();
    });
}

// 推荐:返回异步操作,让链条继续等待
function saveAndRefresh(data) {
  return validate(data)
    .then((valid) => {
      return saveData(valid);
    })
    .then(() => {
      refreshView();
    });
}

2. 误以为 forEach 会等待 async 回调

Array.prototype.forEach 不会等待回调返回的 Promise。需要逐项串行处理时,可使用 for...of;需要同时启动并等待时,可使用 Promise.all。

async function processInOrder(items) {
  for (const item of items) {
    await processItem(item);
  }
}

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

第二种写法会同时启动所有任务,不适合没有并发限制的大型列表。若任务之间依赖顺序,或接口有频率限制,应采用串行或有上限的并发策略。

3. 忘记处理拒绝

调用异步函数却既不 await,也不 catch,失败时可能形成未处理的 Promise 拒绝。应在合适的调用边界统一处理错误,而不是到处吞掉异常。浏览器和 Node.js 对未处理拒绝的报告方式可能不同,但都不应把它当作正常控制流程。

4. 把 await 写在不支持的位置

传统脚本中,await 需要位于 async 函数内。部分 JavaScript 环境支持模块顶层 await,但是否可用取决于运行环境与模块类型;为便于复用,通常可以把流程放入 async function main() 并显式调用。

5. 用 Promise 构造器包装已有 Promise

已有的 Promise 通常直接返回或组合即可。额外包一层执行器容易遗漏错误处理,也让代码更难读。只有在把回调式 API 转换成 Promise,或确实要自行控制 resolve/reject 时,才需要使用 Promise 构造器。

九、异步编程与安全的关系

Promise 和 async/await 只改变任务的组织与等待方式,不会自动验证输入、消毒 HTML、加密数据或提供权限控制。输入校验、输出编码、身份认证和授权,仍需按照具体应用的安全要求分别实现。不要因为一段代码使用了 await,就认为它天然安全。

同样,浏览器前端代码中的密钥、权限判断或隐藏的请求参数,都不能作为可靠的安全边界:用户可以查看和修改客户端代码,也可以自行构造请求。敏感凭据和关键授权判断应放在可信的服务端,并由服务端验证请求权限。通过 Promise 捕获异常也不等于安全地处理了错误;面向用户的错误信息应避免泄露不必要的内部细节,诊断信息则应通过适当的日志机制记录。

十、如何选择写法

需要表达连续的异步步骤时,async/await 通常更容易阅读,适合分支、循环和 try/catch。需要组合一组 Promise 时,Promise.all、Promise.race 等方法更直接。两种写法可以混用,但应保持错误处理清楚:每一个拒绝最终都要在合适的层级处理或继续抛出。

编写异步代码时,可以逐项检查:任务是否真的会返回 Promise;后续步骤是否需要前一步的结果;独立任务是否适合并行;失败是否会传递到调用方;超时是否只停止等待,还是底层 API 确实支持取消;批量任务是否需要限制并发。把这些边界想清楚,Promise 和 async/await 就不只是语法上的简化,而是可预测、可维护的异步流程工具。

© 版权声明

相关文章