banner
约 1,500 字
5 分钟

规范使用 TanStack Start Server Functions

摘要

在 TanStack Start 中,Server Functions 极容易成为代码堆砌的灾难区。这篇文章探讨了如何将其牢牢按接在口层的位置:专注鉴权、限流、参数校验与中间件编排,而把真正的业务逻辑还给 Service。

接着上篇聊路由层面防线的话题,咱们继续顺着 TanStack Start 的请求链路往深处走:这次聊聊 Server Functions

当项目脱离了“Demo 期”,真正开始承载复杂业务逻辑时,首当其冲要面临考验的就是这一层。

刚上手时,我们很容易顺手就把数据库查询、数据拼装甚至权限判断全都糊在一个 createServerFn 里。起初这会让你觉得全栈开发“效率奇高”,但很快,你就会在代码库里迷失。

在这个博客项目里,我对 createServerFn 的定位极其克制:它是纯粹的接口层,绝不是业务层。

它只负责这几件苦力活:

  • 声明 HTTP Method

  • 编排中间件拦截器

  • 校验入参格式

  • 把合法请求恭敬地递交给后方的 Service

至于真正的业务细节?它一行都不应该有。

接口层解剖:评论创建逻辑

评论创建功能,是最能体现 Server Functions 价值的场景。

TypeScript
export const createCommentFn = createServerFn({
  method: "POST",
})
  .middleware([
    createRateLimitMiddleware({
      capacity: 10,
      interval: "1m",
      key: "comments:create",
    }),
    turnstileMiddleware,
    authMiddleware,
  ])
  .inputValidator(CreateCommentInputSchema)
  .handler(
    async ({ data, context }) =>
      await CommentService.createComment(context, data),
  );

你看,仅仅这十几行代码,其实已经把一个生产级接口要求的东西拉满了:

  • 这是写操作,所以锁定了 POST

  • 串联了限流兜底(基于 Durable Objects 分布式限流

  • 串联了 Turnstile 人机验证

  • 串联了必须登录的 Auth 校验

  • inputValidator 对抗所有的脏输入

  • 最终,把干净的 data 丢给 CommentService

整个结构非常漂亮。接口层有明确的宏观职责,但它自己绝对不会长出不可控的业务逻辑分支。

中间件编排:告别面条代码

如果不利用中间件,我们在传统的 API Controller 里得怎么写?

恐怕得一遍遍地手写:先连数据库,再验 Session,再查验证码,再算限流额度……

相比之下,Server Functions 利用中间件组合的写法简直是降维打击。比如,普通的查询接口和刚才的写接口,权限要求就截然不同:

TypeScript
export const getRootCommentsByPostIdFn = createServerFn()
  .middleware([sessionMiddleware])
  .inputValidator(GetCommentsByPostIdInputSchema)
  .handler(async ({ data, context }) => {
    const session = context.session;

    return await CommentService.getRootCommentsByPostId(context, {
      ...data,
      viewerId: session?.user.id,
    });
  });

读接口只需要挂载个轻量的 sessionMiddleware 就够了。一旦底层中间件体系搭建完毕,新写一个接口就如同搭积木一样简单。

强类型的文件上传:FormData处理

全栈框架处理起 JSON 往往很丝滑,但一碰到文件上传等 FormData 场景,接口层就开始群魔乱舞。

在 TanStack Start 里,处理文件导入其实和处理 JSON 没什么心智负担:

TypeScript
// 强制校验输入必须是原生的 FormData 实例
const UploadForImportInputSchema = z.instanceof(FormData);

export const uploadForImportFn = createServerFn({
  method: "POST",
})
  .middleware([adminMiddleware])
  .inputValidator(UploadForImportInputSchema)
  .handler(async ({ data: formData, context }) => {
    // 过滤出真正的 File 对象
    const files = formData
      .getAll("file")
      .filter((f): f is File => f instanceof File);

    return await ImportExportService.startImport(context, files);
  });

依然保持着强类型约束:

  • 请求方法明确

  • 权限隔离明确

  • 入参对象收束明确

这里顺带提一嘴,不管是在处理表单还是普通校验阶段,遇到异常一定要抛出类型安全的 Error,前两天总结的那篇 用 Result 类型处理全栈错误 同样适用于 Server Functions 的 handler 返回层。

Service 纯粹化

标签系统和后台索引重建的接口,最能展现这种“强行压薄”的爽感:

TypeScript
// 创建标签接口
export const createTagFn = createServerFn({
  method: "POST",
})
  .middleware([adminMiddleware])
  .inputValidator(CreateTagInputSchema)
  .handler(({ data, context }) => TagService.createTag(context, data));

// 触发搜索索引全量重建接口
export const buildSearchIndexFn = createServerFn({ method: "POST" })
  .middleware([adminMiddleware])
  .handler(({ context }) => SearchService.rebuildIndex(context));

没有废话。

如果你用传统的框架去写 API 路由文件(比如 Next.js 的 App Router Route Handlers 或者原始的 Express),巨大的空白区域很容易诱惑你往里面塞几行“临时”的 SQL 查询或者胶水逻辑。

createServerFn 这种链式调用的写法,直接在视觉和语法上锁死了你乱写代码的空间。它会“逼着”你老老实实地回 Service 模块里去设计聚合根和领域方法。

Server Functions 的边界

我不会把 Server Functions 吹成无所不能的银弹。

它的主场在同构全栈体系内:它让前后端的互相调用几乎没有摩擦,类型直接打通,省去了手写 Swagger 和 Fetch 的冗长痛点。

但它也有无法覆盖的盲区:

  • 如果你需要开放正规的 RESTful / GraphQL API 给第三方客户端接入,光靠 Server Functions 肯定不踏实,还是得外挂一层真正的外部 API 层。

  • 对于脱离了框架 HTTP 请求生命周期的任务,比如 Cloudflare Workflows、Queues 的消费者,这套逻辑自然也管不着。

这也是为什么我的博客架构里,除了这套对内的 Server Functions,针对特殊场景还分别暴露了纯粹的 Hono 接口、MCP Entrypoint 和 Workflow 的触发器。

没有任何一种工具有资格包揽全部,看菜下饭就好。

总结

一句话:永远把 Server Functions 当接线员,不要当包工头。

它存在的意义是把网上的“荒蛮报文”,优雅地洗出:

  • 靠谱的 HTTP Method

  • 层层清洗过的 Middleware Context

  • 格式极其确定的 Input Validator

然后呢?然后就交给后端 Service 干活去吧。

END
© 2026 阿旷. All Rights Reserved. / RSS / Sitemap
Powered by Tanstack Start & Flare Stack Blog