前端架构的艺术:用 tRPC 和 React 打造“坚不可摧”的房产管理系统
各位下午好!或者说,早安!
我是你们今天的讲师,一位在这个行业里跟 npm install 和 TypeScript compiler 打过无数次交道的老油条。今天,我们不谈什么虚无缥缈的微服务架构,也不聊那些在大厂PPT里才会出现的“云原生黑科技”。
今天我们要聊的是一件非常“接地气”,但又是每一位前端工程师每天早上起床第一件事——就是想把它扔出窗外——的事情:表单校验。
特别是,当我们面对一个房产管理系统时。想象一下,你正在录入一套房源:面积、户型、价格、楼层、是否允许养宠、以及那个该死的“装修状态”。如果你的表单校验逻辑写在 JavaScript 里,后端又在 Python(或者 Java/C#)里写了一套,当那个后端大牛吼一声“这数据校验不对”的时候,你的心情是怎样的?是不是就像是在双十一买到的“预售商品”,明明说好今天发货,结果告诉你还没开始生产?
今天,我们将要一起构建一个 React 驱动的房产管理系统,并且我们将使用一个叫做 tRPC 的神器,加上 Zod 这个年轻力壮的小伙子,来实现前后端逻辑共用。我们将把那个该死的“复制粘贴”变成“复制粘贴即死罪”。
准备好了吗?系好安全带,我们要起飞了。
第一章:表单校验的“西西弗斯”神话
在开始代码之前,我们必须先建立一种“仪式感”。在很多传统的项目中,表单校验就像是西西弗斯推石头上山。你写了一套校验逻辑在前端,觉得“完美无缺”,于是提交给后端。后端一看:“嘿,兄弟,这里没校验啊!”于是你不得不哭着把代码翻出来,加一个 if (price < 0) return error,然后又翻到前端,加一个 if (price < 0) return error。你就像个磨洋工的工人,做着重复的劳动。
更糟糕的是什么?前端和后端的类型定义(Type Definition)往往是不一致的。前端说价格是 number,后端说价格是 string。于是,当你满心欢喜地把表单提交上去,后端抛出一个 500 Internal Server Error,或者一个让你措手不及的 NaN(Not a Number)。
我们不想再过这种日子了。我们想要的是真理。在 TypeScript 的世界里,真理只有一个。我们不仅要保护我们的 UI,我们还要保护我们的 API。
这就是 tRPC 登场的地方。tRPC 让我们不需要写 API 约定,不需要写 OpenAPI 文档,甚至不需要写序列化代码。它就像是给 React 组件和后端数据库之间架起了一座全封闭的、带TypeScript类型检查的“高速公路”。
第二章:搭建舞台(Next.js + tRPC + Zod)
首先,我们要确保我们有正确的工具。假设我们已经在 Next.js 的 App Router(或者路由器)环境下搭建好了项目。
我们的核心阵容是:
- tRPC:负责通信和类型推断。
- Zod:负责数据校验和 Schema 定义。
- React Hook Form:负责 UI 层的表单交互。
让我们先来看看,我们的真理之源——Zod Schema。在 Zod 里,我们可以定义一个 z.object,这就像是我们定义了一个房产的“宪法”。
// lib/types.ts
import { z } from "zod";
// 定义户型枚举,这里我们像是在玩模拟经营游戏
export const UnitTypeSchema = z.enum([
"studio", // 开放式
"one_bedroom", // 一室
"two_bedroom", // 两室
"three_bedroom",// 三室
"penthouse" // 豪宅
]);
// 定义房源类型
export const ListingSchema = z.object({
id: z.string().cuid().optional(), // ID 可选,因为是新增
title: z.string()
.min(5, "标题至少需要5个字!")
.max(100, "标题太长了,房地产经纪人不会喜欢读这么长的标题的。")
.describe("房源标题"),
price: z.number()
.positive("价格必须是正数!你不能付钱给房东让他把你赶出去。")
.min(0, "这房子是不要钱送的吗?"),
area: z.number()
.min(10, "连厕所都没有的房子能叫房产吗?至少10平米起步。")
.max(1000, "这已经是皇宫了吧,这系统撑不住这么大的数据。"),
unitType: UnitTypeSchema.describe("户型类型"),
address: z.object({
street: z.string().min(5),
city: z.string().min(2),
zipCode: z.string().regex(/^d{5}$/, "邮编格式错误,请检查你的邮政编码常识。")
}),
amenities: z.array(z.string()).default([]), // 默认为空数组
isPetFriendly: z.boolean().default(false)
});
// 好戏来了!这行代码是魔法
// 这是我们把 Zod Schema 转换为 TypeScript 类型的地方
export type ListingInput = z.infer<typeof ListingSchema>;
看这行代码 export type ListingInput = z.infer<typeof ListingSchema>;。这是我们要用到的第一把利剑。这意味着,我们在前端定义了 Schema,TypeScript 就会自动生成一个类型。我们把这个类型传给 tRPC,tRPC 就会自动生成后端的类型。前后端就达成了“统一战线”。
第三章:前端大戏(React + React Hook Form)
现在,让我们把镜头切到前端 UI。我们需要一个看起来很酷,但内部逻辑非常严谨的表单。
我们使用 react-hook-form 来处理表单状态,用 zodResolver 来把我们的 Zod Schema 引入表单。这就像是请了一个专业的保安,他在你提交数据之前,会拿着我们的 Zod Schema 把所有数据过一遍筛子。
// components/ListingForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { ListingSchema, ListingInput } from "@/lib/types";
import { trpc } from "@/utils/trpc"; // 假设我们配置了 tRPC 客户端
export const ListingForm = () => {
const { register, handleSubmit, formState: { errors } } = useForm<ListingInput>({
resolver: zodResolver(ListingSchema), // 关键一步:用 Zod 做 resolver
});
// 这里的 mutation 会在表单验证通过后触发
const createListing = trpc.listing.create.useMutation({
onSuccess: () => {
alert("房源发布成功!你的客户会尖叫的。");
},
onError: (err) => {
console.error("发布失败:", err);
alert("哎呀,出错了。可能是后端在睡午觉,或者你填的数据太离谱了。");
}
});
const onSubmit = async (data: ListingInput) => {
// 在这里,data 已经被 Zod 校验过了,它是绝对安全的
// 我们可以直接调用 tRPC mutation
await createListing.mutateAsync(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="p-6 border rounded shadow-lg max-w-2xl mx-auto bg-white">
<h2 className="text-2xl font-bold mb-6 text-gray-800">录入新房源</h2>
{/* 标题输入 */}
<div className="mb-4">
<label className="block text-gray-700 font-bold mb-2">标题</label>
<input
{...register("title")}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
/>
{errors.title && (
<p className="text-red-500 text-xs italic">{errors.title.message}</p>
)}
</div>
{/* 价格输入 */}
<div className="mb-4">
<label className="block text-gray-700 font-bold mb-2">价格 (万元)</label>
<input
type="number"
{...register("price")}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
/>
{errors.price && (
<p className="text-red-500 text-xs italic">{errors.price.message}</p>
)}
</div>
{/* 面积输入 */}
<div className="mb-4">
<label className="block text-gray-700 font-bold mb-2">面积 (平米)</label>
<input
type="number"
{...register("area")}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
/>
{errors.area && (
<p className="text-red-500 text-xs italic">{errors.area.message}</p>
)}
</div>
{/* 户型选择 */}
<div className="mb-4">
<label className="block text-gray-700 font-bold mb-2">户型</label>
<select {...register("unitType")} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
<option value="studio">Studio (单身公寓)</option>
<option value="one_bedroom">一室一厅</option>
<option value="two_bedroom">两室一厅</option>
<option value="three_bedroom">三室两厅</option>
<option value="penthouse">豪华复式</option>
</select>
{errors.unitType && (
<p className="text-red-500 text-xs italic">{errors.unitType.message}</p>
)}
</div>
{/* 宠物友好开关 */}
<div className="mb-6 flex items-center">
<input {...register("isPetFriendly")} type="checkbox" className="mr-2 leading-tight" />
<label className="text-gray-700 text-sm">允许养宠物</label>
</div>
<button
type="submit"
disabled={createListing.isLoading}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline w-full"
>
{createListing.isLoading ? "正在发布中..." : "发布房源"}
</button>
</form>
);
};
看,这段代码多么干净!没有手动的 if (data.price < 0)。Zod 的 zodResolver 会自动解析 errors 对象。所有的类型提示都是现成的。当你把鼠标悬停在 data 上时,你的编辑器会给你指路:这里有 title,这里有 price,这里还有 address。
但是,这只是基础。我们的房产管理系统怎么能没有“复杂逻辑”呢?
第四章:复杂逻辑的统一(Refine 与 Transform)
Zod 不仅仅能做简单的格式校验。它有一个叫做 refine 的方法。这是它的杀手锏。refine 允许你定义自定义的校验逻辑。最棒的是什么?这个逻辑既可以在前端用,也可以在后端用!
假设我们的房产公司有一个奇葩的规定:如果这套房是“豪华复式(penthouse)”或者“面积超过 300 平米”,那么它必须是“允许养宠物”的。
这听起来像是一个合理的要求吗?可能不是。但为了演示,我们就这么干。
前端的 refine
我们在 Zod Schema 里加一行:
// lib/types.ts
const ListingSchema = z.object({
// ... 之前的字段
unitType: UnitTypeSchema,
area: z.number().min(10).max(1000),
isPetFriendly: z.boolean().default(false),
// 复杂逻辑
}).refine(
(data) => {
// 逻辑:如果户型是 penthouse 或者面积 > 300,isPetFriendly 必须为 true
if (data.unitType === "penthouse" || data.area > 300) {
return data.isPetFriendly === true;
}
return true;
},
{
path: ["isPetFriendly"], // 错误信息显示在这个字段上
message: "豪华户型或大面积房产必须允许养宠物!这是硬性规定!"
}
);
现在,当你在前端修改输入框时,只要你不勾选“允许养宠物”并选择了豪华户型或大面积,表单校验就会立即触发,红色的错误提示会像红绿灯一样闪烁。
后端的 refine
现在,最神奇的时刻来了。让我们看看后端的 tRPC Procedure 是怎么写的。我们不需要重写一遍逻辑!
// server/routers/listing.ts
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "../trpc";
import { ListingSchema } from "@/lib/types"; // 我们引入了刚才定义的同一个 Schema!
export const listingRouter = createTRPCRouter({
create: publicProcedure
.input(ListingSchema) // 关键:我们直接把 Schema 作为 input
.mutation(async ({ input }) => {
// 此时,input 已经是经过了 Zod 所有校验(包括 refine)的数据了
// 它是绝对安全的!
// 我们可以假设数据库连接已经配置好了
// await prisma.listing.create({ data: input });
console.log("正在保存房源:", input);
// 模拟数据库操作
return {
success: true,
listingId: "list_12345",
message: "房源已成功保存到后端数据库!"
};
}),
});
等等,你没看错! 在后端,我们甚至不需要写 if (input.isPetFriendly ...)。Zod 的校验逻辑在数据进入 Procedure 之前就已经执行了。如果数据通过了 Zod 的检查,它就已经是“完美数据”了。
这意味着什么?意味着前端的用户看到错误提示,后端的数据库接收到的就是干净的数据。前后端逻辑100% 共用。
第五章:进阶技巧——异步校验
有时候,校验逻辑需要依赖外部数据。比如,我们要检查用户输入的地址在数据库里是否已经被占用了。
Zod 的异步能力
Zod 也是支持异步的。我们可以用 z.preprocess 或者直接在 refine 里写 async 函数。
// lib/types.ts
const ListingSchema = z.object({
// ...
address: z.object({
street: z.string(),
city: z.string(),
zipCode: z.string()
}),
// ...
}).refine(async (data) => {
// 假设我们有一个 API 可以检查地址是否被占用
const isOccupied = await checkAddressAvailability(data.address.street, data.address.city);
if (isOccupied) {
return false; // 失败
}
return true; // 成功
}, {
path: ["address"],
message: "哎呀,这个地址的房源已经被录入系统了,别想重复注册!"
});
前端如何处理?
前端会自动处理这个异步校验。当用户提交表单时,react-hook-form 会等待 zodResolver 完成所有的异步检查。如果失败,它会显示错误信息。
后端如何处理?
后端直接调用 Procedure。Zod 已经在校验,不需要额外的异步检查(除非业务逻辑要求在保存前再次校验,但我们已经有了最严格的 Zod 校验了)。
这就是“单一真相来源”的威力。如果你以后想改校验逻辑,你只需要改 lib/types.ts 这一个文件。前端会自动更新提示,后端会自动拒绝坏数据。
第六章:React Context 与表单状态管理
有时候,我们需要一个全局的状态来管理表单,比如用户正在编辑哪个房源,或者表单是否处于“脏数据”状态。我们可以结合 React Context 和 tRPC。
// context/FormContext.tsx
import React, { createContext, useContext, useState } from "react";
import { ListingInput } from "@/lib/types";
type FormContextType = {
listing: ListingInput | null;
setListing: (listing: ListingInput) => void;
resetListing: () => void;
};
const FormContext = createContext<FormContextType | undefined>(undefined);
export const FormProvider = ({ children }: { children: React.ReactNode }) => {
const [listing, setListing] = useState<ListingInput | null>(null);
return (
<FormContext.Provider value={{ listing, setListing, resetListing: () => setListing(null) }}>
{children}
</FormContext.Provider>
);
};
export const useFormContext = () => {
const context = useContext(FormContext);
if (!context) throw new Error("useFormContext must be used within FormProvider");
return context;
};
现在,我们的 ListingForm 组件可以订阅这个 Context。
// components/ListingForm.tsx
export const ListingForm = () => {
// ... hook form 的配置
const { listing, setListing } = useFormContext();
// 初始化表单数据
React.useEffect(() => {
if (listing) {
// 将 listing 数据填入表单
reset(listing);
}
}, [listing]);
const onSubmit = async (data: ListingInput) => {
// 调用 API
await createListing.mutateAsync(data);
};
// ... JSX
};
这样,我们就创建了一个完整的闭环:数据从 Context 流入,经过 Zod 的洗礼,经过 tRPC 的传输,最后存储在数据库中,并在下一次编辑时重新流回 Context。
第七章:常见陷阱与幽默吐槽
在开发过程中,我们不可避免地会遇到一些坑。让我们来聊聊那些让人抓狂的瞬间。
-
Zod 的
refine返回类型
有时候,你写了一个复杂的refine,它依赖于很多字段。但是,如果你在refine里返回了一个新的对象,可能会和父 Schema 冲突。记住,refine主要用于校验,如果要转换数据,用transform。不要试图把refine当作数据处理器。 -
Schema 变更的“多米诺骨牌效应”
当你修改了 Zod Schema(比如把price的校验从positive()改成了min(1000)),前端表单会立即给出新的提示。但是,如果后端数据库里已经存在price = 500的旧数据,当你运行迁移脚本时,可能会报错。这是因为后端的数据库约束和前端的 Zod 定义不一致了。这就是为什么我们要时刻保持两者的同步。 -
z.infer的“泛型地狱”
在使用复杂的嵌套对象时,z.infer有时会让 TypeScript 的类型提示变得很难看。不要怕,把类型提取出来。就像我们做的那样,export type ListingInput = z.infer<typeof ListingSchema>;。把它当成是一个你定义好的工具类,不要让 TypeScript 的泛型把你的代码搞乱。 -
异步校验的 UX
当你在前端写了异步校验(比如检查地址是否被占用),如果校验时间超过 1 秒,用户可能会以为表单卡死了。加上一个isLoading的状态,告诉用户“正在检查地址…”,这能极大提升用户体验。就像你在相亲时,对方回消息慢了,你不会觉得他在出轨,只会觉得他在查户口。
第八章:总结——告别混乱,拥抱秩序
回顾一下,我们在这个讲座中做了什么?
- 我们定义了一个复杂的房产对象模型(Zod Schema)。
- 我们在前端利用
react-hook-form和zodResolver构建了一个健壮的表单 UI。 - 我们利用 Zod 的
refine方法实现了复杂的业务逻辑校验。 - 我们在后端直接复用了同一个 Schema,确保了数据的一致性。
- 我们结合 React Context 实现了表单状态的全局管理。
通过这种方式,我们消除了前后端校验逻辑不一致的痛苦。我们不再需要维护两套校验规则,不再需要手动同步类型定义。
这不仅仅是一个技术选择,这是一种工程哲学。它告诉我们,代码应该是优雅的,逻辑应该是统一的,错误应该是被捕获的。
当你下次打开你的 IDE,看到 Zod 的 Schema 和 TypeScript 的类型完美同步,看到表单校验在浏览器里流畅运行,而后端数据库只接收到完美的数据时,你会感到一种难以言喻的满足感。那感觉,就像是终于找到了丢了的另一只袜子。
最后,记住一句话:好的表单校验不是关于限制用户,而是关于引导用户正确地做事。 而 tRPC 和 Zod,就是那个最好的引导员。
好了,今天的讲座就到这里。现在,去把你的项目重构一下吧!祝你编码愉快,表单永不过期!