多运行时适配原理
要点
- Hono 的跨运行时能力建立在 Fetch API 标准之上——所有运行时都接受
Request、返回Response - 适配器是连接 Hono 核心与具体运行时的薄层,职责是把运行时的入口约定转换成标准的 fetch handler
- 切换运行时的改动集中在入口文件,路由和业务代码不需要任何修改
Env泛型承载了各运行时特有的绑定(Bindings),类型系统帮你把运行时差异收敛在编译期- 运行时特有能力(KV、D1、R2 等)通过
c.env访问,核心层对此完全无感知 - 不在核心代码中判断运行时,是刻意为之的设计决策——保持核心纯粹,把差异推到边缘
1. 一切的前提:fetch handler 标准
Hono 能跨运行时的根本原因,不是它写了多少兼容代码,恰恰相反——它几乎没写任何兼容代码。
Cloudflare Workers、Deno Deploy、Bun、Node.js(18+),这些运行时都实现了 Web 标准的 Fetch API。这意味着它们处理 HTTP 请求的方式是统一的:接收一个 Request,返回一个 Response。
// 这是所有运行时都能理解的形式
export default {
async fetch(request: Request): Promise<Response> {
return new Response('Hello')
}
}Hono 的核心做的事情就是:接收 Request,执行路由匹配和中间件链,最终返回 Response。它不关心这个 Request 是从哪个运行时来的,就像你写一个纯函数,不关心调用者是谁。
// Hono 核心的本质——一个 fetch handler
const app = new Hono()
app.get('/', (c) => c.text('Hello'))
// app.fetch() 接收标准 Request,返回标准 Response
const res = await app.fetch(new Request('http://localhost/'))app.fetch() 是 Hono 暴露的核心入口。它不接受任何运行时特有的参数,只做一件事:把 Request 变成 Response。这一层是纯粹的。
2. 适配器:运行时与核心之间的翻译层
适配器的职责很简单——把特定运行时的入口约定转换成 app.fetch() 调用。
运行时入口 → 适配器 → app.fetch(request) → Response → 运行时返回
每个适配器只做一件事:把运行时的请求格式包装成标准 Request,把 app.fetch() 返回的 Response 交给运行时。
// hono/cloudflare-workers 适配器的本质
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
// 把运行时的 env 和 ctx 注入到 Hono 的上下文中
return app.fetch(request, env, ctx)
}
}// hono/node 适配器的本质
import { serve } from '@hono/node-server'
serve({
fetch: app.fetch,
port: 3000,
})可以看到,不同适配器的代码量都很小。它们不重新实现路由、中间件、请求解析——这些全在 Hono 核心里完成。适配器只负责「接线」。
Hono 官方提供的适配器包括:
hono/cloudflare-workers— Cloudflare Workershono/node— Node.jshono/bun— Bunhono/deno— Denohono/fastly— Fastly Computehono/aws-lambda— AWS Lambdahono/vercel— Vercel Edge Functions
每个适配器对应一个运行时,但它们在 Hono 核心看来没有区别——都是把请求送进 app.fetch(),把响应拿出来。
3. 不同运行时的入口文件
切换运行时,你需要改的只有入口文件。下面对比几种常见写法。
Cloudflare Workers
// src/index.ts
import { Hono } from 'hono'
type Bindings = {
KV: KVNamespace
DB: D1Database
}
const app = new Hono<{ Bindings: Bindings }>()
app.get('/', (c) => {
return c.text('Hello from Workers!')
})
export default appWorkers 的入口导出就是 app 本身。因为 Hono 实例自带 fetch 方法,符合 Workers 的 ExportedHandler 接口。wrangler.toml 里指定入口文件即可。
Node.js
// src/index.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello from Node.js!')
})
serve({
fetch: app.fetch,
port: 3000,
})Node.js 没有原生的 HTTP server 抽象直接对接 fetch handler,所以 @hono/node-server 包起了一个 HTTP server,把收到的请求转成标准 Request 再交给 app.fetch()。
Bun
// src/index.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello from Bun!')
})
export default appBun 原生支持 { fetch: handler } 形式的导出,和 Workers 写法几乎一样。区别只在 bun build 或 bun run 的启动方式。
Deno
// src/index.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello from Deno!')
})
Deno.serve({ port: 8000 }, app.fetch)Deno 用 Deno.serve() 启动 HTTP 服务,第二个参数直接传 app.fetch。注意这里是传方法引用,不是包一层箭头函数——因为 app.fetch 内部已经绑定了 this。
四种写法,业务路由代码一行没改。变的只是最后一行——怎么把 app 或 app.fetch 交给运行时。
4. Bindings 类型:Env 泛型的运行时含义
Hono 的 Hono 类接受一个泛型参数 Env,用来声明运行时特有的绑定:
type Env = {
Bindings: {
KV: KVNamespace
DB: D1Database
SECRET: string
}
}
const app = new Hono<Env>()Env 的结构在不同运行时下含义不同:
Cloudflare Workers 中,Bindings 对应 wrangler.toml 里声明的 KV Namespace、D1 Database、R2 Bucket、Secret 等。它们在请求到来时由运行时注入到 env 参数。
// Workers 运行时传入的参数
async fetch(request: Request, env: Env, ctx: ExecutionContext)Node.js 中,没有平台注入的 bindings。如果你需要类似功能,通常在 serve() 配置里传入自定义对象,或者直接用环境变量。
Bun / Deno 同理,没有 Workers 那种 bindings 机制。Env 泛型在这些运行时下通常留空,或者手动传入你需要的配置。
在路由里通过 c.env 访问:
app.get('/data', async (c) => {
const kv = c.env.KV
const value = await kv.get('key')
return c.text(value ?? 'not found')
})c.env 的类型由 Env['Bindings'] 推导。如果你正确声明了泛型,c.env.KV 会有完整的类型提示;没声明的话,c.env 就是 unknown,需要你自行断言。
5. 运行时特有能力的访问
KV、D1、R2、Queue 这些是 Cloudflare Workers 特有的能力,Hono 不会在核心层封装它们。你要做的是直接通过 c.env 访问原始绑定对象。
type Bindings = {
KV: KVNamespace
DB: D1Database
BUCKET: R2Bucket
}
const app = new Hono<{ Bindings: Bindings }>()
// KV
app.get('/kv/:key', async (c) => {
const value = await c.env.KV.get(c.req.param('key'))
return c.text(value ?? 'not found')
})
// D1
app.get('/db/users', async (c) => {
const { results } = await c.env.DB.prepare('SELECT * FROM users').all()
return c.json(results)
})
// R2
app.put('/upload/:name', async (c) => {
const body = await c.req.arrayBuffer()
await c.env.BUCKET.put(c.req.param('name'), body)
return c.text('uploaded')
})这种设计有一个重要推论:如果你在代码里直接用了 c.env.KV,这段代码就无法在 Node.js 上运行——因为 Node.js 没有 KVNamespace 这个类型。
想保持跨运行时兼容,有几种常见做法:
方案一:在中间件层抽象。把运行时特有的东西封装到中间件里,通过 c.set() 传给后续路由。
// 存储抽象
interface Storage {
get(key: string): Promise<string | null>
set(key: string, value: string): Promise<void>
}
// Workers 环境下的实现
app.use('*', async (c, next) => {
const storage: Storage = {
async get(key) { return c.env.KV.get(key) },
async set(key, value) { await c.env.KV.put(key, value) },
}
c.set('storage', storage)
await next()
})
// 路由代码与运行时无关
app.get('/data/:key', async (c) => {
const storage = c.get('storage') as Storage
const value = await storage.get(c.req.param('key'))
return c.text(value ?? 'not found')
})方案二:用环境变量统一接口。如果你的需求比较简单,KV 可以替换成 Redis、SQLite 或内存 Map,只要接口一致就行。
这些方案的核心思路相同:把运行时差异隔离在边界,业务代码只依赖抽象接口。
6. 为什么不在核心代码中判断运行时
一种直觉的做法是:在 Hono 核心里写 if (isCloudflare) { ... } else if (isNode) { ... },把所有兼容逻辑集中管理。Hono 没有这样做,原因有几层。
核心保持纯粹。 Hono 的核心只依赖 Web Standards API——Request、Response、Headers、URL、crypto。这些 API 在所有目标运行时中都可用。核心代码不引入任何运行时特有的 import,不判断任何运行时特有的全局变量。这让核心可以在任何实现了 Fetch API 的环境中运行,不需要维护条件分支。
包体积可控。 如果你引入 hono/cloudflare-workers,打包产物里不会出现 Node.js 的 fs 或 http 模块。反之亦然。每个运行时只加载自己需要的适配器代码。对于 Workers 这种对包体积敏感的环境,这一点很重要。
运行时迭代不影响核心。 Node.js 某个版本改了 HTTP 行为,只需要更新 hono/node 适配器。Workers 新增了某种 binding 类型,只需要在 hono/cloudflare-workers 里更新类型定义。核心代码不需要跟着改,也不会因为某个运行时的变更引入回归。
职责清晰。 核心负责路由和请求处理,适配器负责运行时对接。出了问题容易定位:如果是路由匹配错了,查核心;如果是请求格式不对,查适配器。两个模块的测试也能独立写。
这和 Unix 哲学里的「做一件事,做好它」是同一个思路。Hono 核心只做一件事——把 Request 变成 Response。怎么把不同运行时的请求送到核心、怎么把响应送回去,是适配器的事。
7. 切换到不同运行时需要改什么
从 Node.js 迁移到 Cloudflare Workers(或者反过来),需要改动的范围其实很小。以下是最小改动清单:
必须改的:
- 入口文件 — 从
serve({ fetch: app.fetch })改成export default app,或反过来 - 依赖 — 安装对应运行时的适配器包,移除不再需要的
- 构建配置 —
tsconfig.json的types字段、wrangler.toml、Dockerfile等 - 启动方式 —
node dist/index.js→wrangler dev→bun run dev
可能需要改的:
- Bindings 类型 — 如果用了运行时特有能力,需要调整
Env泛型 - 中间件 — 某些中间件是运行时绑定的,比如
@hono/node-server的静态文件中间件在 Workers 上不可用 - Node.js 原生 API — 如果业务代码里用了
fs、path、crypto(Node 版)等,需要替换成 Web 标准 API 或运行时等价物
不需要改的:
- 路由定义(
app.get()、app.post()等) - 中间件逻辑(
c.set()/c.get()/c.req/c.json()等) - 请求处理代码(参数解析、响应构造)
- 验证器、RPC 等上层工具
一个实际可行的迁移策略是:先让路由和业务逻辑跑通,再逐步替换运行时特有功能。大多数情况下,80% 的代码不需要任何修改。
延伸阅读
总结
Hono 的跨运行时能力不是靠大量的条件分支和兼容代码堆出来的,而是建立在 Web Standards 之上的自然结果。核心只做一件事——把 Request 变成 Response。适配器负责把不同运行时的入口翻译成这个标准形式。Env 泛型和 c.env 机制让运行时特有能力的访问保持类型安全,同时不污染核心。
当你理解了这层设计,切换运行时就不再是「迁移」,而是换个入口文件的事。