项目目录结构设计

要点

  • 单文件路由超过 5 个接口后就会失控,拆分是必然的
  • Hono 的 app.route() 支持将路由拆分到独立文件,再在入口组装
  • routes/ 按领域组织,middleware/ 放中间件,helpers/ 放工具函数
  • types/env.d.ts 统一管理 Cloudflare Workers 的绑定类型
  • wrangler.jsonc 的 bindings 配置决定了运行时能力
  • 不同规模的项目用不同复杂度的结构,不要小项目套大架构

1. 从单文件到多文件

上一篇把所有路由写在一个 src/index.ts 里,四个接口还能应付。但真实项目不会停在四个接口。用户模块、聊天模块、健康检查、Webhook 回调——加到十几二十个路由时,一个文件会变成几百行的「上帝文件」,找路由靠滚动,改一处怕影响全局。

什么时候该拆分?实用的判断信号:

  • 单个文件超过 150 行
  • 路由涉及两个以上业务领域(用户、订单、消息……)
  • 想加一个中间件但发现不知道插在哪

不用等到「必须拆」才动手。提前按领域分好文件,后续加路由时自然知道往哪放。

2. 推荐的目录结构

一个中型 Hono 项目的典型结构:

my-api/
  src/
    index.ts              # 入口:创建 app,组装路由,导出
    routes/               # 路由按领域拆分
      health.ts           #   健康检查 GET /api/health
      users.ts            #   用户模块 /api/users/*
      chat.ts             #   聊天模块 /api/chat/*
    middleware/            # 自定义中间件
      auth.ts             #   鉴权:校验 token,注入用户信息
      logger.ts           #   日志:记录请求方法、路径、耗时
    helpers/               # 工具函数
      response.ts         #   统一响应格式 { code, data, message }
    types/                 # 类型定义
      env.d.ts            #   Cloudflare Workers 绑定类型
      models.ts           #   业务数据模型(User、Message 等)
  wrangler.jsonc           # Cloudflare Workers 配置
  tsconfig.json            # TypeScript 配置
  .dev.vars                # 本地环境变量(不进 git)

每个目录的边界:

  • routes/ — 只放路由定义,一个文件对应一个业务领域
  • middleware/ — 可复用的请求处理逻辑,比如鉴权、日志、CORS
  • helpers/ — 纯函数工具,不依赖 Hono Context 的也可以放这里
  • types/ — 类型定义集中管理,避免散落在各文件里重复声明

3. 路由拆分实践

上一篇的 src/index.ts 里有四个路由,包括 /api/health/api/users/*。现在把它们拆到独立文件。

第一步,把用户相关的路由抽到 routes/users.ts

// src/routes/users.ts
import { Hono } from 'hono'
 
const users = new Hono()
 
users.get('/:id', (c) => {
  const id = c.req.param('id')
  return c.json({ id, name: `User ${id}` })
})
 
users.post('/', async (c) => {
  const body = await c.req.json()
  return c.json({ message: 'User created', data: body }, 201)
})
 
export default users

注意两个关键点:

  1. 创建的是子 appconst users = new Hono()),不是全局 app
  2. 路由路径去掉了 /api/users 前缀——前缀在挂载时加上

第二步,抽出健康检查:

// src/routes/health.ts
import { Hono } from 'hono'
 
const health = new Hono()
 
health.get('/', (c) => {
  return c.json({ status: 'ok', timestamp: Date.now() })
})
 
export default health

第三步,在入口文件用 app.route() 组装:

// src/index.ts
import { Hono } from 'hono'
import users from './routes/users'
import health from './routes/health'
 
const app = new Hono()
 
app.get('/', (c) => c.text('Hello Hono!'))
 
// 挂载子路由,第一个参数是路径前缀
app.route('/api/users', users)
app.route('/api/health', health)
 
export default app

app.route('/api/users', users)users 子 app 挂载到 /api/users 前缀下。子 app 里的 /:id 变成 /api/users/:id/ 变成 /api/users。最终路由表和拆分前完全一致,但代码分布在三个文件里,每个文件职责单一。

子 app 的 export 模式

上面的例子用 export default,适合一个文件导出一个子 app。如果需要在同一文件里导出多个路由组,可以用命名导出:

// src/routes/orders.ts
import { Hono } from 'hono'
 
export const orderRoutes = new Hono()
orderRoutes.get('/', (c) => c.json({ orders: [] }))
 
export const returnRoutes = new Hono()
returnRoutes.get('/', (c) => c.json({ returns: [] }))

小项目用 default export 就够了,命名导出在路由组较多时更灵活。

4. 类型文件的组织

Cloudflare Workers 的绑定(D1、KV、R2 等)需要通过泛型注入到 Hono 的 Context 里。集中定义在 types/env.d.ts

// src/types/env.d.ts
export type Env = {
  Bindings: {
    DB: D1Database        // D1 数据库
    CACHE: KVNamespace     // KV 命名空间
    API_SECRET: string     // 环境变量
    JWT_SECRET: string
  }
}

在路由和中间件里通过泛型引用:

// src/routes/users.ts
import { Hono } from 'hono'
import type { Env } from '../types/env'
 
const users = new Hono<Env>()
 
users.get('/:id', async (c) => {
  const db = c.env.DB
  const user = await db.prepare('SELECT * FROM users WHERE id = ?')
    .bind(c.req.param('id'))
    .first()
  return c.json(user)
})
 
export default users

入口文件也加上泛型 new Hono&lt;Env&gt;(),这样 c.env.DBc.env.CACHE 就有完整的类型提示,不用类型断言。

业务数据模型放 types/models.ts,集中管理的好处是改一处全局生效,不会出现两个文件对同一实体定义不一致的问题。

5. 配置文件的职责

wrangler.jsonc 的 bindings

上一篇介绍了 namemaincompatibility_date。真实项目还需要配置 bindings——Workers 访问外部能力的方式:

// wrangler.jsonc
{
  "name": "my-api",
  "main": "src/index.ts",
  "compatibility_date": "2026-04-15",
 
  // 静态环境变量(非敏感)
  "vars": { "ENVIRONMENT": "production" },
 
  // D1 数据库
  "d1_databases": [{
    "binding": "DB",
    "database_name": "my-api-db",
    "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
  }],
 
  // KV 键值存储
  "kv_namespaces": [{
    "binding": "CACHE",
    "id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
  }]
 
  // r2_buckets、services 等同理
}

每种 binding 的含义:

  • vars:非敏感的环境变量,会随代码提交到 git
  • d1_databases:绑定 D1 SQLite 数据库
  • kv_namespaces:绑定 KV 键值存储,适合缓存
  • r2_buckets:绑定 R2 对象存储,适合文件上传
  • services:绑定其他 Worker 服务,实现服务间调用

binding 字段的值必须和 types/env.d.ts 里的属性名一致。binding: "DB" 对应代码里的 c.env.DB

.dev.vars 本地环境变量

敏感信息(API Key、密钥)不要写在 wrangler.jsonc 里,放在 .dev.vars

# .dev.vars(不要提交到 git)
API_SECRET=dev-only-secret-key
JWT_SECRET=dev-jwt-secret

wrangler 在 wrangler dev 时自动加载这个文件。生产环境的同名变量在 Cloudflare Dashboard 配置。.dev.vars 必须加到 .gitignore

tsconfig.json 关键配置

模板生成的配置通常不需要大改,但路径别名建议手动加上:

// tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "strict": true,
    "types": ["@cloudflare/workers-types"],
    "paths": {
      "@/*": ["./src/*"]   // 路径别名
    }
  }
}

项目文件层级变深时,@/types/env../../../types/env 清晰得多。

6. 不同规模项目的结构建议

不要小项目套大架构,结构复杂度应该跟着项目规模增长。

小型项目(1-5 个路由)

单文件就够了,不用拆。index.ts 没超过 150 行,一个文件能一眼看完。

中型项目(5-20 个路由)

就是第 2 节的完整结构,按领域拆分 routes,独立 middleware 和 helpers 目录。大多数项目停在这个阶段。

大型项目(20+ 路由,多团队协作)

在中型结构基础上增加分层:

src/
  index.ts
  routes/
    users/
      index.ts          # 路由组装
      handlers.ts       # 处理函数
      validators.ts     # 参数校验
    chat/
      index.ts
      handlers.ts
      validators.ts
  middleware/
  services/             # 业务逻辑层,不依赖 Hono Context
  repositories/         # 数据访问层,封装数据库操作
  types/

路由处理函数只负责解析请求、调用 service、返回响应;业务逻辑在 service 里;数据库操作在 repository 里。但别急着跳到这个结构——等到路由文件超过 100 行、业务逻辑和 HTTP 处理混在一起难以测试时再拆。

7. 后续章节预告

目录结构是骨架,接下来的章节会逐个填充血肉:

  • 路由系统:路径参数、通配符、路由分组、请求方法匹配
  • 中间件:内置和自定义中间件的写法,执行顺序和组合方式
  • 请求与响应:参数解析、Body 处理、文件上传、流式响应
  • 错误处理:全局错误边界、自定义错误类、统一错误响应格式

建议现在就按第 2 节的结构整理项目,后续跟着做时不用回头调整。

延伸阅读

总结

项目目录结构不是一开始就要设计完美的——先用单文件跑起来,路由多了再按领域拆分。核心是理解 Hono 的 app.route() 机制:子 app 定义路由时不带公共前缀,入口文件挂载时统一加上。类型定义集中在 types/,bindings 类型和 wrangler.jsonc 保持一致。结构跟着项目长,不要提前过度设计。