notFound与onError机制
要点
app.notFound()和app.onError()是 Hono 全局注册的两个回调函数,分别处理「未匹配路由」和「处理过程中抛出的异常」- 不注册时 Hono 有内置默认行为,返回纯文本响应
- 两者都只接受一个回调,后注册的会覆盖先注册的
app.notFound在路由匹配阶段结束时触发;app.onError在中间件或处理函数抛异常时触发HTTPException默认不经过app.onError,除非显式让它冒泡- 自定义 404 和全局错误处理的位置在顶层 app,不在子路由
1. 默认行为:什么都不注册时会发生什么
在讨论注册机制之前,先明确 Hono 在没有任何自定义处理时的默认行为。
当一个请求进来,经过所有中间件和路由都没有匹配到时,Hono 内部会走到 compose 流程的末尾。此时 context.finalized 仍为 false(没有任何处理函数调用过 c.json() 或 c.text() 返回响应),Hono 会执行默认的 404 处理:
// 默认行为等价于
app.notFound((c) => {
return c.text('404 Not Found', 404)
})响应是纯文本 404 Not Found,状态码 404,Content-Type 为 text/plain。
如果路由匹配成功,但处理函数内部抛出了未被捕获的异常,Hono 会执行默认的错误处理:
// 默认行为等价于
app.onError((err, c) => {
console.error(err)
return c.text('Internal Server Error', 500)
})响应是纯文本 Internal Server Error,状态码 500。同时会在控制台打印原始错误堆栈。
这两个默认处理函数定义在 Hono 的 Hono 类构造函数中。理解这一点很重要——它们不是「没有处理」,而是「有默认处理,只是过于简陋」。
2. app.notFound() 的注册机制
注册方式
app.notFound() 接受一个回调函数,签名为 (c: Context) => Response | Promise<Response>:
// app.ts
import { Hono } from 'hono'
const app = new Hono()
app.notFound((c) => {
return c.json(
{
success: false,
error: 'Not Found',
path: c.req.path,
},
404
)
})这个回调被存储在 Hono 实例的 notFoundHandler 属性上。源码层面大致是这样的:
// hono.ts(简化)
class Hono {
notFoundHandler: NotFoundHandler = (c) => {
return c.text('404 Not Found', 404)
}
notFound(handler: NotFoundHandler) {
this.notFoundHandler = handler
return this
}
}覆盖行为
每次调用 app.notFound() 都会直接替换掉之前的处理函数。这意味着:
app.notFound(handlerA)
app.notFound(handlerB) // handlerA 被覆盖,只有 handlerB 生效不会有回调链,不会合并。如果你用 app.route() 挂载了子路由,子路由里也注册了 notFound,最终只有顶层 app 的 notFoundHandler 会生效。这是因为 notFoundHandler 只从 app 实例上读取,不会沿路由树查找。
触发时机
notFoundHandler 在 app.fetch() 内部被调用。简化后的执行流程:
// 简化流程
async function fetch(request: Request) {
const context = new Context(request)
// 1. 执行中间件链和路由匹配
const composed = compose(middlewares, routes)
const response = await composed(context)
// 2. 如果没有任何处理函数产生响应(context.finalized === false)
if (!context.finalized) {
// 触发 notFoundHandler
return notFoundHandler(context)
}
return response
}context.finalized 是关键判断条件。只要有任何一个中间件或路由处理函数调用了 c.json()、c.text()、c.html() 等方法设置了响应,finalized 就会变成 true,notFoundHandler 就不会触发。
换句话说,notFoundHandler 只在「整个中间件链执行完毕,没有任何人产出响应」的情况下才会被调用。
3. app.onError() 的注册机制
注册方式
app.onError() 接受一个回调函数,签名为 (err: Error, c: Context) => Response | Promise<Response>:
// app.ts
import { Hono } from 'hono'
const app = new Hono()
app.onError((err, c) => {
console.error(`[Error] ${err.message}`)
return c.json(
{
success: false,
error: 'Internal Server Error',
},
500
)
})和 notFoundHandler 类似,errorHandler 也存储在 Hono 实例上,后注册的覆盖先注册的。
触发时机
onError 的触发点在 compose 函数内部。当中间件链中的任何一个处理函数抛出异常时,异常会沿着 await 链向上传播,最终被 compose 的 try-catch 捕获:
// compose 内部(简化)
async function composed(context) {
try {
// 依次执行中间件和路由处理函数
await next()
} catch (err) {
// 异常被捕获,调用 errorHandler
context.finalized = false
return errorHandler(err, context)
}
}关键点:onError 捕获的是同步抛出和 async 拒绝两种情况。无论你在处理函数里 throw new Error() 还是 return Promise.reject(),最终都会被 onError 接到。
与中间件 try-catch 的关系
如果你在某个中间件里写了 try-catch,并且捕获了异常但没有重新抛出,那么 onError 不会被触发:
// 中间件吞掉了异常,onError 不会触发
app.use('*', async (c, next) => {
try {
await next()
} catch (err) {
// 这里捕获了异常,但没 throw
console.error(err)
return c.json({ error: 'handled locally' }, 500)
}
})只有当异常一路冒泡到 compose 层的 catch 时,onError 才会介入。这和 09 篇讲的错误冒泡机制是一致的。
4. 触发时机在请求处理流程中的位置
把整个请求处理流程画出来,标注 notFound 和 onError 的位置:
请求进入
│
▼
app.fetch(request)
│
▼
创建 Context
│
▼
执行 compose(中间件链 + 路由匹配)
│
├── 正常执行完毕,context.finalized = true ──▶ 返回响应
│
├── 正常执行完毕,context.finalized = false ──▶ 触发 notFoundHandler
│
└── 执行过程中抛出异常 ──▶ 触发 errorHandler(onError)
│
▼
返回错误响应
两个处理器在流程中是互斥的分支:
- 没有异常,但没有匹配到路由 →
notFoundHandler - 有异常 →
errorHandler - 正常响应 → 两个都不触发
这里有一个容易混淆的点:notFoundHandler 不是异常,它是「正常执行完毕但没有产出响应」的状态。Hono 不会为此抛异常,而是用一个专门的分支来处理。
5. HTTPException 与 onError 的协作
默认行为:HTTPException 不经过 onError
在 09 篇中提到,HTTPException 可以通过 app.onError 统一处理。但这里要补充一个 Hono 内部的细节:当处理函数抛出 HTTPException 时,Hono 的 compose 层有专门的处理逻辑。
// compose 内部(简化)
try {
await next()
} catch (err) {
if (err instanceof HTTPException) {
// HTTPException 直接生成响应,不经过 errorHandler
return err.getResponse()
}
// 其他异常才走 errorHandler
return errorHandler(err, context)
}也就是说,HTTPException 默认会直接调用自身的 getResponse() 方法生成响应,跳过 app.onError。这是一个有意的设计——HTTPException 已经携带了完整的状态码和响应信息,不需要再经过错误处理器加工。
让 HTTPException 经过 onError
如果你的项目需要对所有错误(包括 HTTPException)做统一格式化,比如加上请求 ID、时间戳等字段,可以修改 onError 的判断逻辑。但问题是 compose 层已经把 HTTPException 拦截了。
一种实用的做法是:不直接抛 HTTPException,而是抛自定义异常,让所有异常都走 onError:
// errors.ts
// 不继承 HTTPException,继承普通 Error
export class AppError extends Error {
constructor(
public status: number,
message: string,
public code?: string
) {
super(message)
this.name = 'AppError'
}
}// app.ts
app.onError((err, c) => {
if (err instanceof AppError) {
return c.json(
{
success: false,
error: err.message,
code: err.code,
timestamp: new Date().toISOString(),
},
err.status
)
}
console.error(err)
return c.json(
{
success: false,
error: 'Internal Server Error',
timestamp: new Date().toISOString(),
},
500
)
})// route.ts
import { AppError } from './errors'
app.get('/users/:id', (c) => {
const user = findUser(c.req.param('id'))
if (!user) {
throw new AppError(404, 'User not found', 'USER_NOT_FOUND')
}
return c.json({ success: true, data: user })
})这样所有业务异常和系统异常都统一经过 onError,响应格式完全一致。HTTPException 在这个方案里被完全替代了。
另一种方案:在中间件层拦截 HTTPException
如果你既想用 HTTPException 的便捷,又想统一走 onError,可以在最外层加一个中间件,在中间件层捕获 HTTPException 并转为普通异常重新抛出:
// 最外层中间件
app.use('*', async (c, next) => {
try {
await next()
} catch (err) {
if (err instanceof HTTPException) {
// 转为 AppError 重新抛出,让 onError 处理
throw new AppError(err.status, err.message)
}
throw err
}
})这种方式不需要改路由代码,只需在入口处加一层转换。但要注意中间件的注册顺序——这个转换中间件必须在所有路由之前注册。
6. 自定义 404 页面
JSON 格式
API 服务最常用的 404 格式,返回请求路径方便前端排查:
app.notFound((c) => {
return c.json(
{
success: false,
error: 'Not Found',
path: c.req.path,
method: c.req.method,
},
404
)
})前端收到的响应:
{
"success": false,
"error": "Not Found",
"path": "/api/v1/nonexistent",
"method": "GET"
}HTML 格式
如果 Hono 同时服务前端页面(SSR 或混合渲染),可以返回 HTML 格式的 404 页面:
import { html } from 'hono/html'
app.notFound((c) => {
return c.html(
html`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>404 - 页面不存在</title>
</head>
<body>
<h1>404</h1>
<p>找不到页面:${c.req.path}</p>
<a href="/">返回首页</a>
</body>
</html>`,
404
)
})根据 Accept 头自动选择格式
更完善的方案是根据客户端的 Accept 请求头自动选择返回 JSON 还是 HTML:
app.notFound((c) => {
const accept = c.req.header('accept') || ''
if (accept.includes('application/json')) {
return c.json(
{
success: false,
error: 'Not Found',
path: c.req.path,
},
404
)
}
return c.html(
html`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>404</title>
</head>
<body>
<h1>页面不存在</h1>
<p>请求的路径 ${c.req.path} 没有找到。</p>
<a href="/">返回首页</a>
</body>
</html>`,
404
)
})这种内容协商的思路在服务端渲染项目中很常见。API 客户端发 Accept: application/json 拿到 JSON,浏览器直接访问拿到 HTML 页面。
7. 全局错误处理的最佳实践
统一错误响应格式
无论什么类型的错误,前端拿到的 JSON 结构应该一致:
interface ErrorResponse {
success: false
error: string
code?: string
details?: Record<string, string[]>
path?: string
}分级处理策略
在 onError 里按错误类型分级处理:
// app.ts
app.onError((err, c) => {
const path = c.req.path
// 1. 业务异常:已知的、可预期的错误
if (err instanceof ValidationError) {
return c.json(
{
success: false,
error: err.message,
code: 'VALIDATION_ERROR',
details: err.details,
path,
},
err.status
)
}
if (err instanceof AuthError) {
return c.json(
{
success: false,
error: err.message,
code: 'AUTH_ERROR',
path,
},
err.status
)
}
if (err instanceof AppError) {
return c.json(
{
success: false,
error: err.message,
code: err.code,
path,
},
err.status
)
}
// 2. 系统异常:未预期的错误,需要记录日志
console.error(`[UnhandledError] ${path}`, {
message: err.message,
stack: err.stack,
})
// 3. 生产环境不暴露内部错误细节
return c.json(
{
success: false,
error: 'Internal Server Error',
path,
},
500
)
})这里的分级原则:
- 业务异常(继承自自定义基类的):返回具体错误信息给前端,状态码由错误本身决定
- 系统异常(其他所有
Error):只返回通用提示,详细错误信息只写日志,不暴露给外部
日志记录
onError 是集中记录错误日志的最佳位置。生产环境中建议结构化输出:
app.onError((err, c) => {
const logPayload = {
timestamp: new Date().toISOString(),
method: c.req.method,
path: c.req.path,
requestId: c.get('requestId'),
errorMessage: err.message,
errorStack: err.stack,
}
// 结构化日志,方便日志系统采集和检索
console.error(JSON.stringify(logPayload))
return c.json(
{ success: false, error: 'Internal Server Error' },
500
)
})把请求路径、请求 ID、错误堆栈放在一起输出,排查问题时能快速定位到具体请求。
注册顺序
notFound 和 onError 的注册位置没有强制要求(它们不是中间件,不参与 compose 链),但建议放在路由定义之前,让代码意图更清晰:
// app.ts
const app = new Hono()
// 1. 先注册全局处理器
app.notFound((c) => { /* ... */ })
app.onError((err, c) => { /* ... */ })
// 2. 再注册中间件和路由
app.use('*', logger())
app.route('/api', apiRoutes)延伸阅读
- Hono 源码 - hono.ts:
notFoundHandler和errorHandler的注册与调用 - Hono 源码 - compose.ts:
compose函数中异常的捕获与分发逻辑 - Hono 源码 - http-exception.ts:
HTTPException的getResponse()方法
总结
app.notFound() 和 app.onError() 是 Hono 请求处理流程末端的两个兜底回调。notFound 在「没有异常但也没有产出响应」时触发;onError 在「执行过程中抛出异常」时触发。两者互斥,不会同时执行。
HTTPException 默认由 compose 层直接处理,不经过 onError。如果需要所有错误统一格式化,可以用自定义异常类替代 HTTPException,或者在最外层中间件做转换。
注册方式都是直接赋值覆盖,后注册的替换先注册的。自定义 404 可以根据 Accept 头做内容协商,返回 JSON 或 HTML。全局错误处理应按错误类型分级:业务异常返回具体信息,系统异常只返回通用提示并记录日志。
下一篇聊 Hono 的路由匹配引擎——正则树是如何实现高效路由分发的。