多运行时适配原理

要点

  • 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 Workers
  • hono/node — Node.js
  • hono/bun — Bun
  • hono/deno — Deno
  • hono/fastly — Fastly Compute
  • hono/aws-lambda — AWS Lambda
  • hono/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 app

Workers 的入口导出就是 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 app

Bun 原生支持 &#123; fetch: handler &#125; 形式的导出,和 Workers 写法几乎一样。区别只在 bun buildbun 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

四种写法,业务路由代码一行没改。变的只是最后一行——怎么把 appapp.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) &#123; ... &#125; else if (isNode) &#123; ... &#125;,把所有兼容逻辑集中管理。Hono 没有这样做,原因有几层。

核心保持纯粹。 Hono 的核心只依赖 Web Standards API——RequestResponseHeadersURLcrypto。这些 API 在所有目标运行时中都可用。核心代码不引入任何运行时特有的 import,不判断任何运行时特有的全局变量。这让核心可以在任何实现了 Fetch API 的环境中运行,不需要维护条件分支。

包体积可控。 如果你引入 hono/cloudflare-workers,打包产物里不会出现 Node.js 的 fshttp 模块。反之亦然。每个运行时只加载自己需要的适配器代码。对于 Workers 这种对包体积敏感的环境,这一点很重要。

运行时迭代不影响核心。 Node.js 某个版本改了 HTTP 行为,只需要更新 hono/node 适配器。Workers 新增了某种 binding 类型,只需要在 hono/cloudflare-workers 里更新类型定义。核心代码不需要跟着改,也不会因为某个运行时的变更引入回归。

职责清晰。 核心负责路由和请求处理,适配器负责运行时对接。出了问题容易定位:如果是路由匹配错了,查核心;如果是请求格式不对,查适配器。两个模块的测试也能独立写。

这和 Unix 哲学里的「做一件事,做好它」是同一个思路。Hono 核心只做一件事——把 Request 变成 Response。怎么把不同运行时的请求送到核心、怎么把响应送回去,是适配器的事。

7. 切换到不同运行时需要改什么

从 Node.js 迁移到 Cloudflare Workers(或者反过来),需要改动的范围其实很小。以下是最小改动清单:

必须改的:

  1. 入口文件 — 从 serve(&#123; fetch: app.fetch &#125;) 改成 export default app,或反过来
  2. 依赖 — 安装对应运行时的适配器包,移除不再需要的
  3. 构建配置tsconfig.jsontypes 字段、wrangler.tomlDockerfile
  4. 启动方式node dist/index.jswrangler devbun run dev

可能需要改的:

  1. Bindings 类型 — 如果用了运行时特有能力,需要调整 Env 泛型
  2. 中间件 — 某些中间件是运行时绑定的,比如 @hono/node-server 的静态文件中间件在 Workers 上不可用
  3. Node.js 原生 API — 如果业务代码里用了 fspathcrypto(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 机制让运行时特有能力的访问保持类型安全,同时不污染核心。

当你理解了这层设计,切换运行时就不再是「迁移」,而是换个入口文件的事。