Web 框架生态
本教程共 34 篇 · 第 31 篇 · 更新于 2026-08-06
本节目标:
- 想清楚一个问题——
Bun.serve已经内置路由了,什么时候还需要 Web 框架。- 掌握 Elysia 的四个核心概念:路由、生命周期钩子、校验、插件,并能写出最小可运行片段。
- 掌握 Hono 的最小用法与「洋葱模型」中间件,理解它与 Elysia 的定位差异。
- 建立一张选型对照表,知道在什么场景选谁,以及去哪里找生态里的其他轮子。
31.1 先问一句:还需要框架吗
从第 27 章我们已经知道,Bun.serve() 自带一个基于树结构的路由器,支持静态路径、参数路径和通配符:
// server.ts —— 不用任何框架的最小 HTTP 服务
Bun.serve({
routes: {
"/": () => new Response("Home"),
"/api/users/:id": req => Response.json({ id: req.params.id }),
},
fetch() {
return new Response("Not Found", { status: 404 });
},
});
这段代码没有任何依赖,启动即用,性能也是这一层里最好的。既然如此,为什么 Bun 生态里还活跃着 Elysia、Hono 这样一批框架?
原因在于路由只是 Web 服务的一小部分。当一个服务从「返回一个字符串」长成「有几十条接口的后端」时,你迟早会遇到这些需求:
- 请求体、查询参数、路径参数需要校验,并且校验失败要统一返回 400 而不是抛出 500。
- 横切逻辑需要复用:鉴权、日志、CORS、限流、错误兜底,这些逻辑不属于任何一条具体路由,却要作用在一批路由上。
- 类型要贯穿全链路:路由声明了
id是数字,处理函数里拿到的就应该是number,而不是每次手动Number(req.params.id)。 - 接口要能自描述:自动生成 OpenAPI 文档,或者让前端直接复用后端的类型。
- 代码要能分组和拆分:按业务模块拆成多个文件,各自带自己的前缀和中间件。
这几件事每个团队都要做一遍,框架的价值就是把它们标准化。
所以本章定位很明确:不带你用框架写完整应用,只讲清楚这些框架用什么概念解决上面这些问题。每个概念配一个能单独跑起来的最小片段。
Note判断标准可以很朴素:如果你的服务只有几条路由、逻辑简单、想要极致的启动速度和零依赖,直接用
Bun.serve就够了;一旦开始手写「参数校验 + 鉴权中间件 + 错误处理」这三件套,就该考虑引入框架了。
31.2 Elysia:为 Bun 而生的框架
Elysia 在 Bun 官方生态指南里有专门一章。官方的定性是 Bun-first 框架:它直接构建在 Bun 的 HTTP、文件系统与热重载 API 之上。
官方指南给出的初始化方式是:
bun create elysia myapp
cd myapp
bun run dev
也可以在已有项目里直接装:
bun add elysia
最小路由
Elysia 的 API 是链式的,每个 HTTP 动词对应一个方法:
// server.ts
import { Elysia } from "elysia";
const app = new Elysia().get("/", () => "Hello Elysia").listen(8080);
console.log(`🦊 Elysia is running on port ${app.server?.port}...`);
用 bun run server.ts 启动即可。
返回值不用手动包成 Response:返回字符串、对象、数组,Elysia 会自动推断 Content-Type 并序列化。如果处理函数只是返回一个常量,函数都可以省掉,直接写值:
import { Elysia } from "elysia";
new Elysia()
.get("/", "hello")
.get("/hi", "hi")
.listen(3000);
三种路径类型
和 Bun.serve 一样,Elysia 把路径分为三类,可以混用:
import { Elysia } from "elysia";
new Elysia()
.get("/id/1", "static path") // 静态路径:完全匹配
.get("/id/:id", "dynamic path") // 动态路径:一段任意值
.get("/id/*", "wildcard path") // 通配符:其后任意深度
.listen(3000);
匹配优先级为「静态 > 动态 > 通配符」,也就是说访问 /id/1 命中第一条,/id/2 命中第二条,/id/2/a 命中第三条。动态段的值从 params 里取:
import { Elysia } from "elysia";
new Elysia()
.get("/id/:id", ({ params: { id } }) => `id: ${id}`)
.listen(3000);
路由分组
路由多了以后要按模块分组。Elysia 提供两种写法,效果等价:
import { Elysia } from "elysia";
// 写法一:group —— 就地分组
new Elysia()
.group("/user", app =>
app
.post("/sign-in", "Sign in")
.post("/sign-up", "Sign up")
.post("/profile", "Profile"),
)
.listen(3000);
// 写法二:prefix —— 拆成独立实例,便于分文件
import { Elysia } from "elysia";
const users = new Elysia({ prefix: "/user" })
.post("/sign-in", "Sign in")
.post("/sign-up", "Sign up")
.post("/profile", "Profile");
new Elysia().get("/", "hello world").use(users).listen(3000);
第二种写法尤其重要:一个 Elysia 实例本身就是一个可以被 .use() 装载的单元,这既是分模块的方式,也是插件机制的基础。
生命周期:比「中间件」更细的分层
大多数框架用一条扁平的「中间件链」处理横切逻辑。Elysia 换了个思路,把一次请求拆成若干个生命周期事件(Lifecycle Event),按顺序依次是:
| 阶段 | 职责 |
|---|---|
request | 收到新请求的最早通知点 |
parse | 把请求体解析进 Context.body |
transform | 校验之前修改 Context |
beforeHandle | 校验之后、进入处理函数之前的自定义拦截 |
afterHandle | 加工处理函数的返回值 |
mapResponse | 把返回值映射成 HTTP 响应 |
onError | 处理生命周期中抛出的错误 |
afterResponse | 响应发出之后做清理 |
挂在这些事件上的函数叫钩子(Hook),分两类。局部钩子只对某一条路由生效,写在路由的第三个参数里:
import { Elysia } from "elysia";
import { isHtml } from "@elysia/html";
new Elysia()
.get("/", () => "<h1>Hello World</h1>", {
afterHandle({ responseValue, set }) {
if (isHtml(responseValue))
set.headers["Content-Type"] = "text/html; charset=utf8";
},
})
.get("/hi", () => "<h1>Hello World</h1>")
.listen(3000);
结果是 / 返回 text/html,/hi 仍是 text/plain。拦截器钩子用 .onXxx() 注册,对注册之后的所有路由生效:
import { Elysia } from "elysia";
import { isHtml } from "@elysia/html";
new Elysia()
.get("/none", () => "<h1>Hello World</h1>") // 注册在钩子之前,不受影响
.onAfterHandle(({ responseValue, set }) => {
if (isHtml(responseValue))
set.headers["Content-Type"] = "text/html; charset=utf8";
})
.get("/", () => "<h1>Hello World</h1>") // 受影响
.get("/hi", () => "<h1>Hello World</h1>") // 受影响
.listen(3000);
Warning注意最后这句「对注册之后的路由生效」——代码顺序在 Elysia 里是有语义的。如果你把
onError写在.use(plugin)之前,插件里的路由不会继承这个错误处理。这是初学者最容易踩的坑:钩子明明写了却「不生效」,多半是位置放错了。
校验:一份 Schema,三处收益
Elysia 内置了一个名为 t 的 Schema 构造器,基于 TypeBox。把 Schema 挂到路由上,一份声明换三份收益:运行时校验、编译期类型推导、OpenAPI 文档生成。
import { Elysia, t } from "elysia";
new Elysia()
.get("/id/:id", ({ params: { id } }) => id, {
params: t.Object({
id: t.Number(),
}),
})
.listen(3000);
声明 id: t.Number() 之后,处理函数里拿到的 id 就是 number,字符串到数字的转换已经做完。传入非数字时框架自动返回校验错误,业务代码里一行 if 都不用写。
Elysia 也支持 Standard Schema 规范。这意味着 Zod、Valibot、ArkType、Effect Schema 等库都能直接用,甚至能在同一个处理函数里混用:
import { Elysia } from "elysia";
import { z } from "zod";
import * as v from "valibot";
new Elysia()
.get("/id/:id", ({ params: { id }, query: { name } }) => id, {
params: z.object({
id: z.coerce.number(),
}),
query: v.object({
name: v.literal("Lilith"),
}),
})
.listen(3000);
Tip团队里如果已经在前端用 Zod 定义了表单校验,后端直接复用同一份 Schema 是很自然的选择;如果是全新项目,用内置的
t可以少装一个依赖,并且和 Elysia 的类型推导结合得最紧密。
插件:Elysia 实例即插件
前面提过,Elysia 实例可以被 .use() 装载,这就是插件。一个插件通常长这样——给实例起个 name(用于去重),然后正常声明路由或钩子:
// plugin.ts
import { Elysia } from "elysia";
export const myPlugin = (config: { prefix: string }) =>
new Elysia({
name: "my-plugin",
seed: config,
}).get(`${config.prefix}/hi`, () => "Hi");
// index.ts
import { Elysia } from "elysia";
import { myPlugin } from "./plugin";
new Elysia().use(myPlugin({ prefix: "/v2" })).listen(3000);
官方与社区维护了一批常用插件:JWT 鉴权、CORS、静态文件、Swagger/OpenAPI、tRPC 适配等。
Elysia 还有个配套包叫 Eden,能让前端像调用本地函数一样调用后端接口,并拿到完整类型提示。这是跨端类型贯通的典型收益,也是深度绑定单一运行时才好做的事。
31.3 Hono:跨运行时的轻量选择
Hono 是另一个在 Bun 上很常见的框架。它的设计目标是面向边缘(edge)的轻量框架:同一份代码可以跑在 Cloudflare Workers、Deno、Bun、Node.js 以及各家边缘平台上。
官方指南给出的最小示例:
// server.ts
import { Hono } from "hono";
const app = new Hono();
app.get("/", c => c.text("Hono!"));
export default app;
bun run server.ts
注意这里是 export default app,没有显式调用 listen。Bun 会识别默认导出对象上的 fetch 方法并自动起服务,端口默认 3000。要指定端口就换成对象形式:
import { Hono } from "hono";
const app = new Hono();
app.get("/", c => c.text("Hono!"));
export default {
port: 8080,
fetch: app.fetch,
};
用脚手架初始化项目时,在模板选择里选 bun:
bun create hono myapp
cd myapp
bun install
bun run dev
上下文对象与中间件
Hono 的处理函数只接收一个上下文 c,请求信息从 c.req 取,响应用 c.text() / c.json() / c.html() 构造:
import { Hono } from "hono";
const app = new Hono();
app.get("/users/:id", c => {
const id = c.req.param("id");
const page = c.req.query("page");
return c.json({ id, page });
});
export default app;
Hono 的中间件走的是经典洋葱模型:await next() 之前的代码在进入处理函数前跑,之后的代码在处理函数返回后跑。
这和 Elysia 拆成 8 个具名阶段是两种抽象思路,各有各的好:
import { Hono } from "hono";
const app = new Hono();
// 自定义中间件:记录耗时
app.use("*", async (c, next) => {
const start = performance.now();
await next();
const ms = performance.now() - start;
c.res.headers.set("X-Response-Time", `${ms.toFixed(2)}ms`);
});
app.get("/", c => c.text("Hello"));
export default app;
Hono 内置了一批开箱即用的中间件:CORS、basic auth、JWT、压缩、日志、静态文件等。按需从子路径导入,没用到的不会打进产物。
校验方面 Hono 走的是适配器路线,通过 @hono/zod-validator 这类包接入外部校验库,不自带 Schema 构造器。
NoteElysia 与 Hono 并不是替代关系。Elysia 深度绑定 Bun,换取更强的类型推导与更贴合 Bun 的能力;Hono 用一层薄薄的 Web 标准抽象换取跨运行时可移植性。选哪个,取决于你的服务未来是否可能迁到边缘平台。
31.4 选型对照
| 维度 | Bun.serve(无框架) | Elysia | Hono |
|---|---|---|---|
| 依赖 | 零 | 一个包 | 一个包(中间件按需) |
| 定位 | 运行时内置路由 | Bun-first 全功能框架 | 跨运行时轻量框架 |
| 路由 | routes 对象 | 链式 .get/.post | 链式 .get/.post |
| 横切逻辑 | 自己在 fetch 里写 | 8 阶段生命周期钩子 | 洋葱模型中间件 |
| 校验 | 自己写 | 内置 t + Standard Schema | 适配器(如 zod-validator) |
| 类型推导 | 基础 | 强(可用 Eden 贯通前后端) | 中等(上下文泛型) |
| 可移植性 | 仅 Bun | 主要 Bun(也支持 Node) | Bun / Node / Deno / Workers |
| 适合场景 | 小服务、内部工具、极致性能 | Bun 上的完整后端 | 边缘函数、多运行时部署 |
除了这两个,awesome-bun 精选清单里还有不少别的方向,可以按需了解:Vixeny(纯函数式)、Primate(可扩展、极简)、NBit(零依赖强类型,跨 Bun/Node/Workers)、Brisa(带 Server Actions 与 Web Components 的全栈框架)、GraphQL Yoga(GraphQL 服务端)。
数据层常见的是 Drizzle、Prisma、Kysely 这些 ORM 与查询构造器。它们和 Bun 内置 bun:sqlite、Bun.sql 的关系,第 29 章已经说过。
Tip想横向比较各框架在 Bun 上的吞吐量,社区维护了 bun-http-framework-benchmark 这样的公开项目。看基准数据时请注意:hello-world 级别的压测反映的是框架路由层的开销上限,真实业务里数据库、序列化、网络往往才是瓶颈,不要把这类数字当作唯一选型依据。
31.5 小结与常见误区
本章没带你写完整应用,而是把框架层的四个核心概念拆开讲清楚了:路由决定请求去哪里,生命周期/中间件承载横切逻辑,校验把不可信输入挡在业务代码之外,插件让这一切能按模块拆分复用。
掌握这四点,以后不管换哪个框架,读文档的速度都会快很多。
几个常见误区:
- 「用了框架就不用懂
Bun.serve了」——恰恰相反。Elysia 的listen、Hono 在 Bun 上的默认导出,底层都落到Bun.serve。理解运行时这一层,排查端口占用、TLS、WebSocket 升级这类问题时才不会抓瞎。 - 「钩子写了就一定生效」——Elysia 的拦截器钩子只对注册之后的路由生效,插件的装载顺序同样重要。遇到钩子「失效」,先检查代码顺序。
- 「基准分数高的框架就一定合适」——框架层的差异在真实业务里常常被数据库查询淹没。可维护性、类型体验、团队熟悉度通常比几个百分点的吞吐更重要。
- 「Hono 只能在边缘跑」——Hono 在 Bun 上是一等公民,只是它的抽象层刻意保持了跨运行时可移植性而已。
- 把框架当成实战项目脚手架——
bun create elysia/bun create hono生成的模板确实方便,但模板里的目录结构不是规范。理解概念之后,按自己的业务组织代码即可。
下一章我们把视线从「怎么写」转到「怎么上线」,看看 Bun 应用在 Docker、Vercel、Railway、Render 以及各类云函数上的部署方式与注意事项。