1. 问题现场:一次看似普通却让人头疼的异步顺序错乱
先聊一个我实际踩过的坑。大概是去年下半年,我在给一个内部管理系统做前端页面,需求倒是不复杂:进入某个商品详情页时,需要先请求一遍“用户最近浏览记录”接口,拿到记录后把里面的最后一条商品ID取出来,再用这个ID去请求“商品详情”接口,最后把两份数据都渲染进页面里。
我当时很“自信”,直接在 mounted 里按顺序写了两个 $.ajax 调用,代码大概是这样的:
javascript复制let lastViewId = null;
$.ajax({
url: '/api/recent-view',
success: function (res) {
lastViewId = res.data[res.data.length - 1].goodsId;
}
});
$.ajax({
url: '/api/goods/detail',
data: { id: lastViewId },
success: function (res) {
renderPage(res.data);
}
});
结果呢?第一次渲染出来的商品详情,永远是一个空壳,偶尔能跑出来一次,但也对不上最近浏览的那条记录。后来我在控制台打印日志才明白过来:第二个请求在发出的时候,lastViewId 还是 null,因为第一个请求根本还没回来。这就是最典型的“ajax 异步执行顺序错误”——你以为代码从上往下写,就一定会从上往下执行,实际上 ajax 请求发出去之后,响应什么时候回来并不由你控制。
这个问题看起来很简单,但它背后牵扯到 JavaScript 的事件循环机制、回调时机、以及我们日常写业务代码时最容易忽略的一个事实:ajax 请求不是函数调用,你没法保证“先请求的一定先返回”。
这篇文章我会结合自己实际排查和修复的经验,把这类“执行顺序错乱”的问题从原理到实战完整梳理一遍,包括为什么会出现、常见的几种表现形态、以及我在不同场景下分别采用的几种解法。如果你是刚接触前后端联调不久,或者虽然写了一阵子 ajax 但老是遇到“数据没拿到就往下走”的怪问题,这篇文章应该能帮你省下不少调试时间。
注意:下文所有接口地址和返回结构均为演示用,实际项目中请以你自己的后端契约为准。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞懂为什么:ajax 到底是怎样“乱序”的
2.1 异步不等于“排队等待”
要理解执行顺序为什么会乱,得先回到 JavaScript 的执行模型。JavaScript 是单线程语言,同一时间只能干一件事。但浏览器环境给我们提供了 Web API(比如 XMLHttpRequest、fetch、定时器等),这些 API 的底层逻辑并不占用 JavaScript 主线程。
当一个 ajax 请求发出去后,浏览器会把它交给网络线程去处理,JavaScript 主线程不会停下来等响应,而是继续往下执行后面的代码。等网络线程收到了服务器的响应,它会把对应的回调函数塞进一个叫“任务队列”(Task Queue)的地方。主线程当前的任务都执行完了,才会从队列里把回调取出来执行。
这就是为什么你写了两个 ajax,第二个请求会在第一个响应回来之前就发出去的原因。它们俩都是“发完就撒手不管”,谁先回来谁先进入回调队列,而不是严格按照你写的代码顺序。
2.2 请求并发导致的竞态条件
再看一个我在实际项目里经常遇到的场景:页面上有一个“搜索”按钮,用户可能连续点击好几次,每次点击都会发送一个搜索请求。比如第一次点了“苹果”,第二次点了“香蕉”,由于网络波动或后端处理速度不同,很可能“香蕉”的响应先回来了,界面显示了“香蕉”的结果,几秒后“苹果”的响应也回来了,又把界面覆盖成了“苹果”的结果。
这时页面上显示的内容和用户最后的选择是不一致的。更麻烦的是,如果你在回调里做了“加载状态”控制,就会出现 loading 提前关闭、但后续又有数据覆盖的诡异现象。
这就是典型的竞态条件(Race Condition)。本质原因是 多个异步操作的完成顺序无法预测,而我们的业务逻辑却默认“最后一次操作的结果应该最后显示”。
2.3 回调嵌套带来的“代码顺序”错觉
另一类顺序错乱其实不发生在“多个独立请求”之间,而是发生在“有依赖关系的请求”之间——就像我在开头举的那个例子。代码里看起来是先写 A 请求、再写 B 请求,但因为 A 的响应还没回来,B 请求就已经带着 null 或 undefined 发出去了。
老手会想到用回调嵌套来解决:
javascript复制$.ajax({
url: '/api/recent-view',
success: function (res) {
let lastViewId = res.data[res.data.length - 1].goodsId;
$.ajax({
url: '/api/goods/detail',
data: { id: lastViewId },
success: function (res) {
renderPage(res.data);
}
});
}
});
这样确实保证了顺序:第二个请求在第一个的成功回调里才发出去,响应数据也有了。但这样写的代价是代码嵌套层级变深,如果业务链路上有三四个请求,就会出现“回调地狱”,代码可读性直线下降。
所以“ajax 执行顺序错误”的表面问题是 响应时机不可控,但深层的工程问题其实是 我们缺少一套对异步流程进行编排的工具。
| 表现形态 | 背后的原因 | 解决方向 |
|---|---|---|
| 有依赖的请求拿到 undefined 参数 | 代码顺序 ≠ 执行顺序,B 请求在 A 回调前发出 | 回调嵌套、Promise 链、async/await |
| 多个并发请求结果互相覆盖 | 响应返回顺序不可预测 | 请求序号比对、AbortController 取消竞态请求 |
| loading 关闭后还有数据更新 | 回调执行时机晚于预期 | 竞态标志位管理 |
| 回调地狱导致代码不可维护 | 层层嵌套、逻辑分散 | 用 Promise 和 async/await 重新组织 |
3. 核心解法一:从回调到 Promise,再到 async/await
3.1 用 Promise 封装一次 ajax 请求
在还没有 fetch 的年代,XMLHttpRequest 本身是不支持 Promise 的,jQuery 的 $.ajax 虽然返回了一个 jqXHR 对象,但它本质上还是回调风格。前端社区后来把这类异步操作封装成 Promise,才让“顺序编排”变得容易。
比如我们可以封装一个简单的 request 函数:
javascript复制function request(url, options = {}) {
return new
