学前端一定会撞上异步。
我第一次用 XMLHttpRequest 的时候,代码写好了,数据也返回了,但页面就是不动。折腾了半天发现,我在 readyState === 3 的时候就去读了 responseText——数据还没传完。
后来跟朋友聊,发现他不是一个人。很多人在异步上踩的坑,本质上不是不会写,是没串起来——XHR、Promise、async/await 到底怎么一步一步过来的?Event Loop 到底在中间干了什么?
这篇文章用一个很小的 Node.js + 前端 demo 把这条线串起来。不追求大而全,就把几个关键节点的来龙去脉讲清楚。
一、JSON.stringify:序列化这件事
网络传二进制,JS 对象在内存里。要把对象发出去,先得变成字符串。
const todos = [{
id: "1",
title: "学习node",
completed: false
}, {
id: "2",
title: "学习esm",
completed: false
}];
res.end(JSON.stringify(todos));
JSON.stringify 三个参数:value(要序列化的值)、replace(传 null 保留全部)、space(缩进空格数)。服务端 JSON.stringify 发出去,客户端 JSON.parse 收回来——数据交互的基本循环就这么简单。
space 参数有个实用场景:调试期开 JSON.stringify(data, null, 2),控制台里结构一目了然,比眯着眼睛找字段强多了。生产环境关掉省带宽——这是团队规范里值得约定的小细节。
二、XHR:readyState 的生命周期
XMLHttpRequest,简称 XHR。微软 Outlook Web Access 团队最早搞出来的,后来浏览器全跟进了,直接点燃了 Web 2.0 时代——在这之前,页面更新只能靠刷新。
const xhr = new XMLHttpRequest();
xhr.open('GET', 'http://localhost:3000/todos', true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
const todos = JSON.parse(xhr.responseText);
document.getElementById('todos').innerHTML =
todos.map(todo => `<li>${todo.title}</li>`).join('');
}
};
xhr.send();
XHR 的核心是 readyState。一个请求从创建到完成,会经历五个状态:
readyState
枚举
含义
0
UNSENT
还没 open
1
OPENED
已 open,通道打开
2
HEADERS_RECEIVED
响应头已收到
3
LOADING
正在接收响应体
4
DONE
全部完成
两个条件缺一不可:readyState === 4 且 status === 200。 状态到 4 才代表数据完整,状态码 200 才代表请求成功。这两个有一个没满足就去读 responseText,结果要么是空的,要么是不完整的。
open() 的第三个参数 true 是异步开关。设了 true,send() 发完立刻往下跑,不会停在那等服务器。所以你会先看到 start 和 end 的 console,然后才看到回调里的内容——不是因为代码写错了,而是异步机制在起作用。
这种通过 onreadystatechange 注册一个函数、等状态到了再被调用的方式,就是**回调函数(Callback)**模式。简单、直接,但在复杂场景下会暴露问题。
三、Event Loop:单线程为什么能干异步的事
JavaScript 是单线程的。每次只能做一件事。
那 xhr.send() 发出去了,谁在等服务器响应?如果 JS 引擎自己在等,页面早就卡死了。
答案是:JS 引擎不负责等。它把异步任务丢给浏览器的底层线程(或 Node.js 的 libuv 线程池),自己继续跑后面的代码。 底层线程完成后,对应的回调被塞进任务队列(Task Queue)。
Event Loop 的工作很简单:
while (true) {
执行调用栈中的所有同步代码;
if (调用栈为空 && 任务队列里还有回调没执行) {
取出一个回调,压入调用栈执行;
}
}
一句话概括:同步代码先跑完,异步回调排队等。
这个机制是理解一切异步行为的基础。setTimeout(fn, 0) 为什么不是立即执行?Promise 的回调为什么比 setTimeout 先执行?这些问题的答案都藏在 Event Loop 里。
四、Promise 和 fetch:把嵌套变成链条
XHR 在简单场景下够用,但一旦多个请求有依赖关系,代码就变成这样:
// 请求A → 请求B → 请求C
xhr1.onreadystatechange = function() {
if (xhr1.readyState === 4) {
xhr2.onreadystatechange = function() {
if (xhr2.readyState === 4) {
xhr3.onreadystatechange = function() {
// 缩进已经突破天际了
};
xhr3.send();
}
};
xhr2.send();
}
};
xhr1.send();
回调地狱(Callback Hell)——嵌套越来越深,读起来费劲,错误处理要每层单独写。
ES6 的 Promise 改变了这种写法。Promise 代表一个未来才会完成的操作,三种状态:pending(等待中)、fulfilled(已完成)、rejected(已失败)。状态一旦翻转就不可逆,然后触发 .then() 或 .catch()。
fetch 是现代浏览器内置的 Promise 版 HTTP API:
fetch('http://localhost:3000/todos')
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error('请求失败:', err));
.then() 把嵌套拉成了链条,.catch() 一个地方兜底所有错误。不用在每层回调里单独处理异常了。
老实说,Promise 链也不是完美的。链条长了依然要跳着读——每个 .then() 的返回值是下一个 .then() 的输入,心里得一直默念上下文。这就是下一个方案要解决的问题。
五、async/await:用同步的样子写异步
ES2017 的 async/await 是 Promise 的语法糖。本质没变,但写法彻底变了:
async function fetchTodos() {
try {
const res = await fetch('http://localhost:3000/todos');
const data = await res.json();
document.getElementById('todos').innerHTML =
data.map(todo => `<li>${todo.title}</li>`).join('');
} catch (err) {
console.error('请求失败:', err);
}
}
fetchTodos();
await 会等 Promise 完成,然后把值直接赋给左边变量。代码从上往下读,不用跳,不用在脑子里维护链条上下文。错误处理用原生 try/catch——跟同步代码的习惯完全统一。
三种方案对比:
方案
核心机制
代码风格
错误处理
诞生时间
XHR + Callback
回调函数
嵌套、割裂
每层处理
2006
fetch + Promise
Promise 链
链式、拉平
.catch()
2015
fetch + async/await
Promise 语法糖
同步式
try/catch
2017
这条线走下来,每一步都在解决前一步真实的痛点。
六、CORS:跨域那道坎
demo 的后端里有一行:
res.setHeader('Access-Control-Allow-Origin', '*');
浏览器的同源策略会拦截不同端口、域名、协议之间的请求。前端跑 5500,后端跑 3000——浏览器看来就是跨域,默认不让访问。
这个响应头就是服务端表态:"我允许。" * 表示放行所有来源,开发阶段省事,生产环境换成具体域名。
前后端分离的项目,这个头不写,接口通了页面也白屏。属于那种"配置对了没感觉,配置错了卡半天"的东西。
七、CommonJS → ESM:同样在演进的模块系统
const http = require('http'); // CommonJS
require/module.exports 是 Node.js 早期的模块方案。现在标准是 import/export(ESM)。
模块系统和异步编程的演进轨迹几乎一模一样——先有社区方案跑起来,用的人多了、痛够了,再推标准化方案。理解这条规律,再看 JavaScript 生态的变化就不会觉得乱。
如果你刚开始学,建议按这个顺序走
XHR(手写感受回调)→ fetch + Promise(理解链式)→ async/await(享受语法糖)
后面的方案都是前一个方案的封装。先知道底层在干什么,再用语法糖,出问题的时候心里有底——至少知道从哪里开始排查。
await fetch() 这短短一行,下面是 Event Loop 在轮询,Promise 在翻转状态,HTTP 请求在底层线程里跑。只是你不用再手动管这些了。
你在异步上踩过的最深的坑是什么?评论区聊聊,说不定有人跟你掉过同一个洞里。