模块化路由拆分
要点
- 单个路由文件超过 10 个路由之后,定位和修改成本会快速上升,此时适合按资源拆分到独立文件
- Hono 的
app.route()支持把一个 Hono 实例挂载到另一个实例上,每个文件导出独立的子 app - 项目规模不同,目录组织方式不同——小型项目用扁平 routes 目录即可,中大型项目可以按 feature 分层
routes/index.ts作为聚合入口,把多个子模块合并后统一导出,主入口只需要一次app.route()调用- 模块之间的共享逻辑放在
shared/或lib/目录,避免路由模块之间直接互相引用 - Barrel exports 在模块少时方便,模块多了之后会引入额外的 import 链和类型推导成本
1. 为什么需要拆分
01 给出了一个典型的主入口结构:所有路由写在同一个文件里,或者简单拆成 users.ts、posts.ts 几个模块。当路由只有 5-10 个时,单文件没有明显问题。一旦路由数量超过 10 个,以下情况会陆续出现:
- 文件行数超过 300 行,跳转变得频繁
- 不同资源的中间件各不相同,条件编译式阅读增加
- 多人协作时,同一个文件的合并冲突概率上升
- 单个路由的改动需要加载整个文件,类型推导变慢
这些信号指向同一个判断:路由已经不适合放在同一个文件里了。拆分的基本原则是按资源或业务域,把路由分散到独立文件,每个文件导出一个 Hono 子 app,再由主入口统一挂载。
2. 按资源拆分:File-per-Resource
最直接的拆分方式是每个资源对应一个路由文件。
src/
├── index.ts
└── routes/
├── users.ts
├── posts.ts
└── auth.ts
每个文件导出一个独立的 Hono 实例:
// src/routes/users.ts
import { Hono } from 'hono'
const users = new Hono()
users.get('/', (c) => c.json({ users: [] }))
users.get('/:id', (c) => {
const id = c.req.param('id')
return c.json({ id })
})
users.post('/', (c) => c.json({ message: 'created' }, 201))
export default users主入口只做三件事:创建 app、挂全局中间件、挂载路由模块。
// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import users from './routes/users'
import posts from './routes/posts'
import auth from './routes/auth'
const app = new Hono().basePath('/api/v1')
app.use('*', cors())
app.route('/users', users)
app.route('/posts', posts)
app.route('/auth', auth)
export default app每个路由文件不需要知道自己最终挂在哪个前缀下。users 文件里的 GET / 挂载到 /users 之后变成了 GET /users/。这种解耦允许你在不修改子模块的前提下调整挂载位置。
3. 不同规模项目的目录结构
3.1 小型项目(10 个以下路由)
扁平的 routes 目录就够用,不需要额外的分层目录。
src/
├── index.ts
└── routes/
├── users.ts
├── posts.ts
└── auth.ts
3.2 中型项目(10-50 个路由)
按业务域把路由文件放到子目录中,每个子目录的 index.ts 负责聚合该域的子路由。
src/
├── index.ts
├── routes/
│ ├── index.ts # 聚合入口
│ ├── users/
│ │ ├── index.ts
│ │ └── profile.ts
│ ├── posts/
│ │ ├── index.ts
│ │ └── comments.ts
│ └── auth/
│ ├── index.ts
│ └── oauth.ts
├── middleware/
│ └── auth.ts
└── shared/
├── types.ts
└── utils.ts
3.3 大型项目(50+ 路由)
路由层只负责路径映射和参数提取,业务逻辑下沉到 service 层。每个路由子目录拆出 handlers.ts(处理函数)、validators.ts(校验 schema)和 index.ts(路由注册)。
src/
├── routes/
│ └── users/
│ ├── index.ts # 路由注册
│ ├── handlers.ts # 处理函数
│ └── validators.ts # 校验 schema
├── services/
│ └── user-service.ts # 业务逻辑
├── middleware/
│ └── auth.ts
└── shared/
└── types.ts
// src/routes/users/handlers.ts
import type { Context } from 'hono'
import { UserService } from '../../services/user-service'
const userService = new UserService()
export const listUsers = async (c: Context) => {
const users = await userService.findAll()
return c.json({ users })
}// src/routes/users/index.ts
import { Hono } from 'hono'
import { authMiddleware } from '../../middleware/auth'
import { listUsers } from './handlers'
const users = new Hono()
users.use('*', authMiddleware)
users.get('/', listUsers)
export default users路由文件只做路径绑定和中间件挂载,处理函数调用 service 层完成业务逻辑。这样路由层保持轻量,业务逻辑可以被不同路由复用。
4. Index 文件聚合模式
当路由模块数量增加,用一个 routes/index.ts 聚合所有子模块可以简化主入口:
// src/routes/index.ts
import { Hono } from 'hono'
import users from './users'
import posts from './posts'
import auth from './auth'
const routes = new Hono()
routes.route('/users', users)
routes.route('/posts', posts)
routes.route('/auth', auth)
export default routes主入口只需要一行挂载:
// src/index.ts
import { Hono } from 'hono'
import routes from './routes'
const app = new Hono().basePath('/api/v1')
app.route('/', routes)
export default app这种模式也允许在聚合层统一添加模块级中间件。比如除了 auth 模块之外,其他模块统一需要认证:
// src/routes/index.ts
routes.route('/auth', auth) // auth 模块不加认证
routes.use('/users/*', authMiddleware) // 其他模块统一加认证
routes.use('/posts/*', authMiddleware)
routes.route('/users', users)
routes.route('/posts', posts)5. Barrel Exports 与 Direct Imports
5.1 Barrel Exports
把所有路由从同一个入口导出:
// src/routes/index.ts
export { default as users } from './users'
export { default as posts } from './posts'
export { default as auth } from './auth'好处是 import 来源集中。但 Barrel 文件会在 import 时加载所有子模块,即使你只用其中一个。TypeScript 追踪类型时也需要多经过一层再导出,模块数量很多时 IDE 的跳转和类型提示可能变慢。
5.2 Direct Imports
每个模块直接从文件路径导入:
// src/index.ts
import users from './routes/users'
import posts from './routes/posts'直接导入更透明,你能看到每个模块的确切路径,IDE 跳转只需要经过一层。缺点是主入口的 import 列表会随模块数量增长。
5.3 如何选择
- 路由模块在 5 个以内:两种方式差别不大
- 路由模块超过 5 个:倾向直接导入,或者把聚合入口只用于
app.route()挂载,不做 named export - Monorepo 或需要 tree-shaking 的场景:直接导入更可控
6. 模块间共享与循环依赖
路由拆分后,不同模块之间往往需要共享类型定义和工具函数。关键是找到一个独立的存放位置,避免路由模块之间直接互相引用。
// src/shared/types.ts
export interface User {
id: string
name: string
email: string
}
export interface Post {
id: string
title: string
authorId: string
}路由模块从 shared/ 导入,不从其他路由模块导入:
// src/routes/posts.ts
import type { Post, User } from '../shared/types' // ✅ 从 shared 导入
// import type { User } from './users' // ❌ 不要在路由模块之间直接引用如果两个模块确实需要共享逻辑,提取到 service 层,不要在路由层解决。路由模块之间一旦互相引用,在 ESM 中容易导致导入的值是 undefined。
可以用 dpdm 检测循环依赖:
npx dpdm src/routes/**/*.ts --circular7. 动态路由注册
有些场景下,路由需要在运行时根据配置动态注册。比如从数据库读取租户列表,为每个租户注册一组路由。
// src/index.ts
import { Hono } from 'hono'
import { createTenantRoutes } from './routes/tenant'
const app = new Hono().basePath('/api/v1')
const tenants = await loadTenantsFromDB()
for (const tenant of tenants) {
const tenantRoutes = createTenantRoutes(tenant.id)
app.route(`/tenants/${tenant.id}`, tenantRoutes)
}动态注册需要注意:租户数量很大时(数千个),每个租户一组路由会导致路由表膨胀,此时更适合用一个通配路由加内部路由分发。另外动态注册的路由路径在编译期不确定,类型推导会不完整。
8. 从单文件迁移到模块化
如果项目目前所有路由都在一个文件里,可以按以下步骤逐步迁移。
8.1 识别边界
通过路径前缀把路由按资源分组。
8.2 逐模块提取
一次迁移一个资源模块,不要一次性全部提取。每提取一个模块,运行测试确认没有破坏现有功能。
8.3 提取共享依赖
迁移过程中会发现一些路由处理函数引用了外部的 service、middleware 或类型。按以下优先级处理:
- 依赖只属于当前模块:跟着模块一起移走
- 依赖被多个模块使用:放到
shared/或middleware/目录 - 依赖的提取涉及重构:暂时保留在原地,后续再处理
不要在一次迁移中同时做路由拆分和业务重构。先拆文件,后改逻辑。
8.4 验证
每迁移一个模块后运行 pnpm typecheck 和 pnpm test,手动验证关键路径的请求是否正常。
9. 模块化路由的测试
拆分后的路由模块可以独立测试,不需要启动完整的应用。
9.1 单模块测试
Hono 的子 app 本身就是一个完整的 Hono 实例,可以直接用 app.request() 测试:
// tests/routes/users.test.ts
import { describe, it, expect } from 'vitest'
import users from '../../src/routes/users'
describe('users routes', () => {
it('GET / 返回用户列表', async () => {
const res = await users.request('/')
expect(res.status).toBe(200)
})
it('GET /:id 返回单个用户', async () => {
const res = await users.request('/42')
expect(res.status).toBe(200)
})
})注意 users.request('/') 的路径是相对于子 app 的根路径,不需要带 /users 前缀。
9.2 带中间件的测试
如果路由模块依赖认证中间件,测试时需要手动挂载:
// tests/routes/posts.test.ts
import { describe, it, expect } from 'vitest'
import { Hono } from 'hono'
import posts from '../../src/routes/posts'
import { authMiddleware } from '../../src/middleware/auth'
const createTestApp = () => {
const app = new Hono()
app.use('/posts/*', authMiddleware)
app.route('/posts', posts)
return app
}
describe('posts routes', () => {
it('未认证时返回 401', async () => {
const app = createTestApp()
const res = await app.request('/posts')
expect(res.status).toBe(401)
})
})9.3 集成测试
在主入口层面做集成测试,验证路由挂载是否正确、前缀是否拼接:
// tests/app.test.ts
import { describe, it, expect } from 'vitest'
import app from '../src/index'
describe('app integration', () => {
it('GET /api/v1/users/ 路由可达', async () => {
const res = await app.request('/api/v1/users/')
expect(res.status).toBe(200)
})
})集成测试关注的是路径拼接和全局中间件是否生效,不需要覆盖每个路由的业务逻辑。
延伸阅读
- 01-基础路由 —
app.route()和basePath()的基础用法 - 05-路由分组 — 路由分组策略的进一步讨论
- 03.05-HonoRequest封装机制 — 路由处理函数中如何读取请求数据
- Hono Routing 文档 — Hono 路由 API 的完整参考
- Hono Middleware 文档 — 内置中间件与自定义中间件
总结
模块化路由拆分的核心操作只有一件事:用 app.route() 把按资源组织的 Hono 子 app 挂载到主实例上。围绕这件事,需要做的决策包括:
- 拆分粒度:按资源拆分适用于大多数项目;业务域复杂时可以进一步按子功能拆分
- 目录层级:小型项目用扁平结构,中大型项目按业务域分子目录,超大型项目把路由处理函数和路由注册分开
- 聚合方式:用
routes/index.ts聚合可以减少主入口的 import 数量,但要注意模块加载和类型推导的成本 - 共享策略:类型和工具函数放
shared/,中间件放middleware/,路由模块之间不直接引用 - 迁移节奏:逐模块提取、逐模块验证,不要一次性重构
拆分本身不产生价值,拆分让每个模块的职责更明确、变更影响更可控。当你发现新增一个路由时知道该改哪个文件、改完之后不用担心影响其他资源——拆分就到位了。
下一篇讲版本化 API 设计——当你的 API 需要同时维护 v1 和 v2 时,如何用 Hono 的 basePath() 和路由组织来管理多版本共存。