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

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

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

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

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

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

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

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

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

先写个能跑的最小后端

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

javascript 复制代码
// 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 了,这名字算是历史遗留问题了。

html 复制代码
<!-- 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'。我一开始以为输出顺序应该是:

plaintext 复制代码
start
start send
// 等待请求完成
readyState: 2
readyState: 3
readyState: 4
end

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

plaintext 复制代码
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,写起来更简洁。

javascript 复制代码
// 把上面的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,它可以让你像写同步代码一样写异步代码,可读性大大提高:

javascript 复制代码
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,今天看完终于搞懂了,记得回来留个言,我也想看看你的理解。

相关推荐
newbe365241 小时前
我们如何使用 impeccable 优化前端界面设计与实现稳定性
前端·人工智能·分布式·github·aigc·wpf
KaMeidebaby8 小时前
卡梅德生物技术快报|蛋白 N 端测序在重组贻贝融合蛋白表征中的应用,解决原核表达序列偏移工艺难题
前端·人工智能·物联网·算法·百度
独孤九剑打醒他8 小时前
双层Master-Worker软硬协同调度架构:从根源解决分布式数据一致性难题
后端·嵌入式硬件·硬件架构·硬件工程
kyriewen9 小时前
我筛了 1400 个 Claude Code Skills,留下 5 个天天在用的
前端·ai编程·claude
JNX_SEMI9 小时前
AT2401C 2.4GHz 全集成射频前端单芯片技术解析
前端·单片机·嵌入式硬件·物联网·硬件工程
不会c+9 小时前
02-SpringBoot配置文件
java·spring boot·后端
anOnion9 小时前
Agentic 前端开发之 实时显示 AI Agent 终端输出
前端·javascript·人工智能
随风一样自由9 小时前
【前端领域】2026最新前端领域全梳理(框架/工具/AI/跨端/底层标准/就业趋势)
前端·人工智能·前端框架
这是个栗子10 小时前
【前端性能优化】优化数据加载:用 Promise.all 从串行到并行
前端·javascript·性能优化·异步编程·前端优化·promise.all
fei_sun10 小时前
黑洞路由(Null Route/空接口路由)
服务器·前端·javascript