用了半年 Hono 和 Elysia,我总结了这些坑

551 阅读3分钟

用了半年 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 的应对

  1. 路由通配符:接受现实,写两遍或用正则
  2. 类型声明:定义全局 Env 类型,所有文件引用
  3. 验证:用 @hono/zod-validator,接受繁琐
  4. RPC:用 hono/client,接受 $get 语法

Elysia 的应对

  1. 链式调用:拆分成多个文件,用 .use() 组合
  2. 概念多:花时间学习,记住各自用途
  3. guard 嵌套:接受,或者不用 guard
  4. 运行时锁定:确保团队和基础设施都支持 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 吗?遇到过什么坑?欢迎评论区交流!