模块化路由拆分

要点

  • 单个路由文件超过 10 个路由之后,定位和修改成本会快速上升,此时适合按资源拆分到独立文件
  • Hono 的 app.route() 支持把一个 Hono 实例挂载到另一个实例上,每个文件导出独立的子 app
  • 项目规模不同,目录组织方式不同——小型项目用扁平 routes 目录即可,中大型项目可以按 feature 分层
  • routes/index.ts 作为聚合入口,把多个子模块合并后统一导出,主入口只需要一次 app.route() 调用
  • 模块之间的共享逻辑放在 shared/lib/ 目录,避免路由模块之间直接互相引用
  • Barrel exports 在模块少时方便,模块多了之后会引入额外的 import 链和类型推导成本

1. 为什么需要拆分

01 给出了一个典型的主入口结构:所有路由写在同一个文件里,或者简单拆成 users.tsposts.ts 几个模块。当路由只有 5-10 个时,单文件没有明显问题。一旦路由数量超过 10 个,以下情况会陆续出现:

  1. 文件行数超过 300 行,跳转变得频繁
  2. 不同资源的中间件各不相同,条件编译式阅读增加
  3. 多人协作时,同一个文件的合并冲突概率上升
  4. 单个路由的改动需要加载整个文件,类型推导变慢

这些信号指向同一个判断:路由已经不适合放在同一个文件里了。拆分的基本原则是按资源或业务域,把路由分散到独立文件,每个文件导出一个 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 --circular

7. 动态路由注册

有些场景下,路由需要在运行时根据配置动态注册。比如从数据库读取租户列表,为每个租户注册一组路由。

// 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 或类型。按以下优先级处理:

  1. 依赖只属于当前模块:跟着模块一起移走
  2. 依赖被多个模块使用:放到 shared/middleware/ 目录
  3. 依赖的提取涉及重构:暂时保留在原地,后续再处理

不要在一次迁移中同时做路由拆分和业务重构。先拆文件,后改逻辑。

8.4 验证

每迁移一个模块后运行 pnpm typecheckpnpm 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)
  })
})

集成测试关注的是路径拼接和全局中间件是否生效,不需要覆盖每个路由的业务逻辑。

延伸阅读

总结

模块化路由拆分的核心操作只有一件事:用 app.route() 把按资源组织的 Hono 子 app 挂载到主实例上。围绕这件事,需要做的决策包括:

  1. 拆分粒度:按资源拆分适用于大多数项目;业务域复杂时可以进一步按子功能拆分
  2. 目录层级:小型项目用扁平结构,中大型项目按业务域分子目录,超大型项目把路由处理函数和路由注册分开
  3. 聚合方式:用 routes/index.ts 聚合可以减少主入口的 import 数量,但要注意模块加载和类型推导的成本
  4. 共享策略:类型和工具函数放 shared/,中间件放 middleware/,路由模块之间不直接引用
  5. 迁移节奏:逐模块提取、逐模块验证,不要一次性重构

拆分本身不产生价值,拆分让每个模块的职责更明确、变更影响更可控。当你发现新增一个路由时知道该改哪个文件、改完之后不用担心影响其他资源——拆分就到位了。

下一篇讲版本化 API 设计——当你的 API 需要同时维护 v1 和 v2 时,如何用 Hono 的 basePath() 和路由组织来管理多版本共存。