前言
欢迎到我们的交流群一起交流各种前端技术,同时欢迎访问我的 headless 组件库,同时感谢你的 star:
nest.js / hono.js 一起学系列,最终会封装一个通用的架子,例如有鉴权,日志收集,多环境配置等等功能,用两种框架去实现。 之前写了一篇
其实讲的是学习 nest.js 之前,你只有弄懂了这些基础概念,你才不迷糊,因为大多数前端对于 nest.js 这种类似 angular 的编程范式接触不多,在不理解其思想的前提下使用,会非常别扭。
这里我们第一小节,就补充一下, hono.js 的核心设计思想。
函数式和面向对象
在之前的文章:
我们提到了函数式编程的思想和面向对象的编程思想,nest.js 是典型的面向对象的编程思想,各种组件先写对象,然后调用,内部模块化做的很好,使用下来好处很明显,就是
- 模块清晰,规范清晰,适合大型项目的组织结构
缺点也极为明显:
- 不如 java spring 框架的依赖注入好用
- 用起来很繁琐(虽然也有 cli 快捷键生成模板),但总体来说配置一个接口,涉及到的文件比较多,代码写起来比较麻烦
- 还有本身 nest.js 尤其接入 express 框架后,性能是算是 node.js 框架中垫底的存在
反观 hono.js ,属于传统的类似 express,koa 这类函数式 node.js 框架,很轻巧,简单。我们用代码对比一下在不同编程范式下书写体验上的不同。
举例,新建一个路由:
nest.js 的方式
Nest.js 建路由要至少 3 层结构:
- controller.ts
kotlin
import { Controller, Get } from '@nestjs/common'
@Controller('hello')
export class HelloController {
@Get()
getHello() {
return 'Hello Nest!'
}
}
- module.ts(必须注册)
python
import { Module } from '@nestjs/common'
import { HelloController } from './hello.controller'
@Module({
controllers: [HelloController],
})
export class HelloModule {}
- app.module.ts(还必须再注册)
也就是所有别的 module 都要注册到根 module - AppModule
kotlin
@Module({
imports: [HelloModule],
})
export class AppModule {}
- main.ts 启动 IoC 容器
csharp
async function bootstrap() {
const app = await NestFactory.create(AppModule)
await app.listen(3000)
}
bootstrap()
hono.js 的方式
javascript
import { Hono } from 'hono'
const app = new Hono()
app.get('/hello', (c) => c.text('Hello Hono!'))
export default app
大家可以体会不同的编程范式,带来的书写体验的不同。接下来我们看下 hono.js 的主要的两个设计思想:
- 洋葱模型(责任链模式)
- 适配器模型(适配器模式)
洋葱模型
先看看 hono.js 基本的用法, 本文是适配的 node.js ,hono 还可以适配不同的平台,例如 deno, bun, next.js 等等:
javascript
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
// 中间件 A
app.use('*', async (c, next) => {
console.log('Before A')
await next()
console.log('After A')
})
// 中间件 B
app.use('*', async (c, next) => {
console.log('Before B')
await next()
console.log('After B')
})
app.get('/', (c) => {
return c.text('Hello Hono!')
})
serve({
fetch: app.fetch,
port: 3000
}, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})
这种洋葱模型,跟 koa 框架的设计很相似,我们可以简单实现一下,如何让 app.use 可以串联起来这些函数,最终到达 '/hello' 路由。
我们实现一个简易版本
javascript
class Hono {
private middleware: Middleware[] = []
private routes: Record<string, Middleware> = {}
use(fn: Middleware) {
this.middleware.push(fn)
return this
}
get(path: string, handler: Middleware) {
// 一次性 compose:全局中间件 + 路由 handler
const chain = compose([...this.middleware, handler])
this.routes[path] = chain
return this
}
async fetch(req: Request) {
const path = new URL(req.url).pathname
const context: Context = { req, path }
const fn = this.routes[path]
if (!fn) return new Response('Not Found', { status: 404 })
await fn(context)
return context.res ?? new Response('OK')
}
}
上面实现了很简单的,Hono 简易实现代码,我们来看看简单使用
javascript
const app = new Hono()
// 中间件 A
app.use(async (c, next) => {
console.log('Before A')
await next()
console.log('After A')
})
// 中间件 B
app.use(async (c, next) => {
console.log('Before B')
await next()
console.log('After B')
})
这里相当于在内部 this.middleware 数组中添加了两个中间件函数。
接着我们调用
javascript
// 路由处理
app.get('/hello', (c) => c.text('Hello'))
其中代码最主要的是,middleware 和 get 方法对应处理的 url 中的回调函数 handler 合并了
kotlin
get(path: string, handler: Middleware) {
// 一次性 compose:全局中间件 + 路由 handler
const chain = compose([...this.middleware, handler])
this.routes[path] = chain
return this
}
主要通过的是 compose 函数合并,我们简单看下 compose 函数的实现:
javascript
// compose.ts
import { Middleware, Context, Next } from './types'
export function compose(middleware: Middleware[]) {
return function (context: Context, next?: Next): Promise<void> {
let index = -1
function dispatch(i: number): Promise<void> {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn: Middleware | undefined = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, () => dispatch(i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
return dispatch(0)
}
}
说白了,compose 函数思路很简单,就是调用 dispatch(0) -> 然后内部调用 Promise.resolve(fn(context, () => dispatch(i + 1))),也就是将 dispatch 函数传递给中间函数,这个对应的是, 以下中间件的 next 函数,也就是 next 等于 dispatch(i + 1)
javascript
// 中间件 A
app.use(async (c, next) => {
console.log('Before A')
await next()
console.log('After A')
})
所以只有 next 函数自己调用的时候,中间件才会继续执行到下个中间件。
为什么叫洋葱模型
以下代码的打印顺序是这样的:
javascript
const app = new Hono()
// 中间件 A
app.use('*', async (c, next) => {
console.log('Before A')
await next()
console.log('After A')
})
// 中间件 B
app.use('*', async (c, next) => {
console.log('Before B')
await next()
console.log('After B')
})
// 路由处理
app.get('/hello', (c) => c.text('Hello'))
打印
arduino
console.log('Before A')
↓
console.log('Before B')
↓
执行c.text('Hello')
↓
console.log('After B')
↓
console.log('After A')
就像包裹一层洋葱一样的感觉。
说完第一个 hono 核心设计思想洋葱模型,我们接下来看第二个!
适配器模式
Hono.js 需要在 不同运行时(Node.js、Deno、Bun、Cloudflare Workers 等)上运行,但用户希望用统一接口 app.get('/', handler):
不同 runtime 的 HTTP 请求接口不同:
-
Node.js:req: http.IncomingMessage / res: ServerResponse
-
Cloudflare Workers:req: Request
-
Deno:req: RequestEvent
Hono 不修改用户代码,而是通过 Adapter 层把不同 runtime 的请求和 Response 转换成统一的 Context
这个 Adapter 就是适配器。
适配器层是如何工作的?
当你使用 node 运行 Hono 时,你实际上不是直接用 Node 的 req/res,而是:
javascript
import { serve } from '@hono/node-server'
serve({
fetch: app.fetch,
port: 3000
})
Node 的 serve 会:
-
接收 Node 原生的 req/res
-
转换为 Fetch Request
-
调用 app.fetch(request)
-
将 Hono 返回的 Response 再转换回 Node 的 res
反正无论是什么平台,最终给 hono 的 app.fetch(request) 中的 request 满足 hono 定义的接口就行(一般都是 web 标准定义的 request)。
例如 deno 中,这样启动 deno 服务器:
javascript
Deno.serve({ port: 8787 }, app.fetch)
这里为什么 Deno 没有引入 hono 的包去做适配呢,因为 Deno的去request 本身就是符合 web 标准的,只有 Node.js 不是完全符合的。
到现在大家明白了吧,也就是最终传给 app.fetch 的参数,一定要是 web 标准即可。
欢迎一起交流
最后欢迎大家一起交流前端全栈相关的各种知识,当然也包括 ai agent 开发,一起进步!