用了半年 Hono 和 Elysia,我总结了这些坑
Hono 和 Elysia 是目前最火的两个 TypeScript Web 框架,一个主打轻量跨平台,一个主打极致性能。用了半年后,我来聊聊实际开发中遇到的坑。
前言
先说结论:Hono 和 Elysia 都是优秀的框架,性能好、类型支持强。但「优秀」不代表「完美」,在实际项目中,有些设计会让你踩坑。
这篇文章不是要黑这两个框架,而是帮你提前避坑。如果你正在选型,或者已经在用但感觉哪里不对劲,这篇文章可能会有帮助。
Part 1: Hono 的坑
坑 1:路由通配符匹配规则不直观
// 你以为的通配符
app.get('/api/*', handler) // 匹配 /api/xxx
// 实际情况
// ✅ 匹配 /api/users
// ✅ 匹配 /api/users/123
// ❌ 不匹配 /api (没有斜杠后的内容)
// 想同时匹配 /api 和 /api/xxx?
// 方案1:写两遍
app.get('/api', handler)
app.get('/api/*', handler)
// 方案2:用可选参数(可读性差)
app.get('/api/:path{.*}?', handler)
踩坑场景:想给 /api 前缀的所有路由加中间件,结果 /api 本身没生效。
坑 2:中间件里 c.set() 的类型要提前声明
// 想在中间件里设置用户信息
app.use('/*', async (c, next) => {
const user = await getUser(c.req.header('Authorization'))
c.set('user', user) // ❌ 类型报错:'user' 不存在
await next()
})
// 必须在 Hono 泛型里提前声明
type Env = {
Variables: {
user: { id: string; name: string; role: string }
}
}
const app = new Hono<Env>()
// 问题来了:跨文件怎么办?
// -------- middleware/auth.ts --------
export const authMiddleware = async (c, next) => {
// c 的类型是什么?没有 Variables 类型
c.set('user', user) // 还是报错
await next()
}
// -------- app.ts --------
type Env = { Variables: { user: User } }
const app = new Hono<Env>()
app.use('/*', authMiddleware) // 类型对不上
解决方案:要在每个文件都声明相同的 Env 类型,或者用全局类型。但这很容易出错。
坑 3:路径参数永远是 string
app.get('/users/:id', (c) => {
const id = c.req.param('id') // 类型:string
// 想要 number?自己转
const numId = parseInt(id, 10)
if (isNaN(numId)) {
return c.json({ error: 'Invalid ID' }, 400)
}
// 每个需要数字 ID 的路由都要写这段
})
对比:很多框架(包括 Elysia)支持在 Schema 里声明参数类型,自动转换和验证。
坑 4:验证要额外安装,写法繁琐
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
// 每个路由都要包一层 validator
app.post('/users',
zValidator('json', z.object({
name: z.string(),
email: z.string().email(),
})),
zValidator('query', z.object({
page: z.string().optional(),
})),
(c) => {
// 取值要用 .valid()
const body = c.req.valid('json')
const query = c.req.valid('query')
return c.json({ body, query })
}
)
// 问题:
// 1. 要额外装 @hono/zod-validator
// 2. 每个路由写两遍参数位置('json', 'query')
// 3. 取值要记得用 .valid() 而不是 .json()
坑 5:RPC 客户端类型推断有坑
// 服务端
const app = new Hono()
.get('/users', (c) => c.json([{ id: 1, name: 'John' }]))
.post('/users', async (c) => {
const body = await c.req.json()
return c.json({ id: 2, ...body })
})
export type AppType = typeof app
// 客户端
import { hc } from 'hono/client'
import type { AppType } from './server'
const client = hc<AppType>('http://localhost:3000')
// 调用
const res = await client.users.$get()
const data = await res.json() // 还要手动 .json()
// 问题:
// 1. 返回的是 Response,还要手动解析
// 2. POST 的 body 类型推断不准(因为用了 c.req.json())
// 3. $get $post 这种命名不直观
坑 6:错误处理不统一
import { HTTPException } from 'hono/http-exception'
app.get('/user/:id', async (c) => {
const user = await db.findUser(c.req.param('id'))
if (!user) {
// 方式1:抛异常
throw new HTTPException(404, { message: 'User not found' })
}
// 方式2:直接返回
// return c.json({ error: 'User not found' }, 404)
return c.json(user)
})
// 两种方式响应格式不一样
// HTTPException: { message: 'User not found' }
// 直接返回: { error: 'User not found' }
// 团队用哪种?没有标准
Part 2: Elysia 的坑
坑 1:链式调用太长,代码难以维护
const app = new Elysia()
.state('version', '1.0.0')
.state('requestCount', 0)
.decorate('logger', new Logger())
.decorate('db', database)
.derive(({ headers }) => ({
auth: headers.authorization,
requestId: headers['x-request-id'] || crypto.randomUUID()
}))
.onRequest(({ store }) => {
store.requestCount++
})
.onBeforeHandle(({ auth, logger }) => {
logger.debug('Processing request')
if (!auth) {
return 'Unauthorized'
}
})
.onAfterHandle(({ response, logger }) => {
logger.debug('Request completed')
return response
})
.onError(({ error, code, logger }) => {
logger.error(error)
return { error: code, message: error.message }
})
.get('/health', () => 'OK')
.get('/users', ({ db }) => db.getUsers())
.post('/users', ({ body, db }) => db.createUser(body), {
body: t.Object({
name: t.String(),
email: t.String()
})
})
.get('/users/:id', ({ params, db }) => db.getUser(params.id), {
params: t.Object({
id: t.Numeric()
})
})
.listen(3000)
// 问题:
// 1. 50+ 行链式调用,找个路由要翻半天
// 2. 哪些是配置,哪些是路由,混在一起
// 3. Code Review 噩梦
// 4. Git diff 看不清改了什么
坑 2:概念太多,学习曲线陡峭
// state - 全局状态(可变)
.state('count', 0)
// decorate - 注入工具(不可变)
.decorate('db', database)
// derive - 每次请求派生数据
.derive(({ headers }) => ({ user: parseToken(headers.auth) }))
// resolve - 类似 derive,但在验证后执行
.resolve(({ body }) => ({ parsedBody: transform(body) }))
// 问题:什么时候用 state vs decorate?
// derive 和 resolve 有什么区别?
// store 里有什么,decorator 里有什么?
// 新人要花很长时间理解这些概念
对比:Express/Hono 就一个 c.set(),简单直接。
坑 3:guard 嵌套地狱
const app = new Elysia()
.guard({
beforeHandle: [rateLimiter]
}, app => app
.guard({
beforeHandle: [authMiddleware]
}, app => app
.guard({
body: t.Object({ ... })
}, app => app
.post('/resource', handler)
.guard({
params: t.Object({ id: t.String() })
}, app => app
.get('/resource/:id', handler)
.put('/resource/:id', handler)
.delete('/resource/:id', handler)
)
)
)
)
// 缩进层级恐怖
// 想知道 DELETE /resource/:id 有哪些中间件?
// 要从外往里数:rateLimiter -> authMiddleware -> body验证 -> params验证
坑 4:插件类型必须在链上 use
// ❌ 这样写,类型可能丢失
const jwtPlugin = jwt({ secret: 'xxx' })
const corsPlugin = cors()
// 在另一个文件
app.use(jwtPlugin).use(corsPlugin)
// ✅ 必须这样,类型才完整
app
.use(jwt({ secret: 'xxx' }))
.use(cors())
.get('/protected', ({ jwt }) => {
// jwt 类型正确
})
// 问题:不能把插件配置抽到单独文件复用
坑 5:拆分文件后类型推断断裂
// -------- routes/users.ts --------
import { Elysia, t } from 'elysia'
// 想用 app 上的 state 和 decorate?
export const userRoutes = new Elysia()
.get('/users', ({ db }) => {
// ❌ 报错:db 不存在
// 因为 db 是在主 app 上 decorate 的
return db.getUsers()
})
// -------- app.ts --------
const app = new Elysia()
.decorate('db', database)
.use(userRoutes) // userRoutes 拿不到 db
// 解决方案:每个子路由都要重新声明依赖
// 或者用 .derive() 传递,但很繁琐
坑 6:只能跑在 Bun
// Elysia 深度绑定 Bun 运行时
// 使用了 Bun 特有的 API
// 想部署到这些平台?不行:
// ❌ Node.js
// ❌ Cloudflare Workers
// ❌ Vercel Edge Functions
// ❌ AWS Lambda
// ❌ Deno
// 你的技术栈必须全是 Bun
// 一旦选择 Elysia,就被运行时锁定了
坑 7:错误处理要理解生命周期
app
.onRequest() // 1. 请求进来
.onParse() // 2. 解析 body
.onTransform() // 3. 转换数据
.onBeforeHandle() // 4. 处理前
.onAfterHandle() // 5. 处理后
.onError() // 6. 错误处理
.onResponse() // 7. 响应发送后
// 问题:
// 1. 在 onBeforeHandle 抛错,会走 onError 吗?
// 2. onAfterHandle 能捕获 handler 的错误吗?
// 3. 返回值是什么意思?return 了会怎样?
// 要看文档才能搞清楚每个钩子的行为
Part 3: 这些问题有解吗?
Hono 的应对
- 路由通配符:接受现实,写两遍或用正则
- 类型声明:定义全局
Env类型,所有文件引用 - 验证:用
@hono/zod-validator,接受繁琐 - RPC:用
hono/client,接受$get语法
Elysia 的应对
- 链式调用:拆分成多个文件,用
.use()组合 - 概念多:花时间学习,记住各自用途
- guard 嵌套:接受,或者不用 guard
- 运行时锁定:确保团队和基础设施都支持 Bun
或者,考虑其他方案
如果这些坑让你很难受,可以看看其他思路的框架:
声明式路由(路由是数组,不是链式调用):
const routes = [
{ method: 'GET', path: '/users', handler: getUsers },
{ method: 'POST', path: '/users', handler: createUser, middleware: [auth] },
]
优点:
- 路由一目了然,不用翻代码
- 中间件显式声明,不用猜作用域
- 类型跟 Handler 走,跨文件不丢失
- 不绑定运行时,到处能跑
总结
| 框架 | 优点 | 坑 |
|---|---|---|
| Hono | 轻量、跨平台、生态好 | 通配符规则、类型声明繁琐、验证要额外装 |
| Elysia | 性能极致、类型强大 | 链式地狱、概念多、Bun 锁定、拆分困难 |
选型建议:
- 小项目、快速原型 → Hono(简单够用)
- 追求极致性能、团队熟悉 Bun → Elysia
- 大型项目、需要清晰结构 → 考虑声明式框架
- 需要跨运行时部署 → Hono 或声明式框架
你在用 Hono 或 Elysia 吗?遇到过什么坑?欢迎评论区交流!