昨天想写个最简单的待办列表,前端页面画好了,后端也用 Node 搭了个接口,结果死活拿不到数据,控制台还报了个红。我盯着屏幕看了十分钟,突然反应过来,我好像一直没真正搞懂 Ajax 到底是怎么回事。
这是我当时的项目结构,就俩文件夹,一个后端一个前端,简单到不能再简单了。
说出来你可能不信,我用了这么久的 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,才能确定请求成功了,然后才能处理返回的数据。
我踩过的那些坑
说几个我当时踩的坑吧,估计很多人都遇到过:
- 跨域问题:我一开始后端代码里没加那行
Access-Control-Allow-Origin,结果控制台直接报了个 CORS 错误。前后端端口不一样,浏览器出于安全考虑,会阻止跨域请求,这是前端开发最常见的问题之一。 - 中文乱码:我一开始没设置
Content-Type头,结果返回的中文全是问号。必须告诉浏览器我返回的是什么格式,用什么编码,它才能正确解析。 - 异步执行顺序:就是刚才说的那个,想在 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,今天看完终于搞懂了,记得回来留个言,我也想看看你的理解。