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

0 阅读14分钟

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 并不是凭空出现的——它是"基础类型"这套工具的升级版

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

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

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

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

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

看看我为 Todo 定义的接口:

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

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

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

// 资源
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 实例化,必须由具体子类继承并实现所有抽象方法。这种"半成品"的设计非常适合做"基类模板"。

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 内置了一个高性能的服务器,用起来非常简洁:

// 内置了 一个高性能的服务器
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 的完整签名如下,理解每个参数对后续扩展很有帮助:

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"。

// 同步函数的写法(不存在网络 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 请求"探路":

// 完整的 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:

// 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://通信协议
域名/IP127.0.0.1服务器地址
端口:8080服务进程标识
路径/todos资源位置(pathname)
查询字符串?id=1参数(searchParams)

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

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

路由设计:获取任务列表

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

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

路由设计:获取任务详情

// 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 状态码

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(含多余路径)。用正则做严格匹配更稳健:

// 正则匹配 /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 + 输入校验):

// 创建任务的入参接口 —— 体现"接口约束一切"
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 — 整体替换(传整个对象,缺字段就清空):

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 — 局部更新(只传要改的字段):

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 — 删除任务(成功无返回体):

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 函数(伪代码形式展示结构):

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() 做 IDBun 原生支持,无需引入 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"等场景时,就要把它转成数字。这里把三种方式的实战表现对照一下:

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简洁场景,但慎用——可读性差

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

// 进阶:定义查询参数接口 + 安全解析
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:

return Response.json({ msg: 'hello world' }, { headers });

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

return Response.json({ msg: 'Not Found' }, { status: 404, headers });

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

前端联调:fetch 与 async/await

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

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

// 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 写法(实际使用的版本)

// 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

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) + 手动设 headeraxios.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 库,直接读:

// 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):

PORT=8080
HOSTNAME=0.0.0.0
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://api.openai.com/v1

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

// 衔接前置文章的 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 自己说话的设计哲学。理解了这两点,后端的门才算真正推开了一道缝。