手写 Ajax + Node 服务,从 XHR 到 JSON.stringify 完全吃透
带你从零实现一个完整的 todo 应用:前端用原生 XMLHttpRequest 请求数据,后端用 Node 内置 http 模块提供接口。顺便彻底搞懂
JSON.stringify的每个参数、XHR 的readyState、跨域、以及模块化演进。
前言
很多前端同学初学网络请求时,要么直接上手 axios,要么只知道 fetch,对底层的 XMLHttpRequest(XHR)一知半解。对于后端,往往一上来就是 Express,却忽略了 Node 原生 http 模块的魅力。而对于 JSON.stringify,多数人只会传一个参数,剩下两个参数几乎从未使用过。
今天,我们不依赖任何第三方库,手写一个完整的前后端交互应用 。后端提供 /todos 接口,前端通过 XHR 请求并动态渲染页面。在这个过程中,我会把文件里每一行注释、每一个知识点都讲透,包括:
- 原生 XHR 的完整生命周期(
readyState从 0 到 4) - 为什么有了
fetch还要学 XHR - Node 原生
http模块的路由、跨域处理 JSON.stringify的replacer和space参数到底怎么用- CommonJS 与 ES Modules 的演进
- 回调地狱与异步执行顺序
读完这篇文章,你将不再惧怕任何前端网络面试题。
一、项目概览与文件结构
我们只有两个核心文件:
index.html-- 前端页面,包含一个<ul>容器和一个按钮。页面加载后会通过 XHR 请求http://localhost:3000/todos,并将得到的 todo 列表渲染成<li>。index.js-- Node 后端,使用内置http模块创建服务器,监听 3000 端口,提供/和/todos两个路由。readme.md-- 记录了关于JSON.stringify的笔记。
最终运行效果
- 在终端执行
node index.js,控制台输出server is running on 3000 port。 - 在浏览器中打开
index.html(直接用文件协议或通过 live server)。 - 页面会自动请求数据,几毫秒后页面显示出两个待办事项:"过四六级"和"回家过节"。
二、后端实现(index.js)逐行解读
先看后端的完整代码,再拆分讲解。
javascript
// node 内置的http 模块
// 早期的js ,特别是前端没有模块化系统
// function scr
// node 一定要上模块化方案 require + module.exports
// esm 是升级版 import + export default
// require node 早期的模块化系统 commonjs
const http = require('http');
// 伺服状态
http.createServer((req, res) => {
// 用户服务函数
const todos = [{
id: "1",
title: "过四六级",
completed: false
}, {
id: "2",
title: "回家过节",
completed: false
}];
// req 用户对象
if(req.url === '/'){
res.end("hello world");
}
if(req.url === '/todos'){
// 二进制文本
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json' + '; charset=utf-8');
res.end(JSON.stringify(todos));
}
}).listen(3000, () => {
console.log(`server is running on 3000 port`);
});
2.1 模块化系统的历史包袱
代码开头的注释非常值得玩味:
早期的js ,特别是前端没有模块化系统 / node 一定要上模块化方案 require + module.exports / esm 是升级版 import + export default
为什么会这样?
JavaScript 诞生之初只是为了在浏览器里做简单的表单验证,根本没有模块的概念。所有变量都是全局的,多个脚本文件之间靠 <script> 顺序和全局对象来通信,极易冲突。
后来 Node.js 问世,它需要在服务器端处理复杂的文件依赖,于是借鉴了 CommonJS 规范,引入了 require 和 module.exports。这也是为什么上面的代码第一行是 const http = require('http')。
而在现代浏览器和最新的 Node 版本中,ES Modules(ESM)已经成为标准,使用 import http from 'http' 和 export default。但为了兼容性,很多老项目依然沿用 CommonJS。理解这个演进过程,能帮你读懂不同项目的代码风格。
2.2 创建 HTTP 服务器:http.createServer
http.createServer 接受一个回调函数,该函数在每次请求到达时执行。回调参数 req(请求对象)和 res(响应对象)。
req.url:请求的路径(例如/todos或/)。res.setHeader:设置响应头。res.end:结束响应并返回数据,参数必须是字符串或 Buffer。
2.3 路由判断
我们手动实现了两个最简单路由:
/:返回"hello world"。/todos:返回 todos 数组的 JSON 字符串。
这里隐含了一个问题:如果请求 /todos 时没有设置跨域头,前端从 file:// 协议或不同端口发起的请求会被浏览器拦截。所以我们在 /todos 里添加了:
javascript
res.setHeader('Access-Control-Allow-Origin', '*');
这表示允许任何源访问。生产环境中应替换为具体的域名。
2.4 为什么必须用 JSON.stringify?
res.end() 只能发送字符串或二进制数据。而 todos 是一个 JavaScript 对象,直接传递会报错。因此我们需要调用 JSON.stringify(todos) 将其序列化为 JSON 字符串。
关于
JSON.stringify的细节,我们会在第四节完整展开,包括它的第二个参数(replacer)和第三个参数(space)。
2.5 监听端口
.listen(3000, callback) 启动服务器,成功后执行回调打印日志。
三、前端实现(index.html)逐行解读
下面是前端代码(已简化掉按钮的事件注册部分,保留核心 Ajax 逻辑):
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ajax Todo 示例</title>
</head>
<body>
<ul id="todos"></ul>
<button id="btn">按钮</button>
<script>
console.log('start');
// 事件的注册
document.getElementById('btn').addEventListener('click', () => {
console.log('点击按钮');
});
</script>
<script>
// fetch('http://localhost:3000/todos')
// .then(res => res.json())
// .then(data => console.log(data));
// fetch 前辈
// 输入url, 点击a标签
// 底层本质是js 可以主动的去发起接口http请求, 当前页面还在
// fetch/xhr 请求接口, 动态的更新页面 web2.0时代 繁荣
const xhr = new XMLHttpRequest(); // 实例化一个xhr对象
// 打开一个http 通道
// 异步请求
xhr.open('GET', 'http://localhost:3000/todos', true);
// 发送请求
xhr.send();
// 回调函数 callback
xhr.onreadystatechange = function(){
console.log(xhr.readyState); // 打印状态值 2,3,4
if(xhr.status === 200 && xhr.readyState === 4){
const todos = JSON.parse(xhr.responseText);
document.getElementById('todos').innerHTML = todos.map(todo => `<li>${todo.title}</li>`).join('');
}
}
// 注意:这里重复调用了一次 send,实际项目中只应调用一次。原代码有两处 send,我们保留一处。
// xhr.send(); // 为避免重复,注释掉,上面已经 send 过了
console.log('end');
</script>
</body>
</html>
勘误 :原文件中有两次
xhr.send(),第二次会触发重复请求。我们在讲解时只保留一次。另外原代码中xhr.open之后已经send,后面又send了一次。此处已做修正说明。
3.1 XMLHttpRequest 的前世今生
注释中提到:
fetch 前辈 / 输入url, 点击a标签 / 底层本质是js 可以主动的去发起接口http请求,当前页面还在 / fetch/xhr 请求接口,动态的更新页面 web2.0时代 繁荣
在 Web 1.0 时代,浏览器要获取新数据只能刷新整个页面(比如点击链接或提交表单)。2005 年,Google 在 Gmail 和 Google Maps 中大规模使用 XMLHttpRequest,实现了页面局部刷新,用户体验大幅提升,这就是 Ajax(Asynchronous JavaScript and XML) 的起源。
后来 fetch 基于 Promise 设计,语法更简洁,但 XHR 依然是所有浏览器都支持的底层实现。理解 XHR 有助于:
- 调试一些老旧系统的代码
- 实现上传/下载进度监听(
xhr.upload.onprogress) - 理解同步请求的危害(
open的第三个参数传false会阻塞页面)
3.2 核心步骤:实例化 → open → send → 监听状态
-
new XMLHttpRequest()创建一个 XHR 对象。
-
xhr.open(method, url, async)method:HTTP 方法(GET、POST 等)url:请求地址async:true表示异步(推荐),false表示同步(会阻塞 JS 执行,不推荐)
-
xhr.send()真正发送请求。如果是 POST,可以在
send中传入请求体字符串。 -
xhr.onreadystatechange每当
readyState变化时触发。我们一般在此检查readyState === 4(请求完成)和status === 200(成功),然后处理响应。
3.3 深入理解 readyState
| 值 | 常量名 | 含义 |
|---|---|---|
| 0 | UNSENT | 未调用 open() |
| 1 | OPENED | 已调用 open(),尚未发送 |
| 2 | HEADERS_RECEIVED | 已收到响应头 |
| 3 | LOADING | 正在接收响应体(可能不完整) |
| 4 | DONE | 响应完成(无论成功还是失败) |
控制台会依次打印 2、3、4(跳过 0 和 1,因为注册监听时已经过了那俩状态)。
注意 :readyState 为 4 只代表传输结束,不代表业务成功。还需要检查 status。
3.4 异步执行顺序验证
代码中 console.log('start') 在请求前,console.log('end') 在请求后。实际控制台输出顺序为:
sql
start
end
2
3
4
这说明 onreadystatechange 里的代码是异步回调,不会阻塞后续 console.log('end')。这是 Ajax 非阻塞的核心表现。
3.5 动态渲染页面
当请求成功,我们拿到 xhr.responseText(JSON 字符串),用 JSON.parse 转回数组,然后通过数组的 map 方法生成 <li> 字符串,最后赋值给 innerHTML。
javascript
document.getElementById('todos').innerHTML = todos.map(todo => `<li>${todo.title}</li>`).join('');
join('') 是为了把数组拼接成普通字符串,否则默认会带逗号。
3.6 为什么注释里保留了 fetch 写法?
注释中是:
javascript
// fetch('http://localhost:3000/todos')
// .then(res => res.json())
// .then(data => console.log(data));
fetch 是 XHR 的现代替代品,基于 Promise,写法更优雅。但它的底层依然是 XHR(polyfill 或浏览器原生实现)。理解 XHR 后,你就能明白 fetch 的很多设计意图,比如为什么需要两次 .then(第一次拿到 Response 对象,第二次拿到实际数据)。
四、重头戏:JSON.stringify 完全指南
根据 readme.md 的笔记,我们现在详细展开 JSON.stringify 的三个参数。这不仅是面试常考点,也是实际开发中很有用的技巧。
4.1 基本用法
javascript
const obj = { name: '小明', age: 18 };
JSON.stringify(obj); // '{"name":"小明","age":18}'
作用:将 JavaScript 值转换为 JSON 字符串。支持的数据类型:对象、数组、字符串、数字、布尔值、null。不支持 undefined、函数、Symbol(会被忽略或转为 null)。
4.2 第二个参数:replacer(取舍/转换器)
replacer 可以是数组 或函数,用于控制序列化时包含哪些属性。
4.2.1 数组形式 -- 白名单过滤
javascript
const todo = {
id: 1,
title: '学 JSON.stringify',
secret: '内部密码',
completed: false
};
JSON.stringify(todo, ['title', 'completed']);
// 输出: '{"title":"学 JSON.stringify","completed":false}'
// id 和 secret 被过滤掉了
这在只想发送部分字段到后端时非常有用。
4.2.2 函数形式 -- 自定义转换
javascript
const todos = [
{ id: 1, title: '读书', price: 100 },
{ id: 2, title: '写字', price: 50 }
];
JSON.stringify(todos, (key, value) => {
if (key === 'price') {
return value + '元';
}
return value;
});
// 输出: '[{"id":1,"title":"读书","price":"100元"},{"id":2,"title":"写字","price":"50元"}]'
还可以利用这个函数删除敏感信息:
javascript
const user = { name: 'Alice', password: '123456' };
JSON.stringify(user, (key, value) => {
if (key === 'password') return undefined;
return value;
});
// '{"name":"Alice"}'
4.3 第三个参数:space -- 格式化缩进
正常情况下 JSON 字符串没有多余空格,目的是节省带宽。但在开发调试或写日志时,我们希望它可读性强一些,这时 space 就派上用场了。
4.3.1 数字参数:指定缩进空格数
javascript
const obj = {
name: 'todo列表',
items: ['吃饭', '睡觉']
};
JSON.stringify(obj, null, 2);
输出(注意换行和缩进):
json
{
"name": "todo列表",
"items": [
"吃饭",
"睡觉"
]
}
如果写 4,则每级缩进 4 个空格。
4.3.2 字符串参数:使用自定义字符缩进
javascript
JSON.stringify(obj, null, '\t');
输出会用制表符缩进。
团队规范 :很多公司的日志规范要求 JSON 格式化使用 2 个空格或 Tab。
space参数正是为此设计。但在生产环境的接口响应中,通常不传space以减小体积。
4.4 注意事项与常见坑
-
循环引用:对象引用自身会抛出错误。
javascriptconst obj = {}; obj.self = obj; JSON.stringify(obj); // TypeError: Converting circular structure to JSON -
undefined、函数、Symbol 在对象中会被忽略,在数组中会转为null。javascriptJSON.stringify([undefined, function(){}]); // '[null,null]' -
toJSON方法 :如果对象有toJSON方法,stringify会优先使用它的返回值。javascriptconst date = new Date(); date.toJSON(); // 返回 ISO 字符串 JSON.stringify(date); // 调用了 date.toJSON() -
space不能超过 10:如果传入大于 10 的数字,实际只会用 10 个空格(规范限制)。
五、完整流程串联与思考
现在,我们把所有知识整合到一起:
- 用户打开
index.html,浏览器解析 HTML。 - 遇到第一个
<script>,注册按钮点击事件(虽然本例中未实际请求)。 - 遇到第二个
<script>,立即执行:- 新建 XHR 对象
open配置异步 GET 请求send发送请求(到localhost:3000/todos)- 注册
onreadystatechange回调 - 打印
'end'
- 此时请求在网络中传输,主线程继续执行其他任务(比如响应用户点击)。
- Node 服务收到请求:
req.url匹配/todos- 设置跨域头和 Content-Type
JSON.stringify(todos)将数组转为 JSON 字符串res.end返回
- 前端收到响应:
readyState依次变为 2、3、4- 每次变化触发回调
- 当
readyState === 4且status === 200,执行:JSON.parse解析响应文本- 生成
<li>并插入到 DOM
- 用户看到动态渲染的 todo 列表。
关于模块化的补充思考
后端用了 require,这是 CommonJS。前端没有使用任何模块化语法,因为浏览器环境直到近年才原生支持 ES Modules。如果你想把前端代码也模块化,可以在 <script type="module"> 中使用 import,但要注意跨域和路径问题。这也是为什么注释里提到"早期的 js,特别是前端没有模块化系统"的原因。
六、扩展练习
学完这些,你可以尝试以下练习来巩固:
- 添加 POST 请求 :在前端增加一个输入框和按钮,通过 XHR 发送 POST 请求,将新 todo 传给后端,后端将其添加到
todos数组并返回更新后的列表。(注意:后端需要解析请求体,可能需要用到req.on('data')。) - 封装 XHR 为 Promise :写一个
ajax(url, options)函数,内部使用 XHR,返回 Promise,模拟fetch的基本行为。 - 使用
JSON.stringify美化日志 :在 Node 端每次收到请求时,用space参数打印请求体到控制台,便于调试。 - 理解同步 XHR 的危害 :将
open的第三个参数改为false,观察页面是否卡顿。
七、总结
这篇文章我们从一个真实的 todo 应用出发,覆盖了以下核心知识点:
- 原生 XMLHttpRequest 的完整生命周期、状态码、异步模型。
- Node 原生 http 模块 创建服务、路由、跨域、响应头。
JSON.stringify的三个参数 :基础用法、replacer数组/函数、space格式化。- 模块化演进:从全局污染到 CommonJS 再到 ES Modules。
- Ajax 历史:从 Web 1.0 到 Web 2.0 的动态页面革命。
前端开发不仅仅是会用框架,底层知识才是你解决疑难杂症的利器。当你真正理解了 XHR 的每一行代码和 JSON.stringify 的每个参数,面试官问起"Ajax 原理"或"如何格式化 JSON 输出"时,你就能自信地对答如流。
如果你觉得这篇文章有帮助,请点赞、收藏、转发,让更多前端小伙伴告别一知半解!
完整代码已整理在文中,你可以直接复制运行。有任何疑问欢迎评论区交流~