我一直以为 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,今天看完终于搞懂了,记得回来留个言,我也想看看你的理解。

相关推荐
审小匠OpenCPAi4 分钟前
银行流水核查怎么自动化?单边匹配、双向勾稽与图聚类异常检测的工程对比
java·前端·人工智能·python·审计
用户81655877632577 分钟前
领域模型驱动设计解析(基于elpis框架)
全栈
Revolution618 分钟前
一个公共表格组件,是怎么一步步失控的
前端·前端工程化
腻害兔23 分钟前
【若依项目-产品经理视角】RuoYi-Vue-Pro 源码拆解:CRM 客户关系模块深度解析——从线索到回款,一套完整的 B2B 销售闭环是怎么搭的?
java·前端·javascript·vue.js·产品经理·ai编程
whyfail31 分钟前
前端学 Spring Boot(2):一次点击,如何穿过整个后端?
前端·spring boot·后端
AI多Agent协作实战派1 小时前
AI多Agent协作系统实战(二十三):Agent读HEARTBEAT.md不读AGENTS.md——openclaw的文件加载之谜
前端·数据库·人工智能·uni-app
2601_955760072 小时前
如何用 Claude Opus 5 API 批量扩展长尾关键词和文章选题
前端·python·搜索引擎
KaMeidebaby2 小时前
卡梅德生物技术快报|bli亲和力检测gst:告别批量跑胶:BLI实时酶切监测技术加速GST融合蛋白下游流程优化
前端·网络·数据库·人工智能·算法
程序员cxuan3 小时前
Loop 还没玩明白,Graph Engineering 又火了。
人工智能·后端·程序员
触底反弹3 小时前
🚀 删了数据刷新又回来?3 组件 × 4 回调 × 3 坑讲透 React 父子通信
前端·javascript·react.js