Bun + TypeScript 实战:从接口约束到 RESTful 路由设计

Bun + TypeScript 实战:从接口约束到 RESTful 路由设计

写在前面:通过一个简单的 Todos 任务清单项目,我第一次在 Bun 环境下用 TypeScript 实践了接口约束和 RESTful 路由设计。这个项目虽小,但完整覆盖了后端服务的核心概念------从类型安全到资源语义化,再到前后端联调。这篇文章记录了我的实践过程,适合刚接触 Bun 后端开发的同学阅读。

之前了解了一下 TS 的起源以及一些简单的使用,接下来进入初步实战------用 Bun + TypeScript 搭建一个真正的 HTTP 服务,并理解了接口与 RESTful 设计的意义。

书接上文:Bun + TypeScript:AI 时代的后端开发入门- 掘金

从一个小场景说起

用 Bun 实现一个任务清单(Todos)的后端服务,要求支持获取任务列表和任务详情。听起来简单,但里面涉及的知识点却串联起了 TypeScript 接口、RESTful 设计、HTTP 协议和前后端通信。我发现,要写出一个"合格"的后端服务,先得理解两个核心概念:接口和 RESTful。

接口(interface):给对象一份类型契约

首先要提到的是 interface------这是 OOP(面向对象编程)中的核心概念。

在 TypeScript 中为变量与函数等强制加上了类型约束,所以可以简单理解为: ts = js + 强类型。那么如何约束 todos 的类型?为什么要约束? 答案是:自定义类型对象,需要通过接口来约束,而约束,为了任务的准确性。

前置知识快速回顾:从基础类型到自定义类型

在进入接口之前,先把上一节铺垫过的 TypeScript 基础类型和函数约束拉回来。interface 并不是凭空出现的------它是"基础类型"这套工具的升级版

变量类型注解(前置文章中演示过的):

typescript 复制代码
// 基础类型:string、number、boolean、Date
const nickname: string = '9527';
const age: number = 27;
const isStudent: boolean = true;
const now: Date = new Date();

函数的参数与返回值类型 ------这一点在后面 Bun.servefetch 签名中会再次用到:

typescript 复制代码
// 参数类型 + 返回值类型,构成函数的"完整契约"
function add(a: number, b: number): number {
    return a + b;
}

到这里我们已经能给"标量"和"函数"加类型约束了。但当一个变量需要承载多个字段时 (比如一个 Todo 有 id、title、completed、createdAt 四个属性),如果每个字段都单独声明既冗长又容易遗漏。这就到了 interface 出场的时刻------它正是为"自定义复合类型"准备的契约模板。

看看我为 Todo 定义的接口:

typescript 复制代码
// 面向对象的核心概念
interface Todo {
    // 接口定义区,对象字面量采用, 分隔
    // 接口定义区,属性采用: 分隔
    id: string;
    title: string;
    completed: boolean;
    createdAt: Date;// 任务创建时间
}

几个细节:接口定义区里,对象字面量采用逗号分隔,属性采用冒号分隔。createdAt 用了 Date 类型,记录任务创建时间。

接着我用这个接口约束了 todos 数组:

typescript 复制代码
// 资源
const todos: Todo[]= [
    {
        id: '1',
        title: '学习ts',
        completed: false,
        createdAt: new Date(),
    },
    {
        id: '2',
        title: '睡觉',
        completed: false,
        createdAt: new Date(),
    },
    {
        id: '3',
        title: '吃饭',
        completed: false,
        createdAt: new Date(),
    }
];

众所周知,OOP 的三大核心概念:封装、继承、多态。而接口与这三者都有关联。

  1. 封装:接口本身是一种"契约封装"------只暴露属性/方法签名,隐藏实现细节
  2. 继承:接口可以继承( interface A extends B ),实现接口复用
  3. 多态:接口是实现多态的关键------不同类实现同一接口,可互相替换

接口是用于声明一个对象的约束------规定一个对象应该拥有哪些属性或方法。这里稍微衍生一下,常听人说,面向对象编程是现代企业级开发的基础,而面向接口编程是设计模式的基础。现在我稍微有了一些理解:

  • 面向对象编程解决了如何组织代码,它让我们将散落的变量和函数通过类和接口组织起来,这是语法层面的基础。
  • 而面向接口编程则是解决了如何解耦代码,也就是设计模式的核心思想:"依赖抽象,不依赖具体",这是架构层面的基础,各种设计模式就是建立在这个基础上的。

所以,接口可以说是 OOP 中相当重要的一个概念了。

除了接口,TypeScript 还提供了抽象类(abstract class) 。抽象类和接口类似,都是用来约束类的------但抽象类可以包含具体实现(如公共方法、属性),而接口只能声明签名。抽象类有一个重要特性:不能直接 new 实例化,必须由具体子类继承并实现所有抽象方法。这种"半成品"的设计非常适合做"基类模板"。

typescript 复制代码
abstract class BaseRepo<T> {
    abstract find(id: string): T | undefined;  // 抽象方法,子类必须实现
    abstract save(item: T): void;
  
    log(item: T) {  // 具体方法,子类可直接复用
        console.log(`[${new Date().toISOString()}]`, item);
    }
}

class TodoRepo extends BaseRepo<Todo> {
    find(id: string) { /* 实现查找逻辑 */ return undefined; }
    save(item: Todo) { /* 实现保存逻辑 */ }
}

// new BaseRepo(); // Error: 无法创建抽象类的实例
new TodoRepo();   // ✅ 必须由具体子类实例化

抽象类实现接口时,可以只实现部分方法,把剩余的留给子类完成。最终由具体类确保实现所有方法。

现在我们又加深了一点对 TS 的了解,TypeScript = JavaScript + 类型系统 + OOP 机制(接口、抽象类、泛型等)

RESTful:一切皆资源,让 URL 自带语义

理解了接口,接下来是 RESTful 设计。一句话概括:一切皆资源

RESTful 定义了 URL 的规则:资源的名词 + 资源的操作(HTTP 动词)有一定的语义规则。不同的资源应该放在不同的路径上------这就引出了路由的概念。

比如我们要设计 Todos 资源:

  • GET /todos → 获取任务列表
  • GET /todos/:id → 获取单个任务详情
  • POST /todos → 创建新任务
  • PUT /todos/:id → 更新整个任务
  • PATCH /todos/:id → 部分更新任务(如只改 completed)
  • DELETE /todos/:id → 删除任务

注意区分 PUTPATCHPUT 是"整体替换"(传整个对象),PATCH 是"局部更新"(只传要改的字段)。这是初学者最常混淆的两个动词。

这种设计让 URL 本身就能表达意图,不需要在路径里写动词。它的设计意图非常清晰:用 HTTP 动词表达操作,用路径表达资源 。在 RESTful 设计中,状态码也要语义化------这是和"看起来能跑"的接口拉开差距的关键:

状态码 含义 典型场景
200 OK 成功 GET 成功返回数据
201 Created 资源已创建 POST 创建成功
204 No Content 成功但无返回体 DELETE 删除成功
400 Bad Request 客户端请求错误 参数校验失败
404 Not Found 资源不存在 GET 了一个不存在的 id
500 Internal Server Error 服务器内部错误 代码抛出未捕获异常

一个反例 :很多初学者不管什么情况都返回 200,然后在 body 里塞一个 { code: 404, msg: 'not found' }。这在专业开发中是不规范的------状态码本身就是用来表达结果的。

实战:用 Bun 启动一个 HTTP 服务器

概念理清了,开始写代码。Bun 内置了一个高性能的服务器,用起来非常简洁:

typescript 复制代码
// 内置了 一个高性能的服务器
const server = Bun.serve({
    port: 8080, // 127.0.0.1:8080 IP 地址对应服务器,端口对应具体服务进程
    // IP 对应一个服务器,不同的端口提供不同的服务
    // 例如 http 服务,mail 服务,音乐 服务等
    // ...
});

关于网络和端口:IP 地址对应服务器,端口对应具体服务进程。一台服务器可以有多个端口,分别提供不同的服务------HTTP 服务、邮件服务、音乐服务等。

HTTP 服务器处于伺服 状态。HTTP 是一个基于请求(request)和响应(response)的协议,只能单方面接受用户请求。我们可以对比一下 WebSocket 协议------这是一种通信协议,双方都可以发送请求和响应。

用户通过在浏览器中输入 URL 带上端口号去发送请求(req 对象,可以有多个)。Server 通过 fetch 函数处理所有请求------这是 Bun.serve 内置的方法,所有的请求都会在这里处理。后端的本质,就是 HTTP 协议和 Web 服务。

Bun.serve 的完整签名如下,理解每个参数对后续扩展很有帮助:

typescript 复制代码
const server = Bun.serve({
    port: 8080,           // 端口号
    hostname: '0.0.0.0',  // 监听地址,0.0.0.0 表示接受所有 IP 的请求
    development: false,   // 开发模式下会输出详细日志
    async fetch(req: Request, server: Server): Promise<Response> {
        // req:标准 Web Request 对象
        // server:Server 实例,可以获取 url、port 等
        return new Response('Hello');
    },
    error(err: Error): Response {
        // 全局错误处理:捕获 fetch 中未处理的异常
        return new Response(`Internal Error: ${err.message}`, { status: 500 });
    },
});
深入理解 fetch 签名:async 与 Promise 的实战衔接

把上一节铺垫过的"函数类型约束"和"async/await"放到这里再过一遍,会发现这套知识是前后衔接的:

  1. 参数类型约束req: Requestserver: Server------RequestServer 都是 Bun/浏览器内置的类型,由 interface 风格定义
  2. 返回值类型Promise<Response>------注意这里不是 Response,而是被 Promise<> 包裹的版本

为什么是 Promise<Response> 而不是 Response?因为网络 I/O 本身就是异步 的。前置文章里我们手写过 sleep(t) 函数返回一个 Promise,这里的 fetch 也是同样的逻辑:服务器不会"立即"拿到响应结果,所以 TypeScript 用 Promise<T> 来表达"将来某个时刻会给你一个 T"。

typescript 复制代码
// 同步函数的写法(不存在网络 I/O)
function getHello(): string {
    return 'Hello';  // 立即返回
}

// 异步函数的写法(存在网络 I/O / 磁盘读写 / 定时器)
async function getTodos(): Promise<Todo[]> {
    const res = await fetch('http://127.0.0.1:8080/todos');
    const data = await res.json();
    return data.todos;  // 异步返回
}

async 关键字做了两件事:

  • 自动把函数返回值包成 Promise<T>(所以 Promise<Response> 也可以省略 TS 自动推断)
  • 允许函数体内使用 await 关键字

如果未来要在 fetch 里读 POST 请求体(用 await req.json() 解析 JSON),这套 async/Promise 机制就会直接派上用场------这也是后面扩展 API 的伏笔。

处理跨域与解析请求

前端和后端跑在不同端口上,会遇到跨域问题。完整的 CORS 配置不仅要处理 Access-Control-Allow-Origin,还要处理预检请求(OPTIONS)------浏览器在发送复杂请求(POST/PUT/DELETE 或带自定义头)前,会先发一个 OPTIONS 请求"探路":

typescript 复制代码
// 完整的 CORS 头配置
const headers = {
    'Access-Control-Allow-Origin': '*',           // * 通配符,允许所有域名访问
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',  // 允许的 HTTP 方法
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',      // 允许的请求头
};

// 预检请求直接返回 204
if (req.method === 'OPTIONS') {
    return new Response(null, { status: 204, headers });
}

只在 Access-Control-Allow-Origin* 在开发阶段够用,但生产环境建议指定具体域名 (如 https://your-site.com),* 会带来安全隐患------无法携带 cookie,也无法限制来源。

接下来解析 URL:

typescript 复制代码
// URL 结构: 协议 + 域名 + 端口 + 路径 + 查询字符串
// https:// 127.0.0.1:8080/todos
// https:// 127.0.0.1:8080/todos?id=1
const url = new URL(req.url); // 拿到用户访问的 url 的内置资源(路径、查询参数等)

拆解一下 URL 的结构:

部分 示例 含义
协议 https:// 通信协议
域名/IP 127.0.0.1 服务器地址
端口 :8080 服务进程标识
路径 /todos 资源位置(pathname)
查询字符串 ?id=1 参数(searchParams)

通过 new URL(req.url) 可以拿到 pathname(路径)、searchParams(查询参数对象)等。比手动用 split('/') 切字符串更健壮。

整个 fetch 函数是异步的,用 async 声明。await 用于控制异步任务的流程。

路由设计:获取任务列表

typescript 复制代码
if(url.pathname === '/todos'){
    return Response.json({msg:'获取todos列表',todos}, {headers});
}

当用户访问 /todos 时,返回整个任务列表。

路由设计:获取任务详情

typescript 复制代码
// url.pathname 是用户访问的路径 string ,startsWith('/todos/') 是判断是否以 /todos/ 开头
// todos/:id 详情
if(req.method === 'GET' && url.pathname.startsWith('/todos/')){
    // 从 url 中获取 id
    const id = url.pathname.split('/')[2];
    // 从 todos 中根据 id 查找对应的 todo
    const todo = todos.find((todo) => todo.id === id);
    return Response.json({msg:'获取todo详情',todo}, {headers});
}

这里有两个细节:

  1. url.pathname 是字符串,用 startsWith('/todos/') 判断是否以 /todos/ 开头
  2. 从 URL 中截取 id:url.pathname.split('/')[2],然后在 todos 数组中用 find 方法查找匹配的 todo

但这里有个隐患:find 没找到时会返回 undefined,此时直接返回 {todo: undefined} 给前端,HTTP 状态码还是 200,前端无法区分"找到了"还是"没找到"。更规范的做法是返回 404 状态码

typescript 复制代码
if(req.method === 'GET' && url.pathname.startsWith('/todos/')){
    const id = url.pathname.split('/')[2];
    const todo = todos.find((todo) => todo.id === id);
  
    if (!todo) {
        // 资源不存在,返回 404
        return Response.json(
            { msg: `id 为 ${id} 的 todo 不存在` },
            { status: 404, headers }
        );
    }
    return Response.json({ msg: '获取todo详情', todo }, { headers });
}

更进一步的隐患startsWith('/todos/') 会匹配 /todos/(id 为空字符串)和 /todos/1/extra(含多余路径)。用正则做严格匹配更稳健:

typescript 复制代码
// 正则匹配 /todos/:id,捕获组 1 就是 id
const todoMatch = url.pathname.match(/^\/todos\/([^\/]+)$/);
if (req.method === 'GET' && todoMatch) {
    const id = todoMatch[1]; // 比 split('/')[2] 严谨得多
    const todo = todos.find((t) => t.id === id);
    // ... 后续逻辑
}

[^\/]+ 表示"一个或多个非斜杠字符",确保 id 里不会出现第二个 /。理解了这层"边界检查"的考虑,路由设计才算真正稳了。

完整 CRUD 路由:从只读到读写

只支持 GET 的 API 叫"读",支持增删改查的才叫 RESTful。下面把剩下的 POSTPUTPATCHDELETE 补齐------同时也兑现前面埋伏笔的"用 await req.json() 解析请求体"。

POST /todos --- 创建任务(需要解析请求体 + 生成 ID + 输入校验):

typescript 复制代码
// 创建任务的入参接口 ------ 体现"接口约束一切"
interface CreateTodoInput {
    title: string; // 必填
    completed?: boolean; // 可选,默认 false
}

if (req.method === 'POST' && url.pathname === '/todos') {
    // 1. 解析请求体:await req.json() 把 JSON 字符串转成对象
    const input = (await req.json()) as CreateTodoInput;

    // 2. 输入校验:title 必填且非空
    if (!input.title || input.title.trim() === '') {
        return Response.json(
            { msg: 'title 不能为空' },
            { status: 400, headers }
        );
    }

    // 3. ID 生成策略:Date.now().toString() 简单够用
    //    生产环境推荐 crypto.randomUUID() (Bun 原生支持)
    const newTodo: Todo = {
        id: crypto.randomUUID(), // Bun/Node 内置,无需 import
        title: input.title.trim(),
        completed: input.completed ?? false, // ?? 是空值合并运算符
        createdAt: new Date(),
    };
    todos.push(newTodo);

    // 4. 状态码:POST 创建成功用 201 Created,不是 200
    return Response.json(
        { msg: '创建成功', todo: newTodo },
        { status: 201, headers }
    );
}

PUT /todos/:id --- 整体替换(传整个对象,缺字段就清空):

typescript 复制代码
interface UpdateTodoInput {
    title: string;
    completed: boolean;
}

if (req.method === 'PUT' && todoMatch) {
    const id = todoMatch[1];
    const idx = todos.findIndex((t) => t.id === id);

    if (idx === -1) {
        return Response.json({ msg: 'todo 不存在' }, { status: 404, headers });
    }

    const input = (await req.json()) as UpdateTodoInput;
    // PUT 是整体替换:所有字段都必须传
    if (typeof input.title !== 'string' || typeof input.completed !== 'boolean') {
        return Response.json(
            { msg: 'PUT 必须传完整的 title 和 completed' },
            { status: 400, headers }
        );
    }

    todos[idx] = {
        ...todos[idx], // 保留 id 和 createdAt
        title: input.title,
        completed: input.completed,
    };

    return Response.json({ msg: '整体更新成功', todo: todos[idx] }, { headers });
}

PATCH /todos/:id --- 局部更新(只传要改的字段):

typescript 复制代码
if (req.method === 'PATCH' && todoMatch) {
    const id = todoMatch[1];
    const idx = todos.findIndex((t) => t.id === id);

    if (idx === -1) {
        return Response.json({ msg: 'todo 不存在' }, { status: 404, headers });
    }

    // PATCH 局部更新:用 Partial<T> 表示"所有字段都可省"
    const input = (await req.json()) as Partial<UpdateTodoInput>;
    // 只覆盖传过来的字段
    if (input.title !== undefined) todos[idx].title = input.title;
    if (input.completed !== undefined) todos[idx].completed = input.completed;

    return Response.json({ msg: '局部更新成功', todo: todos[idx] }, { headers });
}

Partial<T> 是 TypeScript 内置的类型工具:把 T 的所有字段都变成可选。这正好对应 PATCH 的语义------客户端只发"变化的部分"。

DELETE /todos/:id --- 删除任务(成功无返回体):

typescript 复制代码
if (req.method === 'DELETE' && todoMatch) {
    const id = todoMatch[1];
    const idx = todos.findIndex((t) => t.id === id);

    if (idx === -1) {
        return Response.json({ msg: 'todo 不存在' }, { status: 404, headers });
    }

    todos.splice(idx, 1);
    // 204 No Content:成功但无返回体 ------ 状态码表里说过
    return new Response(null, { status: 204, headers });
}

整合后的完整 fetch 函数(伪代码形式展示结构):

typescript 复制代码
async fetch(req) {
    // 1. CORS 预检
    if (req.method === 'OPTIONS') {
        return new Response(null, { status: 204, headers });
    }

    const url = new URL(req.url);
    const todoMatch = url.pathname.match(/^\/todos\/([^\/]+)$/);

    try {
        // 2. 路由分发
        if (req.method === 'GET' && url.pathname === '/todos') { /* 列表 */ }
        if (req.method === 'GET' && todoMatch) { /* 详情 */ }
        if (req.method === 'POST' && url.pathname === '/todos') { /* 创建 */ }
        if (req.method === 'PUT' && todoMatch) { /* 整体替换 */ }
        if (req.method === 'PATCH' && todoMatch) { /* 局部更新 */ }
        if (req.method === 'DELETE' && todoMatch) { /* 删除 */ }

        // 3. 未匹配
        return Response.json({ msg: 'Not Found' }, { status: 404, headers });
    } catch (err) {
        // 4. 全局兜底:捕获路由中未处理的异常
        return Response.json(
            { msg: '服务器内部错误', error: String(err) },
            { status: 500, headers }
        );
    }
}

注意几个关键设计:

| 设计点 | 体现的原则 |
|----------------------------|--------------------------|---------|---------------------------------------------------|
| crypto.randomUUID() 做 ID | Bun 原生支持,无需引入 uuid 包 |
| Partial<T> 用于 PATCH 入参 | TS 内置类型工具,与 interface 协同 |
| ?? false 替代 ` | | false` | 空值合并运算符(??)只对 null/undefined 兜底,不会误伤 0/"" |
| 整体 try/catch 兜底 | 即使路由逻辑抛错,也返回规范的 500 |

类型转换的实战应用:从 URL 提取数值

前置文章里我们演示过 parseIntNumber+b 三种类型转换方式,但只停留在"两个变量相加"的简单场景。在真实的路由设计中,类型转换几乎无处不在------因为 HTTP 协议本质上只传字符串。

URL 路径里的 id 本质上是字符串(/todos/1 中的 1 是字符 '1'),但当我们要做"查找第 N 个任务"或"分页 offset"等场景时,就要把它转成数字。这里把三种方式的实战表现对照一下:

typescript 复制代码
const id = url.pathname.split('/')[2]; // id 是 string 类型

// 场景 A:find 比较(string 即可,不需要转)
const todo = todos.find((t) => t.id === id); // ✅ string === string

// 场景 B:需要参与数值计算(必须转 number)
const offset = id;
const pageSize = 10;
const start = +offset;              // 一元加号:最简洁的写法
const end = parseInt(offset) + pageSize;   // parseInt:显式函数调用
const total = Number(offset) * 2;          // Number:显式函数调用

三种方式的取舍建议:

方式 示例 推荐场景
parseInt(str) parseInt('10')10 明确知道是整数(如分页、索引)
Number(str) Number('10.5')10.5 可能出现小数(如价格、坐标)
+str +'10'10 简洁场景,但慎用------可读性差

项目里更安全的做法:用类型断言显式标注,配合前置文章里"接口 + 泛型"的思想做参数校验:

typescript 复制代码
// 进阶:定义查询参数接口 + 安全解析
interface PageQuery {
    offset: number;
    limit: number;
}

function parsePageQuery(searchParams: URLSearchParams): PageQuery {
    return {
        offset: parseInt(searchParams.get('offset') || '0'),
        limit: Number(searchParams.get('limit') || '10'),
    };
}

到这里你应该能感觉到:类型约束不只是"加个冒号",它会一路影响数据的解析、转换、传递。前置文章里我们学会的"工具",在这一节里被真正"用"起来了。

最后,如果路径不匹配,默认返回一个 hello world:

typescript 复制代码
return Response.json({ msg: 'hello world' }, { headers });

改进版:当路径不匹配时,更专业的做法是返回 404 而不是 200:

typescript 复制代码
return Response.json({ msg: 'Not Found' }, { status: 404, headers });

这就是 RESTful 的精髓------让 HTTP 状态码替你说话,前端不用解析 body 就能知道结果。

前端联调:fetch 与 async/await

后端跑起来了,前端怎么调用?我写了一个最简单的 HTML 页面来演示联调过程。

Promise 的 .then 写法(注释掉的版本)

javascript 复制代码
// promise
// fetch('http://127.0.0.1:8080/todos')
// .then(res => res.json()) // thenable 异步变同步
// .then(data => { // 异步
//     // console.log(data);
//     todos.innerHTML = data.todos.map(
//         todo => `<li>${todo.title}</li>`
//     ).join('');
// })

这种写法被称为 "thenable"------把异步变成同步的感觉。但有一个小问题:then 方法可读性不好,需要在后面加函数

async/await 写法(实际使用的版本)

javascript 复制代码
// async/await
async function getTodos() {
    // then 方法 可读性不好,需要在后面加函数
    // await  fetch 请求的返回值是一个 promise 对象
    const res = await fetch('http://127.0.0.1:8080/todos');
    const data = await res.json();
    todos.innerHTML = data.todos.map(
        todo => `<li>${todo.title}</li>`
    ).join('');
}
getTodos();

await 让代码读起来像同步代码。fetch 请求的返回值是一个 Promise 对象,用 await 可以"等待"它完成。我把返回的 todos 数组用 map 转成 <li> 标签,再用 join('') 拼成字符串塞进 DOM。这对初学者来说,心智负担降低了一个数量级。

坑点提醒fetch 只在网络层面 失败时才会 reject(比如断网、DNS 错误)。如果服务器返回 404、500,fetch 仍然会 resolve------因为 HTTP 协议层面"请求-响应"是成功的。所以必须手动检查 res.okres.status

javascript 复制代码
async function getTodos() {
    try {
        const res = await fetch('http://127.0.0.1:8080/todos');
      
        if (!res.ok) {
            // fetch 不会自动 throw,需要手动处理 4xx/5xx
            console.error(`请求失败: ${res.status}`);
            return;
        }
      
        const data = await res.json();
        todos.innerHTML = data.todos.map(
            todo => `<li>${todo.title}</li>`
        ).join('');
    } catch (err) {
        // 网络错误、断网、跨域等才会进这里
        console.error('网络异常:', err);
    }
}

对比 axios:很多前端库(比如 axios)会自动 throw 4xx/5xx,这就是为什么很多人从 axios 切到 fetch 时会踩坑------fetch 不会。

fetch vs axios:两种风格的取舍

前置文章里我们已经用 axios 调过 OpenAI 大模型接口,再来对比一下两者的设计哲学,会有更深的体感:

维度 fetch(浏览器原生) axios(第三方库)
错误处理 4xx/5xx 不自动 throw,需手动 res.ok 检查 4xx/5xx 自动 throw,可直接 try/catch
响应数据 res.json() 返回 Promise,要 await 两次 res.data 直接拿到,无需二次解析
拦截器 无内置 interceptors(统一加 token、错误提示)
请求体 JSON.stringify(data) + 手动设 header axios.post(url, data) 自动序列化
体积 浏览器内置,0 体积 第三方包,约 13KB(gzip)

如果只是做一个简单 demo,fetch 完全够用。但当项目长大、需要统一鉴权、统一错误提示时,axios 的"拦截器"机制会非常香。在 Bun 环境下推荐:浏览器侧用 fetch(零依赖),Node/Bun 服务端用原生 fetch + 自封装工具函数------Bun 本身就是"开箱即用"理念的产物,强行引入 axios 反而冗余。

更关键的一点是:后端的 Bun.serve 里的 fetch 参数、Node 里的 fetch API、浏览器里的 window.fetch------三者签名几乎一致。这套 Web 标准的统一性,让前后端的 HTTP 客户端代码可以无缝迁移。理解了这一点,"前后端联调"就不再是两个世界的对话。

项目虽小,但串联了后端的核心链路

这个小项目完整地串联了后端开发的几个关键环节:

  • 类型安全 :用 interface 约束数据结构,避免运行时出错
  • RESTful 语义:用资源名词 + HTTP 动词设计 API,让接口自解释
  • HTTP 协议:理解请求-响应模型、URL 结构、端口和 IP 的关系
  • 前后端通信:CORS 跨域、fetch API、Promise 与 async/await
  • 路由设计:路径匹配、参数提取、数据查询
  • 类型转换 :从 URL 字符串提取数值时 parseInt / Number / + 的取舍
  • 环境变量 :敏感信息(API Key)通过 .env 管理而非硬编码

对于一个刚接触后端开发的学生来说,亲手用 Bun 跑通这样一条链路,成长远比看十篇文章更大。Bun 的 Bun.serve API 设计非常简洁,不需要像 Node.js 那样引入 http 模块,也不需要繁琐的配置。

扩展延伸:环境变量与项目配置

前置文章里我们用 dotenv 管理过 OpenAI 的 API Key。同样的理念在 Bun 项目中同样适用------任何"环境相关"的值都不应该硬编码。

实战中通常会遇到三类需要放进 .env 的值:

  1. 端口号:开发用 8080,生产可能用 80/443
  2. 数据库连接串:本地 SQLite、生产 MySQL
  3. 第三方 API Key:OpenAI、Anthropic、Stripe 等

Bun 对 .env 文件有原生支持 ------不需要 dotenv 库,直接读:

typescript 复制代码
// bun.config.ts
const server = Bun.serve({
    port: parseInt(process.env.PORT || '8080'),
    hostname: process.env.HOSTNAME || '0.0.0.0',
    async fetch(req) {
        const apiKey = process.env.OPENAI_API_KEY; // 从 .env 读取
        // ... 业务逻辑
    },
});

.env 文件内容(记得加进 .gitignore):

bash 复制代码
PORT=8080
HOSTNAME=0.0.0.0
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://api.openai.com/v1

更重要的是类型安全的环境变量 。直接用 process.env.X 的问题是:变量名拼错不会报错,运行时才崩。可以用接口 + 工具函数做一次封装:

typescript 复制代码
// 衔接前置文章的 interface 思想
interface EnvConfig {
    PORT: number;
    HOSTNAME: string;
    OPENAI_API_KEY: string;
}

function loadEnv(): EnvConfig {
    return {
        PORT: parseInt(process.env.PORT || '8080'),
        HOSTNAME: process.env.HOSTNAME || '0.0.0.0',
        OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
    };
}

const env = loadEnv();

这样 env.PORT 就有完整的类型推导和补全。这就是从"前置文章的 dotenv 知识"到"项目级配置管理"的进化路径------接口约束的对象不再只是 Todo 这种业务数据,环境配置本身也是数据

最后,回到开头的问题:为什么要约束 todos 的类型?------为了任务的准确性。接口不是束缚,而是契约。RESTful 不是规范,而是让 URL 自己说话的设计哲学。理解了这两点,后端的门才算真正推开了一道缝。

相关推荐
雨辰AI1 天前
生产级实测:SpringBoot3 + 达梦数据库接口从 200ms 优化至 20ms 完整调优指南
java·数据库·spring boot·后端·政务
Solis1 天前
Raft:分布式系统的定海神针
后端·架构
程序员老申1 天前
第三篇 5 天 12 个 commit:踩坑实录与代码演进
后端·程序员
程序员鱼皮1 天前
提示词工程已死,Loop Engineering 称王!保姆级教程 + 项目实战
前端·后端·ai编程
LeahDizon1 天前
AI Coding 协作实践方案
程序员·github·代码规范
Mininglamp_27181 天前
Vibe Coding 之后是 Vibe Operating?
后端·开源·多智能体·ai agent·mano-p
星哥的编程之路1 天前
别再调 API 就说自己会 RAG 了,看看真正的企业级 AI 智能体长什么样
后端·面试
长大19881 天前
C++26 静态反射完整实战:告别宏代码生成,一键实现序列化
后端
yb7791 天前
Java 21 虚拟线程最佳实践:虚拟线程如何让高并发 Java 服务更轻更快
后端
fliter1 天前
绕过系统 ICMP:用 rawsock、Npcap 和 WMI 找到默认网卡
后端