异步 JavaScript 入门到进阶:Promise、async/await 与 fetch

从「为什么需要异步」讲起,一路到事件循环和竞态处理。基础同学可以顺着读下来,有经验的同学可以直接跳到后半段的坑和进阶。

异步 JavaScript 入门到进阶文章封面

从「为什么需要异步」讲起,一路到事件循环和竞态处理。基础同学可以顺着读下来,有经验的同学可以直接跳到后半段的坑和进阶。


一、为什么需要异步?

JavaScript 只有一个线程——同一时刻只能干一件事。这带来一个大麻烦:如果一件事很慢(比如向服务器要数据,可能要等好几秒),而你「站在原地等它做完」,那这几秒里整个页面都会卡死:点按钮没反应、动画停住、什么都动不了。

用厨房打个比方。厨房里只有一个厨师。他要煮一锅汤,汤要炖 10 分钟。有两种做法:

  • 同步(傻等):厨师站在锅前盯着,10 分钟里什么别的都不做。这期间来了客人点菜,他也不理——厨房「卡死」了。
  • 异步(聪明):厨师把汤放上灶,转头去切菜、摆盘、招呼客人。汤好了灶台会「叮」一声通知他,他再回来处理。

异步就是第二种做法:遇到慢的事情,不干等,先登记一个「做完了叫我」的回调,转头去干别的。那锅汤(网络请求、定时器、读文件)是由浏览器底层帮你在后台完成的,不占用你这唯一的厨师。

所以在 JS 里有一条铁律:永远不要让主线程傻等。异步不是什么高级技巧,而是单线程环境下的生存必需。


二、Promise 是什么?

既然是「做完了叫我」,那就需要一个东西来代表这件还没做完的事。这个东西就是 Promise(承诺)

你可以把 Promise 理解成一张取餐小票:你点了餐,还没做好,但店家先给你一张票。这张票有三种状态:

  • pending(等待中):餐还在做。
  • fulfilled(成功):餐做好了,你拿到了食物(一个结果值)。
  • rejected(失败):出问题了,比如食材没了(一个错误)。

小票一旦从「等待中」变成「成功」或「失败」,就定下来了,不会再变

拿到一张 Promise 小票后,用 .then 登记「成功了做什么」,用 .catch 登记「失败了做什么」:

const 小票 = 取餐();   // 返回一个 Promise

小票
  .then((食物) => {
    console.log("拿到了", 食物);   // 成功时执行
  })
  .catch((错误) => {
    console.log("出问题了", 错误); // 失败时执行
  });

console.log("我先去找个座位");     // 这行不会等餐,立刻执行

注意最后一行:登记完回调后,代码立刻往下走,不会卡在这里等餐。这就是异步——「登记回调,转头做别的」。


三、async / await:把异步写得像同步

.then 链一多就容易套很多层,读起来累。于是有了 async / await,让异步代码看起来像从上到下的同步代码。

规则很简单:

  • 在函数前加 async,它就变成一个「异步函数」(它一定返回一个 Promise)。
  • 在函数里用 await 等一个 Promise,代码会在这里暂停,等结果出来再继续往下。

同一件事,两种写法对比:

// 写法 A:.then 链
function 取餐流程() {
  取餐()
    .then((食物) => 加热(食物))
    .then((热餐) => 上桌(热餐))
    .catch((错误) => console.log("失败", 错误));
}

// 写法 B:async / await(更好读)
async function 取餐流程() {
  try {
    const 食物 = await 取餐();     // 暂停,等取餐完成
    const 热餐 = await 加热(食物);  // 再暂停,等加热完成
    上桌(热餐);
  } catch (错误) {
    console.log("失败", 错误);
  }
}

写法 B 读起来就是「取餐 → 加热 → 上桌」,一目了然。

关键提醒await 的「暂停」只是暂停这个 async 函数自己,并不会卡死整个页面——它把「后面的代码」登记成一个回调,控制权先还给浏览器去干别的,等结果回来再接着跑。这跟第一节说的「厨师转头做别的」是同一件事,只是语法上看起来像在「等」。


四、用 fetch 取数据(以及三个必踩的坑)

真实项目里,异步几乎都是在向服务器要数据。浏览器内置的 fetch 就是干这个的,它返回一个 Promise:

async function 加载文档() {
  const res = await fetch("/api/docs");  // 等服务器响应
  const data = await res.json();          // 把响应内容解析成 JS 对象
  console.log(data);
}

看起来简单,但有三个坑,不知道就一定会踩

坑一:fetch 对 4xx / 5xx 不会失败(最容易踩)

你以为服务器返回 500 错误,await fetch() 就会抛错进 catch不会。 fetch 只有在网络层挂了(断网、域名解析失败)时才 reject。服务器返回 404、500 这种「请求到了、但结果是错误」,fetch 照样算「成功」。

所以你必须手动检查 res.ok(只有 2xx 状态码它才是 true):

const res = await fetch("/api/docs");
if (!res.ok) {
  throw new Error(`服务器返回 ${res.status}`);  // 自己抛出来
}
const data = await res.json();

漏了这一步,页面就会拿着错误响应当正常数据用,出现莫名其妙的 bug。

坑二:fetch 是「两段式」

拿数据要 await 两次,很多人只写一次就懵了。原因是:

  • 第一个 await fetch(...) 只等到了响应头(服务器说「我要开始发数据了」)。
  • 数据本身(body)还在网络上传输,要再 await res.json() 才能拿到并解析。

而且 res.json() 自己也可能抛错(如果服务器发来的不是合法 JSON)。

坑三:fetch 没有「超时」功能

如果服务器一直不回应,fetch一直等下去,不会自动放弃。想要「等超过 3 秒就放弃」,得自己用 AbortController 做一个:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);  // 3 秒后按下"取消键"

try {
  const res = await fetch("/api/docs", { signal: controller.signal });
  // ...
} finally {
  clearTimeout(timer);  // 不管成功失败都要清掉定时器
}

controller 就像一个「取消按钮」,controller.signal 是连到 fetch 的那根线。谁按下 abort(),正在进行的 fetch 就会立刻失败(抛一个 AbortError)。

有意思的是:「超时」本质上就是「程序自己按下的取消」——用 setTimeout 在 3 秒后自动按一下取消键而已。所以「超时」和「用户手动点取消」用的是同一套机制。


五、用 try / catch 兜住错误

异步操作很容易失败(网断了、服务器挂了、数据格式错了)。用 try / catchawait 包起来,失败时给用户一个明确的提示,而不是让页面默默卡住或白屏:

async function 加载文档() {
  try {
    const res = await fetch("/api/docs");
    if (!res.ok) throw new Error(`服务器出错 ${res.status}`);
    const data = await res.json();
    显示文档(data);
  } catch (错误) {
    显示提示("加载失败:" + 错误.message);  // 让用户知道发生了什么
  }
}

两个原则:

  1. 永远不要写空的 catchcatch {})——它会把错误悄悄吞掉,让 bug 无迹可寻。
  2. 给用户看得懂的话,给开发者留完整的错误对象。

六、异步界面不是「加载中 / 完成」两种状态

学异步最容易犯的错,是用一个 isLoading 布尔值糊弄所有情况。实际上,一次请求是一个小状态机,可能走向好几种结局:

idle(初始)
  └→ loading(加载中)
       ├→ success(成功·有数据)
       ├→ success(成功·但没有数据)  ← 不是错误!应引导用户"去上传"
       ├→ error(网络失败 / 服务器出错)
       ├→ timeout(超时)             ← 应鼓励"重试"
       └→ aborted(用户主动取消)      ← 不是错误!别弹红色警告

三个最容易搞错的辨析:

  • 「没有数据」不是错误:请求成功了,只是结果是个空列表。应该显示「还没有文档,去上传第一个吧」,而不是报错。
  • 「用户取消」不是错误:这是用户自己的选择,安静地回到初始状态就好,弹红色警告反而莫名其妙。
  • 「超时」不等于「网络断了」:网络没断,只是我们等得不耐烦主动放弃了,文案应该鼓励重试。

想要给这几种情况不同的反馈,就得先能区分它们。做法是给错误打一个「种类」标签:

class ApiError extends Error {
  constructor(kind, message, status = null) {
    super(message);
    this.kind = kind;      // "network"|"http"|"timeout"|"aborted"
    this.status = status;  // HTTP 状态码,比如 500
  }
}

这样界面就能照着 kind 决定:是弹红条、还是显示引导、还是安静返回。用一个 phase 字段(idle/loading/success/error…)代替一堆布尔值,能保证任意时刻只处于一个状态,不会出现「又在加载又出错了」这种自相矛盾。这也正是后面学 React 状态管理的思维雏形。


七、(进阶)事件循环:宏任务与微任务

前面说「await 会把后面的代码登记成回调,转头做别的」。那这些回调按什么顺序被执行?这就是事件循环在管的事。它维护两张待办清单:

  • 宏任务(macrotask)setTimeout、DOM 事件回调、<script> 本体。一轮只取一个来做。
  • 微任务(microtask)Promise.then/catch/finallyawait 之后的续体、queueMicrotask。一轮会全部清空

一条铁律:

取一个宏任务 → 执行到底 → 清空全部微任务 →(渲染画面)→ 取下一个宏任务

记法:凡是跟 Promise 沾边的(含 async/await)都算微任务,定时器和用户事件算宏任务;微任务优先级更高。

来看一个经典例子,猜猜输出顺序:

console.log("1");
setTimeout(() => console.log("2"), 0);          // 宏任务
Promise.resolve().then(() => console.log("3"));  // 微任务
console.log("4");

// 输出:1 4 3 2
  • 14 是同步代码,先一口气跑完(<script> 本身就是当前这个宏任务)。
  • 当前宏任务结束后,先清空微任务 → 打印 3
  • 微任务清空了,才去取下一个宏任务 → 打印 2

注意 setTimeout(…, 0)0 不代表「马上执行」,只代表「尽快排进宏任务队列」——而微任务永远插在下一个宏任务前面,所以 32 早。

再看一个进阶的,理解「微任务能插到已经排好队的宏任务前面」:

setTimeout(() => {
  console.log("A");
  Promise.resolve().then(() => console.log("B"));
}, 0);
setTimeout(() => console.log("C"), 0);

// 输出:A B C
  • 两个 setTimeout 先排进宏任务队列:[第一个, 打印C的]
  • 取出第一个执行:打印 A,并产生一个微任务(打印 B)。
  • 第一个宏任务结束——铁律要求先清空微任务,所以 B 先打印。
  • 微任务清空了,才轮到早就在队列里等着的「打印 C」。

B 插到了 C 前面,尽管「打印 C」这个宏任务排队更早。这就是「微任务优先」的威力。


八、(进阶)单线程也躲不掉的一种竞态

你可能会想:只有一个线程、代码一句句执行,那总不会有「并发 bug」了吧?还真有一种——陈旧响应竞态。它藏在 await 里:

async function 加载文档(筛选条件) {
  const res  = await fetch(`/api/docs?filter=${筛选条件}`); // ← 暂停点
  const data = await res.json();                            // ← 暂停点
  渲染(data);   // 跑到这里时,用户可能早就切换成别的筛选了
}

设想用户先点「全部」,紧接着点「失败」,于是发出了两个请求。如果「全部」那个请求更慢、后回来,它就会把界面又覆盖回旧的「全部」数据——用户明明选的是「失败」,却看到了「全部」。

关键在于理解 await 是一个暂停点:暂停期间,别的代码(比如用户的第二次点击)会插进来跑。所以「发请求」和「渲染结果」这两个动作之间,世界已经变了。

解决办法不是加锁(这里没有内存被同时改写的问题),而是发新请求前,取消掉上一个还没回来的

if (inFlight) inFlight.abort();       // 取消上一个还在飞的请求
const controller = new AbortController();
inFlight = controller;
// ...用 controller.signal 发起新请求,旧的结果回来时会被丢弃

好消息是:后面学 React 时,useEffect 的清理函数、以及 TanStack Query 这类库会自动帮你做这件事。但先手写一遍,你才能真正明白框架在背后替你解决了什么。


总结

概念一句话
为什么要异步单线程,傻等会卡死整个页面,所以「登记回调,转头做别的」
Promise代表一件还没做完的事,有 pending/成功/失败三种状态
async / await把异步写得像同步;await 只暂停当前函数,不卡死页面
fetch 坑一4xx/5xx 不会失败,必须自己查 res.ok
fetch 坑二两段式,拿数据要 await 两次(响应头 + body)
fetch 坑三没有原生超时,靠 AbortController + setTimeout 自己做
try / catch兜住失败,给用户明确提示,别写空 catch
异步状态机不是一个布尔值,是 idle/loading/success/空/error/timeout/取消
事件循环一个宏任务 → 清空所有微任务 → 渲染 → 下一个宏任务
陈旧响应竞态await 是暂停点,跨过它世界会变;发新请求前取消旧的

异步是前端的分水岭。搞懂「单线程 + 登记回调」这个核心模型,再往上,Promise、async/await、fetch 都只是这套模型上的具体工具。后面学 React 的数据加载、加载状态、错误处理,全都建立在今天这些概念之上。

没有回应

    发表回复

    您的邮箱地址不会被公开。 必填项已用 * 标注