Node.js 框架的 10 个写法痛点,以及更优雅的解决方案
Express、Koa、Fastify、Hono、Elysia... 用了这么多框架,总有些地方让人难受。这篇文章整理了 Node.js 框架开发中常见的 10 个写法痛点,以及如何用声明式的方式优雅解决。
痛点一:路由分散,找个接口像大海捞针
Express / Koa / Hono 的写法
// Express
app.get('/users', getUsers)
app.post('/users', createUser)
app.get('/users/:id', getUser)
app.put('/users/:id', updateUser)
app.delete('/users/:id', deleteUser)
// 50 行后...
app.get('/posts', getPosts)
app.post('/posts', createPost)
// 100 行后...
app.get('/comments', getComments)
// 想知道项目有哪些接口?慢慢翻吧...
问题:
- 路由分散在各处,没有全局视图
- 新人接手要一行行看才知道有哪些 API
- Code Review 时很难快速理解接口全貌
Vafast 的写法
const routes = defineRoutes([
// 用户相关
{ method: 'GET', path: '/users', handler: getUsers },
{ method: 'POST', path: '/users', handler: createUser },
{ method: 'GET', path: '/users/:id', handler: getUser },
{ method: 'PUT', path: '/users/:id', handler: updateUser },
{ method: 'DELETE', path: '/users/:id', handler: deleteUser },
// 文章相关
{ method: 'GET', path: '/posts', handler: getPosts },
{ method: 'POST', path: '/posts', handler: createPost },
// 评论相关
{ method: 'GET', path: '/comments', handler: getComments },
])
路由就是数组,所有接口一目了然。 团队协作、Code Review、接口文档生成都更方便。
痛点二:中间件作用域混乱
Express 的写法
app.use(cors()) // 全局?
app.use('/api', authMiddleware) // /api/* 都生效
app.get('/api/users', getUsers) // 有 auth
app.get('/api/public', getPublic) // 也有 auth?不想要啊!
// 怎么让 /api/public 不走 auth?
// 方案1:调整顺序(容易出错)
// 方案2:在 authMiddleware 里加判断(恶心)
// 方案3:拆分路由文件(复杂)
Hono 的写法
app.use('/*', cors())
app.use('/api/*', authMiddleware) // /api 本身呢?要不要 auth?
// 路径匹配规则:
// /api/* 匹配 /api/users,但不匹配 /api
// /api/** 呢?不同框架规则不一样...
问题:
- 中间件通过路径匹配,作用域不直观
- 想精确控制某个路由的中间件很麻烦
- 容易出现「这个接口为什么没走鉴权」的 bug
Vafast 的写法
const routes = defineRoutes([
// 公开接口,无中间件
{ method: 'GET', path: '/public', handler: publicHandler },
// 需要鉴权的接口,显式声明
{ method: 'GET', path: '/api/users', middleware: [authMiddleware], handler: getUsers },
{ method: 'GET', path: '/api/profile', middleware: [authMiddleware], handler: getProfile },
// 需要管理员权限
{ method: 'DELETE', path: '/api/users/:id', middleware: [authMiddleware, adminMiddleware], handler: deleteUser },
])
中间件直接声明在路由上,哪个接口用什么中间件,一眼就能看出来。
痛点三:类型推断跨文件就丢失
Hono 的类型问题
// -------- app.ts --------
import { Hono } from 'hono'
type Env = { Variables: { user: { id: string; role: string } } }
const app = new Hono<Env>()
app.use('/*', async (c, next) => {
c.set('user', { id: '1', role: 'admin' })
await next()
})
// -------- routes.ts --------
import { Hono } from 'hono'
// 想复用上面的类型?对不起,做不到
export function setupRoutes(app: Hono) {
app.get('/profile', (c) => {
const user = c.get('user') // ❌ 类型是 unknown
return c.json(user)
})
}
Elysia 的类型问题
// -------- app.ts --------
const app = new Elysia()
.state('user', null as { id: string } | null)
.derive(({ store }) => ({ user: store.user }))
// -------- routes.ts --------
// 想在另一个文件定义路由?
// 链式调用的类型上下文无法传递到其他文件
问题:
- 类型绑定在 App 实例上,跨文件无法复用
- 大型项目拆分文件后,类型安全形同虚设
- 要么所有代码写一个文件,要么放弃类型
Vafast 的写法
// -------- types.ts --------
export type AuthContext = { user: { id: string; role: string } }
// -------- handlers/profile.ts --------
import { createHandlerWithExtra } from 'vafast'
import type { AuthContext } from '../types'
// 类型在 Handler 级别定义,任意文件都能用
export const getProfile = createHandlerWithExtra<AuthContext>(
(ctx) => {
const user = ctx.user // ✅ 类型完整: { id: string; role: string }
return { profile: user }
}
)
// -------- handlers/admin.ts --------
import { createHandlerWithExtra, Type } from 'vafast'
import type { AuthContext } from '../types'
// 另一个文件,同样的类型
export const deleteUser = createHandlerWithExtra<AuthContext>(
{ params: Type.Object({ id: Type.String() }) },
(ctx) => {
const adminId = ctx.user.id // ✅ 类型完整
return { deleted: true, by: adminId }
}
)
类型跟着 Handler 走,不跟着 App 实例走。 多大的项目都能保持类型安全。
痛点四:错误处理各写各的
Express 的写法
app.get('/user/:id', async (req, res, next) => {
try {
const user = await db.findUser(req.params.id)
if (!user) {
return res.status(404).json({ message: 'User not found' })
// 还是 { error: 'Not found' }?
// 还是 { code: 404, msg: 'xxx' }?
// 团队每个人写法都不一样...
}
res.json(user)
} catch (err) {
next(err) // 交给错误处理中间件
}
})
// 错误处理中间件
app.use((err, req, res, next) => {
res.status(500).json({ message: err.message })
})
Hono 的写法
import { HTTPException } from 'hono/http-exception'
app.get('/user/:id', async (c) => {
const user = await db.findUser(c.req.param('id'))
if (!user) {
throw new HTTPException(404, { message: 'User not found' })
// 响应格式?自己定,没有标准
}
return c.json(user)
})
问题:
- 错误响应格式没有统一标准
- 团队成员各写各的,前端对接痛苦
- 状态码和错误信息容易不匹配
Vafast 的写法
import { createHandler, err } from 'vafast'
const getUser = createHandler(
{ params: Type.Object({ id: Type.String() }) },
async ({ params }) => {
const user = await db.findUser(params.id)
if (!user) {
throw err.notFound('用户不存在') // 404
}
if (!user.active) {
throw err.forbidden('用户已被禁用') // 403
}
return user
}
)
// 统一的错误响应格式
// { "error": "NOT_FOUND", "message": "用户不存在" }
// { "error": "FORBIDDEN", "message": "用户已被禁用" }
语义化的错误 API,统一的响应格式。 团队不用再争论错误格式怎么定。
| 方法 | 状态码 | error 字段 |
|---|---|---|
err.badRequest() | 400 | BAD_REQUEST |
err.unauthorized() | 401 | UNAUTHORIZED |
err.forbidden() | 403 | FORBIDDEN |
err.notFound() | 404 | NOT_FOUND |
err.conflict() | 409 | CONFLICT |
err.internal() | 500 | INTERNAL_ERROR |
痛点五:参数验证和类型两套逻辑
Express + Joi/Zod 的写法
import { z } from 'zod'
// 定义 Schema
const CreateUserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().min(0),
})
// 定义 TypeScript 类型
type CreateUserInput = z.infer<typeof CreateUserSchema>
app.post('/users', async (req, res) => {
// 手动验证
const result = CreateUserSchema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({ errors: result.error.issues })
}
const body = result.data // 类型:CreateUserInput
// ...
})
问题:
- Schema 定义和类型推断是两步操作
- 每个接口都要手动调用验证
- 验证失败的响应格式还要自己处理
Vafast 的写法
import { createHandler, Type } from 'vafast'
const createUser = createHandler(
{
body: Type.Object({
name: Type.String(),
email: Type.String({ format: 'email' }),
age: Type.Number({ minimum: 0 }),
})
},
({ body }) => {
// body 自动验证 ✅
// body 自动推断类型 ✅
// 验证失败自动返回 400 ✅
return { id: crypto.randomUUID(), ...body }
}
)
Schema 定义一次,验证和类型推断自动完成。 验证失败自动返回标准格式的 400 响应。
痛点六:响应处理繁琐
Express 的写法
app.get('/users', async (req, res) => {
const users = await db.getUsers()
res.json(users) // 要手动调用 res.json()
})
app.get('/text', (req, res) => {
res.type('text/plain').send('Hello') // 要手动设置 Content-Type
})
app.post('/users', async (req, res) => {
const user = await db.createUser(req.body)
res.status(201).json(user) // 要手动设置状态码
})
Hono 的写法
app.get('/users', async (c) => {
const users = await db.getUsers()
return c.json(users) // 要用 c.json()
})
app.get('/text', (c) => {
return c.text('Hello') // 要用 c.text()
})
问题:
- 返回不同类型的数据要用不同的方法
- 容易忘记设置正确的 Content-Type
- 状态码要手动指定
Vafast 的写法
import { createHandler, json } from 'vafast'
// 返回对象 → 自动转 JSON
const getUsers = createHandler(async () => {
return await db.getUsers() // 自动 200 + application/json
})
// 返回字符串 → 自动 text/plain
const getText = createHandler(() => {
return 'Hello' // 自动 200 + text/plain
})
// 需要自定义状态码
const createUser = createHandler(async ({ body }) => {
const user = await db.createUser(body)
return json(user, 201) // 201 Created
})
// 需要完全控制
const custom = createHandler(() => {
return new Response('custom', {
status: 200,
headers: { 'X-Custom': 'value' }
})
})
直接 return,框架自动处理。 对象转 JSON,字符串转 text/plain,需要自定义时也很简单。
痛点七:切换运行时要改代码
Express 的问题
// Express 只能跑在 Node.js
import express from 'express'
const app = express()
app.listen(3000)
// 想部署到 Cloudflare Workers?
// 对不起,不支持,得换框架重写
Fastify 的问题
// Fastify 主要针对 Node.js 优化
import Fastify from 'fastify'
const fastify = Fastify()
await fastify.listen({ port: 3000 })
// Bun 支持?实验性的
// Workers 支持?不支持
Vafast 的写法
import { Server, defineRoutes, createHandler } from 'vafast'
const routes = defineRoutes([
{ method: 'GET', path: '/', handler: createHandler(() => 'Hello!') }
])
const server = new Server(routes)
// -------- Bun --------
export default { port: 3000, fetch: server.fetch }
// -------- Cloudflare Workers --------
export default { fetch: server.fetch }
// -------- Node.js --------
import { serve } from 'vafast'
serve({ fetch: server.fetch, port: 3000 })
// -------- Deno --------
Deno.serve({ port: 3000 }, server.fetch)
基于 Web 标准 Fetch API,一套代码到处运行。 只需要改启动方式,业务代码完全不用动。
痛点八:嵌套路由写起来很啰嗦
Express 的写法
// 需要创建多个 Router
const userRouter = express.Router()
userRouter.get('/', getUsers)
userRouter.get('/:id', getUser)
const postRouter = express.Router()
postRouter.get('/', getUserPosts)
postRouter.get('/:postId', getUserPost)
// 然后一层层挂载
userRouter.use('/:id/posts', postRouter)
app.use('/api/users', userRouter)
// 嵌套越深,文件越多,跳转越累
// 想看完整路由结构?祝你好运...
Hono 的写法
const users = new Hono()
users.get('/', getUsers)
users.get('/:id', getUser)
users.get('/:id/posts', getUserPosts)
users.get('/:id/posts/:postId', getUserPost)
app.route('/api/users', users)
// 稍微好点,但还是要跳文件
问题:
- 路由分散在多个文件/Router 中
- 嵌套层级不直观
- 公共中间件要在多处声明
Vafast 的写法
const routes = defineRoutes([
{
path: '/api/users',
middleware: [authMiddleware], // 整个分组共享中间件
children: [
{ method: 'GET', path: '/', handler: getUsers },
{ method: 'GET', path: '/:id', handler: getUser },
{
path: '/:id/posts',
children: [
{ method: 'GET', path: '/', handler: getUserPosts },
{ method: 'GET', path: '/:postId', handler: getUserPost },
]
}
]
}
])
// 一个数组,层级清晰,中间件继承
声明式嵌套,一眼看清层级关系。 子路由自动继承父路由的路径前缀和中间件。
痛点九:Format 验证要自己写
Zod / 其他框架
import { z } from 'zod'
// 想验证邮箱?内置
const email = z.string().email()
// 想验证中国手机号?自己写正则
const phone = z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确')
// 想验证 UUID?要看版本支持
const uuid = z.string().uuid()
// 想验证 JWT?自己写
const jwt = z.string().regex(/^[\w-]+\.[\w-]+\.[\w-]+$/)
// 想验证中国身份证?更复杂...
const idCard = z.string().regex(/^[1-9]\d{5}(18|19|20)\d{2}.../)
// 每个项目都要写一遍这些正则
问题:
- 常用格式验证要自己写正则
- 团队每个人写的正则可能不一样
- 容易出错,不好维护
Vafast 的写法
import { Type } from 'vafast'
// 内置 30+ format,直接用
const UserSchema = Type.Object({
email: Type.String({ format: 'email' }),
phone: Type.String({ format: 'phone' }), // 中国手机号
phoneIntl: Type.String({ format: 'phone-e164' }), // 国际格式
website: Type.String({ format: 'url' }),
avatar: Type.String({ format: 'uuid' }),
token: Type.String({ format: 'jwt' }),
ip: Type.String({ format: 'ipv4' }),
createdAt: Type.String({ format: 'date-time' }),
color: Type.String({ format: 'hex-color' }),
})
内置常用格式,开箱即用。 支持的格式包括:
| 分类 | 格式 |
|---|---|
| 标识符 | email, uuid, cuid, ulid, nanoid, objectid |
| 网络 | url, ipv4, ipv6, hostname |
| 日期 | date, time, date-time, duration |
| 电话 | phone(中国), phone-e164(国际) |
| 编码 | base64, jwt |
| 其他 | hex-color, emoji, semver |
痛点十:前端调用接口没有类型提示
传统方式
// 后端定义了接口
app.post('/api/login', async (req, res) => {
const { email, password } = req.body
const user = await auth.login(email, password)
res.json({ token: user.token, user: { id: user.id, name: user.name } })
})
// 前端要手动定义类型
interface LoginResponse {
token: string
user: { id: string; name: string }
}
interface LoginRequest {
email: string
password: string
}
// 手动写请求
async function login(data: LoginRequest): Promise<LoginResponse> {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(data)
})
return res.json()
}
// 问题:
// 1. 类型要手动同步,后端改了前端不知道
// 2. 字段名写错了,运行时才发现
// 3. 维护两套类型定义,累
OpenAPI / Swagger 方式
// 后端写注释或装饰器生成文档
/**
* @openapi
* /api/login:
* post:
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* email: { type: string }
* password: { type: string }
*/
app.post('/api/login', handler)
// 然后用工具生成客户端代码
// npx openapi-typescript-codegen ...
// 问题:
// 1. 要维护注释/装饰器,容易和代码不同步
// 2. 生成的代码臃肿
// 3. 每次改接口要重新生成
Vafast + @vafast/api-client
// ========== 服务端 ==========
import { defineRoutes, createHandler, Type } from 'vafast'
export const routes = defineRoutes([
{
method: 'POST',
path: '/api/login',
handler: createHandler(
{ body: Type.Object({ email: Type.String(), password: Type.String() }) },
async ({ body }) => {
const user = await auth.login(body.email, body.password)
return { token: user.token, user: { id: user.id, name: user.name } }
}
)
}
])
// 导出类型
export type AppRoutes = typeof routes
// ========== 客户端 ==========
import { eden, InferEden } from '@vafast/api-client'
import type { AppRoutes } from './server' // 只导入类型,不增加包体积
type Api = InferEden<AppRoutes>
const api = eden<Api>('http://localhost:3000')
// 完整类型提示 ✅
const { data } = await api.api.login.post({
email: 'test@example.com', // 必填,有提示
password: '123456' // 必填,有提示
})
console.log(data?.token) // string ✅
console.log(data?.user.name) // string ✅
// 写错字段名?TypeScript 编译就报错
// 后端改了返回值?前端立刻感知
类型从服务端自动推断,零手动维护。 前后端类型永远同步,改了接口 TypeScript 就会报错。
完整对比表
| 痛点 | Express | Hono | Elysia | Vafast |
|---|---|---|---|---|
| 路由可读性 | ❌ 分散 | ❌ 链式 | ❌ 链式 | ✅ 数组一览 |
| 中间件控制 | ❌ 路径匹配 | ❌ 路径匹配 | ⚠️ 链式顺序 | ✅ 显式声明 |
| 跨文件类型 | ❌ | ❌ | ⚠️ 链断裂 | ✅ Handler 级 |
| 错误格式 | ❌ 自定义 | ❌ 自定义 | ⚠️ 半标准 | ✅ 统一格式 |
| Schema 验证 | ❌ 手动 | ❌ 手动 | ✅ 内置 | ✅ 内置 |
| 响应处理 | ❌ 手动 | ⚠️ 需调方法 | ✅ 自动 | ✅ 自动 |
| 跨运行时 | ❌ Node only | ✅ | ⚠️ Bun 优先 | ✅ |
| 嵌套路由 | ❌ 多文件 Router | ⚠️ route() | ⚠️ group() | ✅ 声明式 children |
| 内置 Format | ❌ | ❌ | ⚠️ 部分 | ✅ 30+ 格式 |
| 前后端类型同步 | ❌ 手动 | ❌ 手动 | ⚠️ 需配置 | ✅ 自动推断 |
| 性能 (RPS) | 56K | 56K | 118K | 101K |
什么时候该用 Vafast?
适合的场景:
- ✅ 团队协作项目,需要清晰的路由结构
- ✅ 大型项目,需要拆分多个文件但保持类型安全
- ✅ 需要跨运行时部署(Node.js / Bun / Workers)
- ✅ 追求代码可读性和可维护性
- ✅ 厌倦了链式调用和隐式中间件
不适合的场景:
- ❌ 追求极致性能(Elysia 更快)
- ❌ 喜欢链式 API 的开发体验
- ❌ 已有大量 Express/Hono 代码不想迁移
快速体验
# 创建项目
npx create-vafast-app my-app
cd my-app
# 启动开发
npm run dev
或者手动安装:
npm install vafast
import { Server, defineRoutes, createHandler, Type, err } from 'vafast'
const routes = defineRoutes([
{
method: 'GET',
path: '/hello/:name',
handler: createHandler(
{ params: Type.Object({ name: Type.String() }) },
({ params }) => ({ message: `Hello, ${params.name}!` })
)
}
])
const server = new Server(routes)
export default { port: 3000, fetch: server.fetch }
相关链接
- 📖 Vafast 官方文档
- 💻 GitHub 仓库
- 📦 NPM 包
如果你也被这些痛点困扰过,欢迎试试 Vafast。
有问题或建议?评论区见 👇