自定义中间件开发
要点
- 中间件本质上是一个
async (c, next) => void函数 createMiddleware()提供类型推导,减少手动标注类型的成本c.set()/c.get()是中间件向下游传递数据的通道- 中间件可以接收配置参数,通过闭包返回真正的处理函数
- 类型安全的
c.set()/c.get()需要扩展ContextVariableMap - 中间件可以抽到独立文件,按职责组织到
src/middleware/目录
1. 最简单的中间件
中间件就是一个 async 函数,接收 context 和 next 两个参数:
// src/index.ts
import { Hono } from 'hono'
const app = new Hono()
// 内联中间件:请求计时
app.use('*', async (c, next) => {
const start = Date.now()
await next()
const duration = Date.now() - start
c.header('X-Response-Time', `${duration}ms`)
})
app.get('/', (c) => c.text('Hello'))
export default appc.header() 在响应里添加自定义头。await next() 之前的代码在请求到达路由前执行,之后的代码在路由返回后执行。
这种内联写法适合一次性的逻辑。如果同一个中间件需要在多处使用,应该把它抽成独立函数。
2. 抽成独立函数
把中间件函数赋给一个变量,就可以在任何地方复用:
// src/middleware/timing.ts
import type { Context, Next } from 'hono'
export const timing = async (c: Context, next: Next) => {
const start = Date.now()
await next()
const duration = Date.now() - start
c.header('X-Response-Time', `${duration}ms`)
}// src/index.ts
import { Hono } from 'hono'
import { timing } from './middleware/timing'
const app = new Hono()
app.use('*', timing)
app.get('/', (c) => c.text('Hello'))
export default app类型标注 Context 和 Next 来自 hono。这种写法已经可以工作,但 c.get() / c.set() 的键值类型都是 any,缺少类型保护。
3. createMiddleware 工厂函数
Hono 提供了 createMiddleware() 工厂函数,用于创建类型安全的中间件:
// src/middleware/timing.ts
import { createMiddleware } from 'hono/factory'
export const timing = createMiddleware(async (c, next) => {
const start = Date.now()
await next()
const duration = Date.now() - start
c.header('X-Response-Time', `${duration}ms`)
})createMiddleware 自动推导 c 和 next 的类型,不需要手动标注。
如果中间件需要接收配置参数,用外层函数返回 createMiddleware 的结果:
// src/middleware/timing.ts
import { createMiddleware } from 'hono/factory'
type TimingOptions = {
headerName?: string
}
export const timing = (options: TimingOptions = {}) => {
const headerName = options.headerName ?? 'X-Response-Time'
return createMiddleware(async (c, next) => {
const start = Date.now()
await next()
const duration = Date.now() - start
c.header(headerName, `${duration}ms`)
})
}// src/index.ts
import { Hono } from 'hono'
import { timing } from './middleware/timing'
const app = new Hono()
// 使用默认配置
app.use('*', timing())
// 使用自定义配置
app.use('/api/*', timing({ headerName: 'X-Api-Response-Time' }))
export default app这种「接收配置 → 返回中间件」的模式在 Hono 内置中间件里广泛使用,例如 cors()、logger()、secureHeaders() 都是这个形状。
4. 中间件之间的数据传递
中间件处理完的结果需要传给下游路由时,用 c.set() 写入、c.get() 读取:
// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
type User = {
id: string
name: string
role: string
}
export const auth = createMiddleware(async (c, next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '')
if (!token) {
return c.json({ error: 'Unauthorized' }, 401)
}
// 假设 token 解析后得到用户信息
const user: User = { id: '1', name: 'Alice', role: 'admin' }
// 把用户信息存入 context
c.set('user', user)
await next()
})// src/index.ts
import { Hono } from 'hono'
import { auth } from './middleware/auth'
const app = new Hono()
app.get('/api/profile', auth, (c) => {
// 从 context 读取用户信息
const user = c.get('user') as { id: string; name: string; role: string }
return c.json({ user })
})
export default app直接写 c.get('user') 返回的类型是 any,需要手动 as 转换。这种写法能工作,但类型保护很弱——拼错键名、字段名都不会在编译期报错。
5. 类型安全的 c.set / c.get
要让 c.set() / c.get() 具备类型推导,需要扩展 Hono 的 ContextVariableMap:
// src/types/hono.ts
import 'hono'
type User = {
id: string
name: string
role: string
}
declare module 'hono' {
interface ContextVariableMap {
user: User
requestId: string
}
}扩展之后,c.get('user') 的返回类型自动推导为 User,c.get('requestId') 推导为 string:
// src/index.ts
import { Hono } from 'hono'
import { auth } from './middleware/auth'
import './types/hono' // 确保类型扩展被加载
const app = new Hono()
app.get('/api/profile', auth, (c) => {
// 类型自动推导为 User
const user = c.get('user')
// user.id、user.name、user.role 都有类型提示
return c.json({ user })
})
export default app拼错键名会在编译期报错:
const u = c.get('usr') // ❌ 类型错误:'usr' 不存在于 ContextVariableMap这种类型扩展方式是 Hono 的内置设计,不是 as any 的 workaround。ContextVariableMap 是一个开放的接口,任何模块都可以通过 declare module 'hono' 来扩展它。
6. 带类型约束的中间件
有些中间件需要在特定的 context 类型下才能使用。例如 auth 中间件写入的 user 字段,只有经过鉴权的路由才能读取。
可以用泛型约束来编码这种规则:
// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
import type { Context } from 'hono'
type User = {
id: string
name: string
role: string
}
type Env = {
Variables: {
user: User
}
}
export const auth = createMiddleware<Env>(async (c, next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '')
if (!token) {
return c.json({ error: 'Unauthorized' }, 401)
}
const user: User = { id: '1', name: 'Alice', role: 'admin' }
c.set('user', user)
await next()
})// src/index.ts
import { Hono } from 'hono'
import { auth } from './middleware/auth'
// 路由声明 Env 类型后,c.get('user') 自动推导为 User
const app = new Hono<{ Variables: { user: { id: string; name: string; role: string } } }>()
app.get('/api/profile', auth, (c) => {
const user = c.get('user') // 类型:{ id: string; name: string; role: string }
return c.json({ user })
})
export default app这种方式比全局扩展 ContextVariableMap 更精确,适合不同模块有不同 context 变量的场景。缺点是类型声明比较长,代码可读性会降低。
两种方式的取舍:
ContextVariableMap扩展:适合全局共享的变量(user、requestId、locale 等),声明一次,全局可用- 泛型
Env:适合模块特有的变量,类型隔离更严格,但需要在每个 app 实例上声明
7. 中间件的错误处理
中间件里的错误如果没被捕获,会冒泡到 Hono 的全局错误处理。可以在中间件内部用 try-catch 处理:
// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
export const auth = createMiddleware(async (c, next) => {
try {
const token = c.req.header('Authorization')?.replace('Bearer ', '')
if (!token) {
return c.json({ error: 'Unauthorized' }, 401)
}
// 假设 token 解析可能抛异常
const user = await verifyToken(token)
c.set('user', user)
await next()
} catch (err) {
// 记录日志
console.error('Auth failed:', err)
// 返回统一格式的错误响应
return c.json({ error: 'Invalid token' }, 401)
}
})也可以不在中间件里处理错误,让错误冒泡到 app.onError():
// src/index.ts
import { Hono } from 'hono'
const app = new Hono()
app.onError((err, c) => {
console.error('Unhandled error:', err)
return c.json({ error: 'Internal Server Error' }, 500)
})两种方式的选择:
- 中间件内部 try-catch:适合预期内的错误(token 无效、参数缺失),可以返回精确的状态码和错误信息
- 冒泡到 onError:适合非预期的异常(网络超时、数据库断开),统一返回 500
中间件里「预期会失败」的场景建议就地处理,避免错误冒泡到全局后才丢失上下文。
8. 中间件的测试
自定义中间件可以独立测试。创建一个 mini-app,只挂载要测试的中间件和一个捕获路由:
// tests/middleware/timing.test.ts
import { describe, it, expect } from 'vitest'
import { Hono } from 'hono'
import { timing } from '../../src/middleware/timing'
describe('timing middleware', () => {
it('在响应头写入 X-Response-Time', async () => {
const app = new Hono()
app.use('*', timing())
app.get('/', (c) => c.text('ok'))
const res = await app.request('/')
expect(res.headers.get('X-Response-Time')).toMatch(/\d+ms/)
})
it('支持自定义 header name', async () => {
const app = new Hono()
app.use('*', timing({ headerName: 'X-Custom-Time' }))
app.get('/', (c) => c.text('ok'))
const res = await app.request('/')
expect(res.headers.get('X-Custom-Time')).toMatch(/\d+ms/)
expect(res.headers.get('X-Response-Time')).toBeNull()
})
})测试 c.set() / c.get() 的中间件时,在捕获路由里读取 context 变量并返回:
// tests/middleware/auth.test.ts
import { describe, it, expect } from 'vitest'
import { Hono } from 'hono'
import { auth } from '../../src/middleware/auth'
describe('auth middleware', () => {
it('token 有效时放行并设置 user', async () => {
const app = new Hono()
app.use('*', auth)
app.get('/', (c) => c.json({ user: c.get('user') }))
const res = await app.request('/', {
headers: { Authorization: 'Bearer valid-token' },
})
expect(res.status).toBe(200)
expect(await res.json()).toHaveProperty('user')
})
it('token 缺失时返回 401', async () => {
const app = new Hono()
app.use('*', auth)
app.get('/', (c) => c.text('ok'))
const res = await app.request('/')
expect(res.status).toBe(401)
})
})总结
自定义中间件的开发路径从内联函数开始,逐步演进到 createMiddleware() 工厂、类型扩展和独立测试。
这一节涉及到的几个层次:
- 基本形式:
async (c, next) => void函数,await next()前后分别处理请求和响应 - 工厂模式:外层函数接收配置,内层返回
createMiddleware()的结果,例如timing({ headerName: '...' }) - 数据传递:
c.set()/c.get()在中间件和路由之间传递数据 - 类型安全:两种方式——扩展
ContextVariableMap适合全局变量,泛型Env适合模块变量 - 错误处理:预期内错误就地 try-catch,非预期异常冒泡到
app.onError() - 测试:创建 mini-app 挂载中间件,用
app.request()验证行为
中间件开发到这一节已经覆盖了从形式到类型的完整链路。接下来几节会逐个拆开 Hono 内置的中间件,看它们各自解决什么问题、配置项怎样影响行为。
下一篇看日志中间件——logger() 的输出格式、自定义 logger 的接入方式,以及结构化日志在 Hono 里的实现路径。