从零搭建Todo应用:搞懂RESTful、跨域和Bun.serve高性能服务器
写在前面
你有没有遇到过这样的报错?
text
Access to fetch at 'http://localhost:8080/todos' from origin 'http://127.0.0.1:5500' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
这是一个典型的跨域(CORS)报错。今天,我们就从一个简单的Todo任务清单项目入手,彻底搞懂:
-
什么是RESTful API设计规范
-
接口(Interface)在编程中的核心地位
-
从输入域名到看到页面,中间发生了什么
-
Bun.serve为什么这么快
-
fetch到底在做什么
一、项目需求:一个任务清单(Todos)
我们要做一个最简单的任务清单应用:
-
后端提供任务数据(id、标题、完成状态、创建时间)
-
前端从后端获取数据并展示在页面上
-
支持查看任务列表、查看单个任务详情
听起来很简单对吧?但背后涉及的概念可一点都不简单。
二、先看后端:用TypeScript定义任务接口
2.1 什么是接口(Interface)
在面向对象编程(OOP)中,接口是一个核心概念。它与封装、继承、多态并称为OOP的四大基石。
简单理解:接口就是一套契约或规范,它声明了一个对象应该有哪些属性和方法,但不负责具体实现。
// 用接口定义Todo任务的数据结构
interface Todo {
id: string; // 任务ID
title: string; // 任务标题
completed: boolean; // 是否完成
createdAt: Date; // 创建时间
}
这个接口告诉我们:任何一个Todo对象,都必须有这四个属性,且类型必须匹配。
为什么要用接口?
-
类型安全:TypeScript会在编译时检查,防止你给todo对象乱加属性或遗漏属性
-
代码提示:IDE可以自动补全,减少拼写错误
-
面向接口编程:这是设计模式的基础,让代码更灵活、可维护
如果你尝试这样写:
const wrongTodo = {
id: "1",
title: "吃饭",
// 缺少 completed 和 createdAt
}
TypeScript会直接报错,告诉你违反了接口契约。
2.2 接口的扩展:索引签名
有时候你可能需要给对象添加额外的属性,但又不想破坏接口的约束。这时可以用索引签名:
interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: Date;
[key: string]: any; // 允许添加任意额外属性
}
这样既保证了核心属性必须存在,又保留了灵活性。
三、RESTful:一套优雅的API设计规范
3.1 什么是RESTful
REST(Representational State Transfer,表现层状态转换)是一种API设计风格,它的核心思想是:
一切皆资源(Resource)
每个资源对应一个URL,通过HTTP动词来定义对资源的操作。
3.2 RESTful的URL设计规则
| 动作 | HTTP方法 | URL示例 | 说明 |
|---|---|---|---|
| 获取所有任务 | GET | /todos |
查询资源列表 |
| 获取单个任务 | GET | /todos/1 |
查询id=1的任务 |
| 新建任务 | POST | /todos |
创建一个新任务 |
| 全量更新 | PUT | /todos/1 |
完整替换id=1的任务 |
| 局部更新 | PATCH | /todos/1 |
修改id=1的部分字段 |
| 删除任务 | DELETE | /todos/1 |
删除id=1的任务 |
注意:URL中只用名词(资源名称),不要出现动词。比如:
-
✅ 好:
GET /todos -
❌ 差:
GET /getTodosList
3.3 为什么这样设计?
想象你去图书馆找书:
-
资源(书)的URL是
/books -
你想找某本书:
/books/978-7-111-12345-6 -
你想借一本书:
POST /books/123/borrow(这是RPC风格,动词在URL里)
RESTful更符合Web的原始设计:URL定位资源,HTTP动词描述操作。这样API清晰、可预测,前端不需要看文档就能猜到怎么调用。
3.4 一个RESTful的完整例子
// 我们的Todo服务按照RESTful规范设计:
GET /todos // 获取所有任务
GET /todos/:id // 获取某个任务
POST /todos // 创建任务(暂时未实现)
PUT /todos/:id // 更新整个任务
PATCH /todos/:id // 更新部分字段
DELETE /todos/:id // 删除任务
四、从输入域名到看到页面:完整请求链路解析
4.1 你在浏览器输入了什么?
当你输入 http://localhost:8080/todos,背后发生了什么?
4.2 URL的组成结构
http://localhost:8080/todos?id=1&completed=true
\___/ \________/ \__/ \___/ \________________/
协议 域名 端口 路径 查询参数
| 部分 | 示例 | 说明 |
|---|---|---|
| 协议 | http:// 或 https:// |
双方沟通的语言,HTTP是Web的基础协议 |
| 域名 | localhost 或 baidu.com |
服务器的地址(localhost指本机) |
| 端口 | 8080 |
一台服务器有多个端口,每个端口提供不同服务 |
| 路径 | /todos |
定位到具体资源 |
| 查询参数 | ?id=1&completed=true |
附加条件,用于过滤、分页等 |
关于端口的比喻:
IP地址就像一栋大楼的地址,端口就是大楼里不同的房间号。80号房间是HTTP服务(默认可以省略),443是HTTPS服务,8080可能是你的测试服务。
4.3 完整流程
1. DNS解析
localhost → 127.0.0.1(本机回环地址)
2. 建立TCP连接
浏览器 ←→ 服务器(三次握手)
3. 发送HTTP请求
GET /todos HTTP/1.1
Host: localhost:8080
Origin: http://127.0.0.1:5500
4. 服务器处理请求(Bun.serve 的 fetch 函数被调用)
5. 返回HTTP响应
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: *
[{"id":"1","title":"吃饭"},...]
6. 浏览器解析响应
- 如果是HTML:渲染页面
- 如果是JSON:交给JavaScript处理
7. 关闭/保持连接
五、Bun.serve:为什么它这么快?
5.1 Bun是什么?
Bun是一个全新的JavaScript运行时,就像Node.js和Deno一样。但它有三大特点:
-
极快的启动速度
-
原生支持TypeScript(不需要ts-node或编译)
-
内置高性能HTTP服务器
5.2 Bun.serve 的使用
const server = Bun.serve({
port: 8080, // 监听端口
async fetch(req) {
// 所有请求都会经过这个函数
const url = new URL(req.url);
if (url.pathname === "/todos") {
return Response.json(todos, {
headers: {
'Access-Control-Allow-Origin': '*'
}
});
}
// 处理详情请求:/todos/1
if (req.method === 'GET' && url.pathname.startsWith("/todos/")) {
const id = url.pathname.split("/")[2];
const todo = todos.find(t => t.id == id);
return Response.json(todo);
}
return new Response("Not Found", { status: 404 });
}
});
5.3 高性能的秘密
| 特性 | Node.js (Express) | Bun.serve |
|---|---|---|
| 请求处理 | 基于回调/异步 | 原生支持异步 |
| 路由匹配 | 需要中间件(如express.Router) | 手动解析URL(零开销) |
| 序列化 | 调用JSON.stringify | 内置优化 |
| 底层 | libuv线程池 | 自定义事件循环 |
Bun用Zig语言编写,直接对接系统调用,避免了JavaScript层面的很多开销。据官方测试,Bun.serve的吞吐量是Node.js + Express的4倍以上。
5.4 fetch函数的妙用
注意Bun.serve里的fetch函数:每一次HTTP请求,都会调用一次这个fetch函数。
async fetch(req) {
// req 就是浏览器发来的请求对象
// 包含:请求方法、请求头、请求体、URL等
const url = new URL(req.url); // 解析URL
// 根据不同的路径,返回不同的响应
}
这就是一个路由器(Router)的基本逻辑:警察根据URL路径,把请求分发给不同的处理函数。
六、前端fetch:如何从后端拿数据
6.1 fetch基本用法
// 方式1:Promise链式调用
fetch("http://localhost:8080/todos")
.then(res => res.json()) // 解析JSON
.then(data => {
console.log(data);
// 渲染到页面
})
.catch(err => console.error("请求失败", err));
6.2 用async/await更好读
async function getTodos() {
try {
const res = await fetch("http://localhost:8080/todos");
const data = await res.json();
return data;
} catch (error) {
console.error("请求失败", error);
}
}
// 渲染到页面
getTodos().then(todos => {
const list = document.getElementById("todos");
list.innerHTML = todos.map(todo =>
`<li>${todo.title}</li>`
).join('');
});
6.3 为什么fetch需要两次await?
const res = await fetch(url); // 第一次:拿到响应对象(状态码、响应头)
const data = await res.json(); // 第二次:读取响应体(JSON数据)
这是因为:网络请求是分阶段完成的。fetch返回后,响应头可能已经到达,但响应体(实际数据)还在传输中。res.json()返回一个Promise,等待完整数据到达后再解析。
6.4 真实的请求过程
// 前端代码
fetch("http://localhost:8080/todos")
// 实际上浏览器做了:
// 1. 解析URL(协议、域名、端口、路径)
// 2. 检查是否有缓存
// 3. DNS查询(localhost → 127.0.0.1)
// 4. 建立TCP连接(三次握手)
// 5. 发送HTTP请求报文
// 6. 等待服务器响应
// 7. 接收响应报文
// 8. 触发Promise完成
七、跨域(CORS)的来龙去脉
7.1 为什么会有跨域问题?
同源策略(Same-Origin Policy)是浏览器的一个安全机制。
什么是同源?
-
协议相同(http vs https)
-
域名相同(localhost vs 127.0.0.1)
-
端口相同(8080 vs 5500)
三个条件必须全部满足,否则就是跨域。
我们的情况:
-
前端:
http://127.0.0.1:5500(协议http,域名127.0.0.1,端口5500) -
后端:
http://localhost:8080(协议http,域名localhost,端口8080)
域名不同(127.0.0.1 ≠ localhost),虽然指向同一台机器,但浏览器认为这是跨域。
7.2 CORS怎么解决?
CORS(跨域资源共享)需要服务器配合,在响应头中添加:
const headers = {
'Access-Control-Allow-Origin': '*', // 允许所有域名访问
// 或者指定具体域名
// 'Access-Control-Allow-Origin': 'http://127.0.0.1:5500'
}
return Response.json(todos, { headers });
7.3 简单请求与非简单请求
简单请求(GET、POST,且Content-Type有限制):
-
浏览器直接发送请求
-
服务器返回
Access-Control-Allow-Origin即可
非简单请求(PUT、DELETE,或者POST带自定义头):
-
浏览器先发一个
OPTIONS预检请求 -
服务器返回允许的方法和头
-
浏览器收到确认后,再发送真实请求
// 处理预检请求
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Origin': '*'
}
});
}
八、完整项目代码
8.1 后端:server.ts
interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: Date;
}
const todos: Todo[] = [
{
id: "1",
title: "吃饭",
completed: false,
createdAt: new Date()
},
{
id: "2",
title: "打豆豆",
completed: false,
createdAt: new Date()
}
];
const server = Bun.serve({
port: 8080,
async fetch(req) {
const url = new URL(req.url);
const headers = {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json'
};
// 处理预检请求
if (req.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Origin': '*'
}
});
}
// GET /todos - 获取所有任务
if (req.method === 'GET' && url.pathname === '/todos') {
return Response.json(todos, { headers });
}
// GET /todos/:id - 获取单个任务
if (req.method === 'GET' && url.pathname.startsWith('/todos/')) {
const id = url.pathname.split('/')[2];
const todo = todos.find(t => t.id === id);
if (!todo) {
return new Response('Not Found', { status: 404, headers });
}
return Response.json(todo, { headers });
}
return new Response('Not Found', { status: 404 });
}
});
console.log(`Server running at http://localhost:8080`);
8.2 前端:index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo 任务清单</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
ul { list-style: none; padding: 0; }
li { padding: 12px; border-bottom: 1px solid #eee; cursor: pointer; }
li:hover { background: #f5f5f5; }
</style>
</head>
<body>
<h1>📝 我的任务清单</h1>
<ul id="todos"></ul>
<div id="detail"></div>
<script>
const API_BASE = 'http://localhost:8080';
// 获取并渲染所有任务
async function loadTodos() {
try {
const res = await fetch(`${API_BASE}/todos`);
if (!res.ok) throw new Error('请求失败');
const todos = await res.json();
const todoList = document.getElementById('todos');
todoList.innerHTML = todos.map(todo =>
`<li data-id="${todo.id}">${todo.title} ${todo.completed ? '✅' : '⏳'}</li>`
).join('');
// 添加点击事件查看详情
document.querySelectorAll('#todos li').forEach(li => {
li.addEventListener('click', () => loadTodoDetail(li.dataset.id));
});
} catch (error) {
console.error('加载失败:', error);
document.getElementById('todos').innerHTML = '<li>加载失败,请确保后端服务已启动</li>';
}
}
// 获取单个任务详情
async function loadTodoDetail(id) {
try {
const res = await fetch(`${API_BASE}/todos/${id}`);
const todo = await res.json();
document.getElementById('detail').innerHTML = `
<div style="margin-top: 20px; padding: 16px; background: #e8f4f8; border-radius: 8px;">
<h3>📌 任务详情</h3>
<p><strong>ID:</strong> ${todo.id}</p>
<p><strong>标题:</strong> ${todo.title}</p>
<p><strong>状态:</strong> ${todo.completed ? '已完成' : '未完成'}</p>
<p><strong>创建时间:</strong> ${new Date(todo.createdAt).toLocaleString()}</p>
</div>
`;
} catch (error) {
console.error('加载详情失败:', error);
}
}
// 启动
loadTodos();
</script>
</body>
</html>
8.3 如何运行
# 1. 安装 Bun(如果还没安装)
curl -fsSL https://bun.sh/install | bash
# 2. 运行后端
bun run server.ts
# 3. 打开前端
# 直接用 VS Code Live Server 或者
# npx serve . 然后访问 http://localhost:3000
九、写在最后:一张图总结整个流程
用户输入 localhost:8080/todos
│
▼
┌───────────────────────────────────────┐
│ 1. DNS解析: localhost → 127.0.0.1 │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 2. 浏览器构建HTTP请求(GET /todos) │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 3. Bun.serve的fetch()被调用 │
│ - 解析url.pathname │
│ - 匹配到 /todos │
│ - 返回 todos 数组的JSON │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 4. 浏览器接收响应 │
│ - 检查CORS头 │
│ - 解析JSON │
│ - 触发fetch的Promise │
└───────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 5. JavaScript渲染DOM │
│ - data.map() 生成HTML │
│ - innerHTML 更新页面 │
└───────────────────────────────────────┘
核心概念速查表
| 概念 | 一句话解释 |
|---|---|
| 接口(Interface) | 声明"必须有什么",不实现"怎么做" |
| RESTful | URL定位资源,HTTP动词描述操作 |
| CORS | 浏览器安全策略,需要服务器配合放行 |
| fetch | 浏览器原生API,发起HTTP请求 |
| Bun.serve | 高性能HTTP服务器,原生支持TS |
| 端口 | 服务器上的"房间号",区分不同服务 |
| 同源策略 | 协议+域名+端口必须完全一致 |
希望这篇文章能帮你彻底理解Todo应用背后的技术原理。从接口定义到RESTful规范,从fetch请求到Bun服务器,再到跨域问题的来龙去脉,现在你应该能回答开头那个报错是什么意思了。
如果你在实践过程中遇到问题,欢迎在评论区留言交流!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)