Node.js 框架的 10 个写法痛点,以及更优雅的解决方案

125 阅读10分钟

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()400BAD_REQUEST
err.unauthorized()401UNAUTHORIZED
err.forbidden()403FORBIDDEN
err.notFound()404NOT_FOUND
err.conflict()409CONFLICT
err.internal()500INTERNAL_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 就会报错。


完整对比表

痛点ExpressHonoElysiaVafast
路由可读性❌ 分散❌ 链式❌ 链式✅ 数组一览
中间件控制❌ 路径匹配❌ 路径匹配⚠️ 链式顺序✅ 显式声明
跨文件类型⚠️ 链断裂✅ Handler 级
错误格式❌ 自定义❌ 自定义⚠️ 半标准✅ 统一格式
Schema 验证❌ 手动❌ 手动✅ 内置✅ 内置
响应处理❌ 手动⚠️ 需调方法✅ 自动✅ 自动
跨运行时❌ Node only⚠️ Bun 优先
嵌套路由❌ 多文件 Router⚠️ route()⚠️ group()✅ 声明式 children
内置 Format⚠️ 部分✅ 30+ 格式
前后端类型同步❌ 手动❌ 手动⚠️ 需配置✅ 自动推断
性能 (RPS)56K56K118K101K

什么时候该用 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。

有问题或建议?评论区见 👇