我一直以为 Ajax 是个黑盒,直到我写了这 50 行代码

0 阅读4分钟

昨天想写个最简单的待办列表,前端页面画好了,后端也用 Node 搭了个接口,结果死活拿不到数据,控制台还报了个红。我盯着屏幕看了十分钟,突然反应过来,我好像一直没真正搞懂 Ajax 到底是怎么回事。

这是我当时的项目结构,就俩文件夹,一个后端一个前端,简单到不能再简单了。

屏幕截图 2026-06-14 084417.png

说出来你可能不信,我用了这么久的 axios 和 fetch,从来没亲手写过一次原生的 XMLHttpRequest。总觉得这玩意儿是上古时代的东西,没必要学。结果真到自己写的时候,连基本的执行顺序都搞不清楚。

原来 Ajax 就是个 "跑腿的服务员"

以前我总觉得 Ajax 是个很高大上的词,后来才明白,它其实就是个专门负责传菜的服务员。

以前的网页啊,就像那种老式餐厅,你点个菜,服务员把单子拿到后厨,你就得坐着等,啥也干不了,等菜做好了,服务员端上来,整个桌子都给你换一遍。这就是传统的同步请求,点一下链接,整个页面刷新。

而 Ajax 呢,就是个专门负责传菜的服务员。你点了菜,他把单子拿到后厨,你该干嘛干嘛,刷刷手机聊聊天,等菜做好了,他悄咪咪把菜端到你桌子上,其他东西一点不动。这就是我们现在说的 "无刷新更新页面",也是 Web 2.0 时代能这么繁荣的根本原因。

说白了,Ajax 的本质就是让 JS 可以主动发起 HTTP 请求,然后根据返回的数据动态更新页面,不用刷新整个页面。

先写个能跑的最小后端

先看后端代码,用的是 Node 内置的 http 模块,几行代码就能起个服务器。

// backend/index.js
// node 内置的 http 模块
const http = require('http');

http.createServer((req, res) => {
    // 用户服务函数,每次有请求进来都会执行这里
    const todos = [
        { id: 1, title: '学习 node', completed: false },
        { id: 2, title: '学习 react', completed: false },
        { id: 3, title: '学习 vue', completed: false }
    ];

    if(req.url === '/'){
        res.end('hello world!');
    }

    if(req.url === '/todos'){
        // 注意这一行,坑了我半小时!解决跨域问题
        res.setHeader('Access-Control-Allow-Origin', '*');
        // 解决中文乱码问题,告诉浏览器我返回的是JSON格式,用utf-8编码
        res.setHeader('Content-Type', 'application/json; charset=utf-8');
        // 把对象转成JSON字符串,格式化输出,方便调试
        res.end(JSON.stringify(todos, null, 2));
    }
}).listen(3000, () => {
    console.log('server is running at port 3000');
});

然后在 backend 目录下执行node index.js,服务器就起来了。打开浏览器访问http://localhost:3000/todos,就能看到返回的 JSON 数据了。

这里的JSON.stringify第三个参数是空格数,用来格式化输出 JSON,团队开发的时候一般都会加,可读性会好很多。

前端用原生 XHR 发请求

前端这边,最原始的 Ajax 实现就是用 XMLHttpRequest 对象,简称 XHR。说实话,我第一次看到这个名字的时候,以为它只能处理 XML,结果现在大家都用 JSON 了,这名字算是历史遗留问题了。

<!-- frontend/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Ajax Demo</title>
</head>
<body>
    <ul id="todos"></ul>
    <button id="btn">按钮</button>
    <script>
        console.log('start');

        // 事件注册
        document.getElementById('btn')
            .addEventListener('click', () => {
                console.log('点击按钮');
            })

        // 实例化一个 xhr 对象
        const xhr = new XMLHttpRequest();
        // 打开一个 http 通道,三个参数:请求方法、请求地址、是否异步
        xhr.open('GET', 'http://localhost:3000/todos', true);
        // 发送请求
        console.log('start send');

        // 回调函数,当xhr的状态发生变化时触发
        xhr.onreadystatechange = function() {
            console.log('readyState:', xhr.readyState);
            // 必须同时满足这两个条件,才表示请求成功
            if(xhr.status === 200 && xhr.readyState === 4) {
                const todos = JSON.parse(xhr.responseText);
                // 动态更新页面
                document.getElementById('todos').innerHTML = 
                    todos.map(item => `<li>${item.title}</li>`).join('');
            }
        }

        xhr.send();
        console.log('end');
    </script>
</body>
</html>

最让我懵的执行顺序

我当时写这段代码的时候,最懵的就是这个执行顺序。你们看,我先打印了'start',然后调用 xhr.send (),然后打印 'end'。我一开始以为输出顺序应该是:

start
start send
// 等待请求完成
readyState: 2
readyState: 3
readyState: 4
end

结果控制台一跑,直接给我整懵了。实际输出是:

start
start send
end
readyState: 2
readyState: 3
readyState: 4

怎么 send () 之后直接就打印 end 了?请求还没发完呢?

后来才搞明白,JS 是单线程的,就像一个只有一个窗口的银行,所有人都得排队办理业务。如果遇到一个需要等很久的业务,比如办房贷,总不能让后面的人都等着吧?所以银行会把这个客户带到旁边的休息室,让他等着,先给后面的人办理。等房贷审批完了,再叫这个客户过来继续办。

JS 也是一样的,遇到像网络请求这种异步任务,不会傻等着它完成,而是把它放到事件循环队列里,先执行后面的同步代码。等异步任务完成了,再从队列里把它拿出来执行回调函数。

所以 send () 只是把请求发出去了,然后就继续往下执行了,不会等响应回来。这就是为什么 end 会在 readyState 之前打印。

这是新手最容易踩的坑!我一开始想把请求返回的数据存到一个全局变量里,然后在 send () 之后直接使用,结果永远是 undefined。就是因为执行到后面那行代码的时候,请求还没回来呢。

再聊聊 readyState 到底是什么

再看这个 onreadystatechange 事件,它会在 XHR 对象的状态发生变化的时候触发。readyState 有 5 个值,从 0 到 4:

  • 0: 未初始化,还没调用 open ()
  • 1: 启动,已经调用 open (),还没调用 send ()
  • 2: 发送,已经调用 send (),还没收到响应
  • 3: 接收,正在接收响应数据
  • 4: 完成,响应数据接收完毕

所以我们必须判断readyState === 4,并且status === 200,才能确定请求成功了,然后才能处理返回的数据。

我踩过的那些坑

说几个我当时踩的坑吧,估计很多人都遇到过:

  1. 跨域问题:我一开始后端代码里没加那行Access-Control-Allow-Origin,结果控制台直接报了个 CORS 错误。前后端端口不一样,浏览器出于安全考虑,会阻止跨域请求,这是前端开发最常见的问题之一。
  2. 中文乱码:我一开始没设置Content-Type头,结果返回的中文全是问号。必须告诉浏览器我返回的是什么格式,用什么编码,它才能正确解析。
  3. 异步执行顺序:就是刚才说的那个,想在 send () 之后直接使用返回的数据,结果永远是 undefined。

现在我们都用 fetch 了

现在其实很少有人直接用 XHR 了,大部分人都用 fetch API,它是 ES6 之后出来的,基于 Promise,写起来更简洁。

// 把上面的XHR代码换成fetch,是不是清爽多了?
fetch('http://localhost:3000/todos')
    .then(res => res.json())
    .then(data => {
        document.getElementById('todos').innerHTML = 
            data.map(item => `<li>${item.title}</li>`).join('');
    })

而现在最推荐的写法是 async/await,它可以让你像写同步代码一样写异步代码,可读性大大提高:

async function getTodos() {
    const res = await fetch('http://localhost:3000/todos');
    const data = await res.json();
    document.getElementById('todos').innerHTML = 
        data.map(item => `<li>${item.title}</li>`).join('');
}

getTodos();

最后说几句

今天折腾了一下午,算是把 Ajax 的底层逻辑搞明白了。其实最核心的就三点:第一,Ajax 就是让 JS 能主动发起 HTTP 请求,然后无刷新更新页面,这是现代前端的基础。第二,JS 是单线程的,所有异步任务都会放到事件循环里处理,执行顺序一定要搞清楚,不然很容易出 bug。第三,前后端分离的本质就是前后端通过 HTTP 接口交换数据,后端负责提供数据,前端负责展示数据。

当然,XHR 也不是万能的,它有很多缺点,比如 API 太繁琐,容易写出回调地狱。所以现在大家都用 fetch 或者 axios 这样的库。不过了解 XHR 的原理还是很有必要的,毕竟所有上层的封装都是基于它的。

如果你也像我一样,之前一直稀里糊涂地用 Ajax,今天看完终于搞懂了,记得回来留个言,我也想看看你的理解。