写在前面

你有没有遇到过这样的报错?

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对象,都必须有这四个属性,且类型必须匹配。

为什么要用接口?

  1. 类型安全:TypeScript会在编译时检查,防止你给todo对象乱加属性或遗漏属性

  2. 代码提示:IDE可以自动补全,减少拼写错误

  3. 面向接口编程:这是设计模式的基础,让代码更灵活、可维护

如果你尝试这样写:

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一样。但它有三大特点:

  1. 极快的启动速度

  2. 原生支持TypeScript(不需要ts-node或编译)

  3. 内置高性能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服务器,再到跨域问题的来龙去脉,现在你应该能回答开头那个报错是什么意思了。

如果你在实践过程中遇到问题,欢迎在评论区留言交流!

 

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐