Bun + TypeScript 从零到一:用 RESTful 思维构建 AI 全栈

设计思考
一、为什么做这个功能
2024 年 Bun 1.0 发布后,前端工程师手里多了一把瑞士军刀——它同时是包管理器、打包器、打包器,还是一台高性能 HTTP 服务器。跟 Node.js 比,Bun 开箱支持 TypeScript、启动快 4 倍、内置 SQLite,做全栈项目不用再拼凑一堆工具链。
另一方面,AI 大模型应用越来越热,"怎么把 LLM 接入自己的项目"成了高频话题。但大部分教程要么只讲调 Prompt,要么一笔带过 API 调用,从搭服务器到接 AI 的中间地带几乎没人讲清楚。
所以我从零搭了一个 Bun + TS 的 RESTful TodoList,作为地基。然后基于这个地基,去推演"如果要做成 AI 问答系统,会踩哪些坑、怎么设计"。这篇文章就把这条线完整走一遍。
目标:用 Bun 这把新刀,从最简单的增删查改开始,一路推到 AI 接入的工程设计。
二、目标:这个模块要解决什么问题
分两层来看:
| 层次 | 目标 | 解决的问题 |
|---|---|---|
| 已实现:地基 | Bun + TS RESTful API | 搭一个能跑的后端,理解接口、类型、异步 |
| 推演:进化 | AI 问答系统工程设计 | 文件上传、安全、日志、超时等工程问题怎么思考 |
地基阶段交付的是一个经典的 TodoList——提供任务列表查询和详情查询。代码约 50 行,但覆盖了 Bun 服务器、TypeScript 类型约束、RESTful 路由、CORS 跨域、前后端异步通信等核心知识点。
三、设计:前端、后端怎么设计
3.1 整体架构——当前实现
┌──────────────┐ HTTP GET /todos ┌───────────────┐
│ Browser │ ◄─────────────────► │ Bun Server │
│ index.html │ JSON 响应 │ server.ts │
│ fetch API │ │ port: 8080 │
└──────────────┘ └───────────────┘
非常简单:浏览器通过 fetch 发 GET 请求,Bun 服务器返回 JSON 数据,前端渲染成列表。没有数据库——数据存在内存数组里,服务重启就没了,但对理解 HTTP 交互来说够用。
3.2 后端设计:一切皆资源
RESTful 的核心思想很简单:URL 定位资源(名词),HTTP 动词表达操作。就像快递员看地址送货——/todos 是一个仓库地址,GET 是"取货",POST 是"存货"。
把路由想象成警察分派案件:不同的"报案"(URL),分给不同的"辖区"(路由处理函数),各管各的,互不干扰。
当前项目的资源定义:
| 资源 | URL | 方法 | 说明 |
|---|---|---|---|
| 任务列表 | /todos |
GET | 获取全部任务 |
| 单个任务 | /todos/1 |
GET | 获取 id 为 1 的任务详情 |
这里用到了 TypeScript 的 interface——可以说它是整个后端设计的基石。
面向对象编程有三大支柱:封装、继承、多态。而 interface(接口) 是这三者的"合同书"——它声明一个对象必须有哪些属性和方法,但不关心具体怎么实现。抽象类负责"满足合同条款",具体类负责"干活"。
interface → 约定(合同)
↓
abstract class → 满足约定(框架)
↓
class → 具体实现(干活)
从"面向对象编程"走到"面向接口编程",这是设计模式的第一步。 比如你写了一个 interface Todo,后面不管数据来自内存数组、SQLite、还是远端 API,只要满足这个接口,上层代码一行不用改——插拔式替换。
项目中的核心类型:
// 一个任务就是一个资源,interface 约定了这个资源的"形状"
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() },
{ id: "3", title: "打豆豆", completed: false, createdAt: new Date() },
];
3.3 前端设计:两种异步方式的对决
前端只有一个 index.html,零框架。核心是一个 <ul> 列表 + 一段 JS 脚本。关键在于代码里留下了两种异步调用方式的对比——注释掉的 .then() 链和实际使用的 async/await。
方式一:Promise 链式调用(传统 .then())
// 这是注释掉的老写法
// fetch("http://localhost:8080/todos")
// .then(res => res.json()) // 第一层异步:等响应回来 → 解析 JSON
// .then(data => { // 第二层异步:拿到解析后的数据
// todos.innerHTML = data.map(todo => `<li>${todo.title}</li>`).join("");
// })
每一步 .then() 都是一个新的回调函数。逻辑简单时还好,一旦要加错误处理、条件判断、循环调用,就会变成臭名昭著的回调地狱(Callback Hell)——层层嵌套,代码像"俄罗斯套娃"。
方式二:async/await(现代语法)
// 这是实际使用的写法——像写同步代码一样写异步逻辑
async function main() {
const res = await fetch("http://localhost:8080/todos");
const data = await res.json();
todos.innerHTML = data.map(todo => `<li>${todo.title}</li>`).join("");
}
main();
对比一眼就能看出差别:
| 维度 | .then() |
async/await |
|---|---|---|
| 可读性 | 链式调用,步骤多了眼花 | 像读菜谱一样自上而下 |
| 错误处理 | 最后加 .catch(),定位不精确 |
try/catch 包裹,哪步出错清清楚楚 |
| 调试 | 断点难打(链式跳转) | 断点逐行走,跟同步代码一样 |
| 条件分支 | 要在 .then() 里写 if,很丑 |
直接写 if/else,自然 |
结论:.then() 的可读性确实没有特别好。三步以内的异步用哪个都行,但超过三步、或者有分支和循环时,async/await 是压倒性的优势。后续所有异步代码一律 async/await。
四、实现:核心代码和流程
4.1 Bun 服务器骨架
Bun.serve() 是整台服务器的发动机。不需要 Express、不需要 Koa,一个函数就启动:
const server = Bun.serve({
port: 8080,
// fetch 是所有请求的总入口——就像商场前台
// 不管谁来、来干嘛,都先到这里登记,再由前台分派到对应柜台
async fetch(req) {
// CORS 头:告诉浏览器"我允许跨域请求"
const headers = {
'Access-Control-Allow-Origin': "*"
};
// 解析用户访问的地址
// URL = 协议://主机:端口/路径?参数
// 比如 https://baidu.com:443/search?q=bun
const url = new URL(req.url);
// 路由 1:GET /todos —— 列出所有任务
if (req.method === 'GET' && url.pathname === "/todos") {
return Response.json(todos, { headers });
}
// 路由 2:GET /todos/:id —— 查看单个任务详情
// startsWith 做动态路由匹配
if (req.method === 'GET' && url.pathname.startsWith("/todos/")) {
const id = url.pathname.split("/")[2];
// "/todos/3".split("/") → ["", "todos", "3"],取下标 2 就是 id
const todo = todos.find(t => t.id === id);
console.log(todo, '-----');
return Response.json(todo);
}
// 兜底:没匹配到任何路由,打个招呼
return Response.json({ msg: 'hello world' });
}
});
关键知识点逐条讲:
-
IP 对应一台物理机器,端口区分不同服务:一台服务器只有一个 IP 地址(就像一栋楼只有一个门牌号),但可以开很多端口(就像楼里有很多房间)。HTTP 服务一般用 80 端口,邮件服务用 25 端口,音乐服务用别的端口——各走各的门,互不干扰。
-
服务器处于"伺服状态":
Bun.serve()一旦调用,返回的 server 对象就一直竖着耳朵监听 8080 端口,等待客户端来敲门。这就是"伺服"——一直在服务,随时待命。 -
HTTP 就是请求-响应的回合制游戏:用户发一个 Request(“我要看任务列表”),服务器回一个 Response(“给你 JSON”)。一来一回,一问一答,简单直接。
-
fetch是 Bun.serve 的内置方法:所有请求——不管什么方法、什么路径——都会从这个函数过。所以你的路由逻辑(一个个if/else判断)本质上就是在这个入口函数里做"分流"——像医院挂号台,根据症状(URL)分到不同科室(处理分支)。 -
async/await控制异步流程:fetch函数前有async,意味着里面可以用await等待异步操作完成。比如以后如果要从数据库读数据,直接await db.query(...)就行,代码结构不变。
4.2 前端数据渲染
浏览器拿到 JSON 后,用 map 把数据转成 HTML 字符串,一把塞进 DOM:
todos.innerHTML = data.map(todo => `<li>${todo.title}</li>`).join("");
这行代码虽短,完成了三件事:
data.map(...)—— 把 3 个 todo 对象映射成 3 段<li>吃饭</li>、<li>睡觉</li>、<li>打豆豆</li>.join("")—— 把数组拼成一个长字符串(不加分隔符)todos.innerHTML = ...—— 整个替换<ul>的内容,浏览器一次性重绘
五、踩坑:开发中遇到的实际问题
5.1 CORS 跨域
开发时如果用 file:// 协议直接打开 HTML,或者前端跑在 localhost:3000 而后端在 localhost:8080,浏览器会报经典的红色错误:
Access to fetch at 'http://localhost:8080/todos' from origin 'null' has been blocked by CORS policy
这是浏览器的同源策略——协议、域名、端口三者任何一个不同,就视为"跨域",浏览器默认拦截。
解决:后端响应里加 CORS 头,告诉浏览器"我允许跨域":
const headers = {
'Access-Control-Allow-Origin': "*"
};
* 表示允许任何来源——开发阶段方便,生产环境要收紧到具体域名。
5.2 动态路由匹配
RESTful 风格里,获取单个资源用 /todos/:id 这种带参数的路径(如 /todos/1、/todos/2)。但 Bun.serve 没有内置路由,得手动解析。
做法:用 startsWith("/todos/") 匹配所有详情请求,然后把 URL 拆开取 id:
const id = url.pathname.split("/")[2];
// "/todos/3" → ["", "todos", "3"] → "3"
这是最简单的手工动态路由。接口多了以后应该抽象成路由注册函数,但两三个接口时这样写最直接。
5.3 async/await 必须放在 async 函数里
新手容易犯的错——直接在顶层写 await:
// ❌ 报错:await is only valid in async functions
const res = await fetch("http://localhost:8080/todos");
解决:包一个 async function main(),在里面用 await,然后调用它:
async function main() {
const res = await fetch("http://localhost:8080/todos");
const data = await res.json();
// ...
}
main(); // 别忘了调用!我就是踩了这个坑
最后
这篇文章从一个 50 行的 Bun + TS TodoList 出发,先讲清楚 RESTful、interface 约束、async/await 异步这些地基知识。
核心想表达的是:Bun + TypeScript 把全栈开发的门槛拉到了历史最低。前端工程师不需要学 Go 也能写出高性能服务端,不需要学 Python 也能接入 AI 大模型。先用小项目跑通全流程,再逐步加复杂度——这是一条被验证有效的路线。
地基打好了,盖楼就是一层一层往上加的事。
更多推荐

所有评论(0)