一个50行的demo,串起了JS异步的整条演进线

42 阅读3分钟

学前端一定会撞上异步。

我第一次用 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 === 4status === 200 状态到 4 才代表数据完整,状态码 200 才代表请求成功。这两个有一个没满足就去读 responseText,结果要么是空的,要么是不完整的。

open() 的第三个参数 true 是异步开关。设了 true,send() 发完立刻往下跑,不会停在那等服务器。所以你会先看到 startend 的 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 请求在底层线程里跑。只是你不用再手动管这些了。

你在异步上踩过的最深的坑是什么?评论区聊聊,说不定有人跟你掉过同一个洞里。