从零实现一个 Todos 应用:原生 Ajax + Node 服务,顺便吃透 JSON.stringify

手写 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.stringifyreplacerspace 参数到底怎么用
  • CommonJS 与 ES Modules 的演进
  • 回调地狱与异步执行顺序

读完这篇文章,你将不再惧怕任何前端网络面试题。


一、项目概览与文件结构

我们只有两个核心文件:

  • index.html -- 前端页面,包含一个 <ul> 容器和一个按钮。页面加载后会通过 XHR 请求 http://localhost:3000/todos,并将得到的 todo 列表渲染成 <li>
  • index.js -- Node 后端,使用内置 http 模块创建服务器,监听 3000 端口,提供 //todos 两个路由。
  • readme.md -- 记录了关于 JSON.stringify 的笔记。

最终运行效果

  1. 在终端执行 node index.js,控制台输出 server is running on 3000 port
  2. 在浏览器中打开 index.html(直接用文件协议或通过 live server)。
  3. 页面会自动请求数据,几毫秒后页面显示出两个待办事项:"过四六级"和"回家过节"。

二、后端实现(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 规范,引入了 requiremodule.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 → 监听状态

  1. new XMLHttpRequest()

    创建一个 XHR 对象。

  2. xhr.open(method, url, async)

    • method:HTTP 方法(GET、POST 等)
    • url:请求地址
    • asynctrue 表示异步(推荐),false 表示同步(会阻塞 JS 执行,不推荐)
  3. xhr.send()

    真正发送请求。如果是 POST,可以在 send 中传入请求体字符串。

  4. 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 注意事项与常见坑

  1. 循环引用:对象引用自身会抛出错误。

    javascript 复制代码
    const obj = {}; obj.self = obj;
    JSON.stringify(obj); // TypeError: Converting circular structure to JSON
  2. undefined、函数、Symbol 在对象中会被忽略,在数组中会转为 null

    javascript 复制代码
    JSON.stringify([undefined, function(){}]); // '[null,null]'
  3. toJSON 方法 :如果对象有 toJSON 方法,stringify 会优先使用它的返回值。

    javascript 复制代码
    const date = new Date();
    date.toJSON(); // 返回 ISO 字符串
    JSON.stringify(date); // 调用了 date.toJSON()
  4. space 不能超过 10:如果传入大于 10 的数字,实际只会用 10 个空格(规范限制)。


五、完整流程串联与思考

现在,我们把所有知识整合到一起:

  1. 用户打开 index.html,浏览器解析 HTML。
  2. 遇到第一个 <script>,注册按钮点击事件(虽然本例中未实际请求)。
  3. 遇到第二个 <script>,立即执行:
    • 新建 XHR 对象
    • open 配置异步 GET 请求
    • send 发送请求(到 localhost:3000/todos
    • 注册 onreadystatechange 回调
    • 打印 'end'
  4. 此时请求在网络中传输,主线程继续执行其他任务(比如响应用户点击)。
  5. Node 服务收到请求:
    • req.url 匹配 /todos
    • 设置跨域头和 Content-Type
    • JSON.stringify(todos) 将数组转为 JSON 字符串
    • res.end 返回
  6. 前端收到响应:
    • readyState 依次变为 2、3、4
    • 每次变化触发回调
    • readyState === 4status === 200,执行:
      • JSON.parse 解析响应文本
      • 生成 <li> 并插入到 DOM
  7. 用户看到动态渲染的 todo 列表。

关于模块化的补充思考

后端用了 require,这是 CommonJS。前端没有使用任何模块化语法,因为浏览器环境直到近年才原生支持 ES Modules。如果你想把前端代码也模块化,可以在 <script type="module"> 中使用 import,但要注意跨域和路径问题。这也是为什么注释里提到"早期的 js,特别是前端没有模块化系统"的原因。


六、扩展练习

学完这些,你可以尝试以下练习来巩固:

  1. 添加 POST 请求 :在前端增加一个输入框和按钮,通过 XHR 发送 POST 请求,将新 todo 传给后端,后端将其添加到 todos 数组并返回更新后的列表。(注意:后端需要解析请求体,可能需要用到 req.on('data')。)
  2. 封装 XHR 为 Promise :写一个 ajax(url, options) 函数,内部使用 XHR,返回 Promise,模拟 fetch 的基本行为。
  3. 使用 JSON.stringify 美化日志 :在 Node 端每次收到请求时,用 space 参数打印请求体到控制台,便于调试。
  4. 理解同步 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 输出"时,你就能自信地对答如流。

如果你觉得这篇文章有帮助,请点赞、收藏、转发,让更多前端小伙伴告别一知半解!

完整代码已整理在文中,你可以直接复制运行。有任何疑问欢迎评论区交流~

相关推荐
昭昭颂桉a1 小时前
TypeScript 前端的必修课,从 JS 到 TS
开发语言·前端·javascript·typescript
霸道流氓气质1 小时前
Spring Boot 文件上传大小限制配置全解析
spring boot·后端·firefox
Java面试题总结1 小时前
SpringBoot API参数校验
java·spring boot·后端
何以解忧,唯有..1 小时前
Go 语言安装与环境配置完整指南
开发语言·后端·golang
alwaysrun1 小时前
C++之常量体系const
c++·后端·程序员
程序猿阿伟1 小时前
《Chrome扩展:穿透沙箱与签名体系的技术本质》
前端·chrome
飘尘1 小时前
豆包里一句话就能P图生视频,背后究竟发生了什么?
前端·人工智能·aigc
武子康1 小时前
Java-24 深入浅出 Spring 全景:从起源到 Spring 6 一文打通 IoC / AOP / 发展史
java·后端·spring
codeking1 小时前
3 步把 AI 桌面自动化从失控拉回可用
javascript·架构