首页 / Bun 入门教程 / Web 框架生态

Bun 入门教程

Web 框架生态

本教程共 34 篇 · 第 31 篇 · 更新于 2026-08-06

BunElysiaHonoWeb 框架路由中间件生态

本节目标:

  • 想清楚一个问题——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 构造器。

Note

Elysia 与 Hono 并不是替代关系。Elysia 深度绑定 Bun,换取更强的类型推导与更贴合 Bun 的能力;Hono 用一层薄薄的 Web 标准抽象换取跨运行时可移植性。选哪个,取决于你的服务未来是否可能迁到边缘平台。

31.4 选型对照

维度Bun.serve(无框架)ElysiaHono
依赖一个包一个包(中间件按需)
定位运行时内置路由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 服务端)。

数据层常见的是 DrizzlePrismaKysely 这些 ORM 与查询构造器。它们和 Bun 内置 bun:sqliteBun.sql 的关系,第 29 章已经说过。

Tip

想横向比较各框架在 Bun 上的吞吐量,社区维护了 bun-http-framework-benchmark 这样的公开项目。看基准数据时请注意:hello-world 级别的压测反映的是框架路由层的开销上限,真实业务里数据库、序列化、网络往往才是瓶颈,不要把这类数字当作唯一选型依据。

31.5 小结与常见误区

本章没带你写完整应用,而是把框架层的四个核心概念拆开讲清楚了:路由决定请求去哪里,生命周期/中间件承载横切逻辑,校验把不可信输入挡在业务代码之外,插件让这一切能按模块拆分复用。

掌握这四点,以后不管换哪个框架,读文档的速度都会快很多。

几个常见误区:

  1. 「用了框架就不用懂 Bun.serve 了」——恰恰相反。Elysia 的 listen、Hono 在 Bun 上的默认导出,底层都落到 Bun.serve。理解运行时这一层,排查端口占用、TLS、WebSocket 升级这类问题时才不会抓瞎。
  2. 「钩子写了就一定生效」——Elysia 的拦截器钩子只对注册之后的路由生效,插件的装载顺序同样重要。遇到钩子「失效」,先检查代码顺序。
  3. 「基准分数高的框架就一定合适」——框架层的差异在真实业务里常常被数据库查询淹没。可维护性、类型体验、团队熟悉度通常比几个百分点的吞吐更重要。
  4. 「Hono 只能在边缘跑」——Hono 在 Bun 上是一等公民,只是它的抽象层刻意保持了跨运行时可移植性而已。
  5. 把框架当成实战项目脚手架——bun create elysia / bun create hono 生成的模板确实方便,但模板里的目录结构不是规范。理解概念之后,按自己的业务组织代码即可。

下一章我们把视线从「怎么写」转到「怎么上线」,看看 Bun 应用在 Docker、Vercel、Railway、Render 以及各类云函数上的部署方式与注意事项。