1. 为什么我们需要全栈类型安全?
在传统的前后端分离开发模式中,API接口就像两个说着不同方言的邻居——前端用JavaScript期待某种数据结构,后端用Java/Python返回另一种格式。我曾在实际项目中遇到过这样的场景:后端修改了一个字段名但忘记通知前端,导致线上页面大面积报错。这种问题通常要等到运行时才会暴露,而类型安全就是要从根本上解决这类问题。
TypeScript的出现已经显著改善了前端的类型安全,但前后端之间的"协议断层"依然存在。常见的解决方案包括:
- Swagger/OpenAPI文档(维护成本高且容易过时)
- 手动编写DTO类型(前后端各写一遍,容易不同步)
- GraphQL(学习曲线陡峭且需要额外基础设施)
tRPC的核心理念是:既然前后端都用TypeScript,为什么不直接共享类型定义?这就像把前后端代码放在同一个类型系统中,让编译器成为你的API契约守护者。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. tRPC架构深度解析
2.1 tRPC的核心设计哲学
tRPC不是一个新框架,而是一个轻量层(约5KB)。它的工作原理可以类比为:
- 后端定义"可调用方法"(类似RPC)
- 前端像调用本地函数一样调用这些方法
- 类型系统自动完成参数校验和返回类型推断
其核心优势体现在:
- 零API文档:类型即文档
- 端到端类型安全:从数据库到UI组件全程类型校验
- 开发体验优化:代码补全、类型检查、重构支持
2.2 Next.js的全栈能力加持
Next.js的App Router模式天然支持服务端和客户端组件混合渲染。结合tRPC使用时:
- API路由:
/app/api/trpc/[trpc]/route.ts - 客户端调用:通过生成的trpc客户端实例
- 类型共享:通过统一的
@/server类型定义
这种架构下,一个完整的类型调用链路是这样的:
code复制数据库Schema → Prisma类型 → tRPC路由类型 → 前端组件props
3. 实战:构建类型安全的待办事项应用
3.1 项目初始化与依赖安装
首先创建Next.js项目(使用TypeScript模板):
bash复制npx create-next-app@latest --typescript
cd your-app
安装核心依赖:
bash复制npm install @trpc/server @trpc/client @trpc/next @trpc/react-query zod
npm install -D prisma @types/node
注意:zod用于输入验证,虽然tRPC支持多种验证器,但zod与TypeScript的类型推断配合最佳
3.2 后端路由定义(含完整类型)
在/server/routers/todo.ts中:
typescript复制import { z } from 'zod'
import { router, publicProcedure } from '@/server/trpc'
const Todo = z.object({
id: z.string(),
text: z.string().min(1),
completed: z.boolean().default(false)
})
export const todoRouter = router({
list: publicProcedure.query(async ({ ctx }) => {
return ctx.prisma.todo.findMany()
}),
add: publicProcedure
.input(z.object({ text: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
return ctx.prisma.todo.create({
data: { text: input.text }
})
}),
toggle: publicProcedure
.input(z.object({ id: z.string(), completed: z.boolean() }))
.mutation(async ({ input, ctx }) => {
return ctx.prisma.todo.update({
where: { id: input.id },
data: { completed: input.completed }
})
})
})
关键点说明:
publicProcedure定义了可公开访问的端点.input()指定了Zod验证schema- 返回类型会自动推断为Prisma操作的结果类型
3.3 前端调用与类型消费
在组件中调用时:
typescript复制import { api } from '@/utils/trpc'
function TodoList() {
// 完全类型安全的查询
const { data: todos } = api.todo.list.useQuery()
// 完全类型安全的变更
const toggleMutation = api.todo.toggle.useMutation()
return (
<ul>
{todos?.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleMutation.mutate({
id: todo.id,
completed: !todo.completed
})}
/>
{todo.text}
</li>
))}
</ul>
)
}
你会注意到:
api.todo.list.useQuery()的返回类型自动推断为Todo[]toggleMutation.mutate()的参数会被检查是否符合后端定义- 输入时会有完整的代码补全提示
4. 高级模式与性能优化
4.1 服务端辅助类型导出
有时我们需要在非tRPC上下文中使用相同类型(如SSR页面)。可以在路由文件中导出类型:
typescript复制// 导出输入输出类型
export type TodoAddInput = inferProcedureInputs<typeof todoRouter.add>
export type TodoAddOutput = inferProcedureOutputs<typeof todoRouter.add>
// 在getServerSideProps中使用
export const getServerSideProps = async () => {
const todos = await todoRouter.list()
return { props: { todos } }
}
4.2 请求批处理与缓存
tRPC默认会自动批处理同时发起的请求。例如:
typescript复制// 这两个请求会被合并为一个HTTP请求
const [user, posts] = await Promise.all([
trpc.user.byId.query(1),
trpc.post.byUserId.query(1)
])
缓存策略可以通过React Query配置:
typescript复制// /utils/trpc.ts
export const trpc = createTRPCNext<AppRouter>({
config() {
return {
queryClientConfig: {
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000 // 5分钟缓存
}
}
}
}
}
})
4.3 错误处理标准化
定义统一的错误格式:
typescript复制// /server/middlewares/errorFormatter.ts
export function formatError(error: TRPCError) {
return {
code: error.code,
message: error.message,
// 开发环境返回堆栈
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
}
}
// 然后在trpc实例中配置
export const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return formatError(error)
}
})
前端消费错误时:
typescript复制const addTodo = api.todo.add.useMutation({
onError: (err) => {
toast.error(err.message)
if (err.data?.code === 'UNAUTHORIZED') {
redirectToLogin()
}
}
})
5. 从开发到生产的最佳实践
5.1 安全防护措施
虽然我们使用publicProcedure作为示例,实际项目应该:
- 定义认证中间件
typescript复制const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' })
return next({
ctx: { user: ctx.user } // 新的上下文类型
})
})
export const protectedProcedure = t.procedure.use(isAuthed)
- 在路由中使用
typescript复制export const userRouter = router({
profile: protectedProcedure.query(({ ctx }) => {
// ctx.user现在被类型系统保证存在
return ctx.prisma.user.findUnique({
where: { id: ctx.user.id }
})
})
})
5.2 性能监控与优化
添加性能追踪:
typescript复制// /server/middlewares/performance.ts
const perfMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now()
const result = await next()
const duration = Date.now() - start
metrics.timing(`trpc.${path}.${type}`, duration)
return result
})
export const tracedProcedure = t.procedure.use(perfMiddleware)
5.3 渐进式迁移策略
对于已有项目,可以:
- 从新功能开始采用tRPC
- 通过类型导出保持与旧API的兼容
typescript复制// 包装旧API
export const legacyRouter = router({
oldEndpoint: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const data = await fetchLegacyAPI(`/old?${qs.stringify(input)}`)
return transformToNewType(data)
})
})
- 逐步替换旧端点
6. 常见问题与解决方案
6.1 循环类型依赖问题
当类型A引用B,B又引用A时,解决方案:
- 使用
type而非interface(类型别名更宽松) - 延迟类型解析
typescript复制type UserWithPosts = Awaited<ReturnType<typeof userRouter.byId.query>>
6.2 大类型导致的性能问题
如果类型推断变慢:
- 使用
@trpc/server/dist/shared中的工具类型 - 拆分大型路由为多个子路由
- 在
tsconfig.json中启用"skipLibCheck": true
6.3 与前端状态管理集成
与Zustand/Jotai配合示例:
typescript复制const useTodoStore = create((set) => ({
todos: [],
fetchTodos: async () => {
const res = await trpc.todo.list.query()
set({ todos: res })
}
}))
// 在组件中使用
function TodoView() {
const { todos, fetchTodos } = useTodoStore()
useEffect(() => { fetchTodos() }, [])
// ...
}
7. 测试策略与类型安全
7.1 单元测试中的类型模拟
使用@trpc/server的测试工具:
typescript复制import { createCaller } from '@/server/trpc'
import { prismaMock } from '@/tests/mocks'
test('should add todo', async () => {
prismaMock.todo.create.mockResolvedValue({
id: '1',
text: 'Test',
completed: false
})
const caller = createCaller({ prisma: prismaMock })
const result = await caller.todo.add({ text: 'Test' })
expect(result.text).toBe('Test')
expect(prismaMock.todo.create).toHaveBeenCalled()
})
7.2 端到端类型测试
使用tsd进行类型测试:
typescript复制import { expectType } from 'tsd'
import { AppRouter } from '@/server/trpc'
expectType<AppRouter['todo']['list']>({
query: () => Promise.resolve([{
id: '1',
text: 'test',
completed: false
}])
})
7.3 负载测试与类型验证
使用zod的.strict()模式防止多余字段:
typescript复制const TodoInput = z.object({
text: z.string()
}).strict()
8. 项目结构建议
推荐的生产级目录结构:
code复制/src
/app # Next.js页面路由
/api
/trpc
[trpc]
route.ts
/components # 共享UI组件
/lib
/types # 共享类型定义
/server
/context # 数据库连接等
/middlewares
/routers # tRPC路由定义
/trpc.ts # tRPC初始化配置
/styles
/utils
trpc.ts # 前端TRPC客户端
每个路由文件应保持单一职责原则:
typescript复制// /server/routers/user.ts
export const userRouter = router({
profile: procedure.query(...),
update: procedure.input(...).mutation(...)
})
// /server/routers/index.ts
export const appRouter = router({
user: userRouter,
post: postRouter,
todo: todoRouter
})
