React 驱动的房产管理系统:利用 tRPC 实现复杂表单校验逻辑在前后端的逻辑共用

前端架构的艺术:用 tRPC 和 React 打造“坚不可摧”的房产管理系统

各位下午好!或者说,早安!

我是你们今天的讲师,一位在这个行业里跟 npm installTypeScript 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(或者路由器)环境下搭建好了项目。

我们的核心阵容是:

  1. tRPC:负责通信和类型推断。
  2. Zod:负责数据校验和 Schema 定义。
  3. 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。


第七章:常见陷阱与幽默吐槽

在开发过程中,我们不可避免地会遇到一些坑。让我们来聊聊那些让人抓狂的瞬间。

  1. Zod 的 refine 返回类型
    有时候,你写了一个复杂的 refine,它依赖于很多字段。但是,如果你在 refine 里返回了一个新的对象,可能会和父 Schema 冲突。记住,refine 主要用于校验,如果要转换数据,用 transform。不要试图把 refine 当作数据处理器。

  2. Schema 变更的“多米诺骨牌效应”
    当你修改了 Zod Schema(比如把 price 的校验从 positive() 改成了 min(1000)),前端表单会立即给出新的提示。但是,如果后端数据库里已经存在 price = 500 的旧数据,当你运行迁移脚本时,可能会报错。这是因为后端的数据库约束和前端的 Zod 定义不一致了。这就是为什么我们要时刻保持两者的同步。

  3. z.infer 的“泛型地狱”
    在使用复杂的嵌套对象时,z.infer 有时会让 TypeScript 的类型提示变得很难看。不要怕,把类型提取出来。就像我们做的那样,export type ListingInput = z.infer<typeof ListingSchema>;。把它当成是一个你定义好的工具类,不要让 TypeScript 的泛型把你的代码搞乱。

  4. 异步校验的 UX
    当你在前端写了异步校验(比如检查地址是否被占用),如果校验时间超过 1 秒,用户可能会以为表单卡死了。加上一个 isLoading 的状态,告诉用户“正在检查地址…”,这能极大提升用户体验。就像你在相亲时,对方回消息慢了,你不会觉得他在出轨,只会觉得他在查户口。


第八章:总结——告别混乱,拥抱秩序

回顾一下,我们在这个讲座中做了什么?

  1. 我们定义了一个复杂的房产对象模型(Zod Schema)。
  2. 我们在前端利用 react-hook-formzodResolver 构建了一个健壮的表单 UI。
  3. 我们利用 Zod 的 refine 方法实现了复杂的业务逻辑校验。
  4. 我们在后端直接复用了同一个 Schema,确保了数据的一致性。
  5. 我们结合 React Context 实现了表单状态的全局管理。

通过这种方式,我们消除了前后端校验逻辑不一致的痛苦。我们不再需要维护两套校验规则,不再需要手动同步类型定义。

这不仅仅是一个技术选择,这是一种工程哲学。它告诉我们,代码应该是优雅的,逻辑应该是统一的,错误应该是被捕获的。

当你下次打开你的 IDE,看到 Zod 的 Schema 和 TypeScript 的类型完美同步,看到表单校验在浏览器里流畅运行,而后端数据库只接收到完美的数据时,你会感到一种难以言喻的满足感。那感觉,就像是终于找到了丢了的另一只袜子。

最后,记住一句话:好的表单校验不是关于限制用户,而是关于引导用户正确地做事。 而 tRPC 和 Zod,就是那个最好的引导员。

好了,今天的讲座就到这里。现在,去把你的项目重构一下吧!祝你编码愉快,表单永不过期!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注