普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月14日首页

“受控组件”的诅咒:为什么你需要 React Hook Form + Zod 来拯救你的键盘?

2025年12月14日 17:46

前言:你的手指累吗?

上一篇咱们聊了把 Tabs 状态放进 URL 里,这就好比给了你的应用一个“身份证”,走到哪都在。

但在这个 Tab 下面,往往藏着前端开发最大的噩梦——表单

你是不是还在这么写代码? 一个简单的注册页,你定义了 username, password, email, confirmPassword 四个 useState。 然后写了四个 handleXxxChange 函数。 每次用户敲一个键盘,React 就重新渲染一次组件(Re-render)。 如果表单有 50 项,你在第一个输入框打字,整个页面卡得像是在放 PPT。

还有那个该死的校验逻辑:

if (!email.includes('@')) { ... }
if (password.length < 8) { ... }
if (password !== confirmPassword) { ... }

这些 if-else 像面条一样缠绕在你的组件里。写到最后,你都不知道自己是在写 UI 还是在写逻辑判断。

兄弟,放下你手里的 useState。是时候引入 React Hook FormZod 这对“黄金搭档”了。

观念重塑:受控 vs 非受控

React 官方文档早期教我们要用“受控组件”(Controlled Components),也就是 value 绑定 state,onChange 更新 state。

但这在复杂表单场景下,简直是性能杀手

React Hook Form (RHF) 的核心哲学是回归 HTML 的本质:非受控组件(Uncontrolled Components) 。 它利用 ref 直接接管原生 DOM 元素。

  • 你打字时,React 渲染。
  • 只有校验报错或提交时,React 才介入。

这就好比,以前你是傀儡师,手要把着每一个木偶的关节动(受控);现在你给了木偶一个指令“往前走”(非受控),它自己就走了,你只管终点。

实战演练:从 50 行代码缩减到 10 行

假设我们要写一个带有校验的用户表单。

❌ 痛苦的传统写法(useState):

  const [values, setValues] = useState({ name: '', age: 0 });
  const [errors, setErrors] = useState({});

  const handleChange = (e) => {
    // 1. 更新状态(触发重渲染)
    setValues({ ...values, [e.target.name]: e.target.value });
    // 2. 还要在这里手写校验逻辑,或者等提交时校验
    // ...逻辑省略,已经想吐了
  };

  return (
    <form>
      <input name="name" value={values.name} onChange={handleChange} />
      <input name="age" value={values.age} onChange={handleChange} />
    </form>
  );
};

✅ 爽翻天的 RHF 写法:


const BetterForm = () => {
  // register: 注册器,相当于 ref + onChange 的语法糖
  // handleSubmit: 帮你处理 `e.preventDefault` 和数据收集
  // formState: 所有的错误状态都在这
  const { register, handleSubmit, formState: { errors } } = useForm();

  const onSubmit = (data) => console.log(data); // data 直接就是 {name:..., age:...}

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* 也就是一句话的事 */}
      <input {...register("name", { required: true })} />
      {errors.name && <span>名字必填</span>}
      
      <input {...register("age")} />
      
      <button type="submit">提交</button>
    </form>
  );
};

看到没有?没有 useState,没有 handleChangeregister 函数给你的 Input 注入了原本需要手写的 name, onBlur, onChange, ref。一切都在黑盒里自动完成了。

灵魂伴侣:Zod 登场

RHF 解决了数据收集和性能问题,但校验规则如果写在 JSX 里(比如 { required: true, minLength: 10 }),还是很乱。

这时候,你需要 Zod。 Zod 是一个 TypeScript 优先的 Schema 声明库。简单说,就是把校验逻辑从 UI 里抽离出来,变成一份“说明书”

1. 定义说明书 (Schema)


const schema = z.object({
  username: z.string().min(2, "名字太短了,再长点"),
  email: z.string().email("这根本不是邮箱"),
  age: z.number().min(18, "未成年人请绕道").max(100),
  // 甚至可以做复杂的依赖校验
  password: z.string().min(6),
  confirm: z.string()
}).refine((data) => data.password === data.confirm, {
  message: "两次密码不对啊兄弟",
  path: ["confirm"], // 错误显示在 confirm 字段下
});

2. 连接 RHF 和 Zod

我们需要一个“中间人”:@hookform/resolvers

import { zodResolver } from '@hookform/resolvers/zod';

const BestForm = () => {
  const { 
    register, 
    handleSubmit, 
    formState: { errors, isDirty } // isDirty 很有用!
  } = useForm({
    resolver: zodResolver(schema) // ✨ 注入灵魂
  });

  return (
    <form onSubmit={handleSubmit(saveData)}>
      <input {...register("username")} />
      <p className="text-red-500">{errors.username?.message}</p>
      
      {/* ...其他字段... */}
      
      <button type="submit">提交</button>
    </form>
  );
};

现在,你的 JSX 极其干净。所有的校验逻辑都在 schema 对象里。想改规则?去改 schema 就行,不用动组件。

回应开头:防止“手滑关网页”

还记得前言里说的那个悲剧吗?用户填了一半,手滑关了 Tab。

RHF 提供了一个神属性:isDirty(表单是否被弄脏了/是否被修改过)。

配合 React Router 的 useBlocker (v6.x) 或者传统的 window.onbeforeunload,我们可以轻松实现拦截


// 一个简单的拦截 Hook
const usePreventLeave = (isDirty) => {
  useEffect(() => {
    const handleBeforeUnload = (e) => {
      if (isDirty) {
        e.preventDefault();
        // 现代浏览器通常不支持自定义文本,但这会触发浏览器的默认弹窗
        e.returnValue = ''; 
      }
    };
    
    window.addEventListener('beforeunload', handleBeforeUnload);
    return () => window.removeEventListener('beforeunload', handleBeforeUnload);
  }, [isDirty]);
};

// 在组件里使用
const MyForm = () => {
  const { formState: { isDirty } } = useForm();
  
  // 只要用户改了一个字,isDirty 就变成 true,防守模式开启
  usePreventLeave(isDirty); 
  
  return <form>...</form>;
}

现在,只要用户动了表单,试图关闭或刷新页面时,浏览器就会弹出一个无情的警告框:“您有未保存的更改,确定要离开吗?”。 PM 再也不用担心用户投诉数据丢了。


总结

React Hook Form + Zod 是现代 React 开发的“工业标准”。

  • RHF 负责 DOM 交互和性能优化,让你的页面丝滑顺畅。
  • Zod 负责数据结构和逻辑校验,保证你的数据干净可靠。
  • TypeScript 自动推导类型,让你写代码有自动补全。

当你熟练掌握这一套组合拳,你会发现写表单不再是折磨,甚至有一种解压的快感。

好了,我要去把那个嵌套了 10 层 if-else 校验的屎山给重构了。

438783fa67714fda959e8e52adb9245d.webp


下期预告:表单数据量如果不止 50 项,而是 10,000 项呢? 比如一个超长的“用户管理列表”,或者一个即时滚动的“日志监控台”。如果你把这 10,000 个 div 直接渲染出来,浏览器会直接死机。 下一篇,我们来聊聊 “虚拟滚动 (Virtual Scrolling) 技术” 。教你如何用 react-window 欺骗用户的眼睛,让无限列表像德芙一样纵享丝滑。

昨天以前首页

你的组件 API 为什么像个垃圾场?—— React 复合组件模式 (Compound Components) 实战教学

2025年12月11日 09:34

前言:一种名为“配置地狱”的组件

接上回。咱们用 React Query 把服务端状态剥离了,用 Context 把全局状态理顺了。现在你的数据流很干净。

但是,当你打开 components 文件夹,看着那个被你改了无数次的 Tabs 组件,是不是又想骂人了?

为了满足产品经理五彩斑斓的需求,你的组件 props 越加越多,最后变成了这样:

// ❌ 典型的“配置型”组件
 }, { title: '设置', content:  }]}
  activeTab={currentTab}
  onTabChange={setCurrentTab}
  tabBarClassName=&#34;bg-gray-100&#34; // 想改 Tab 栏背景?加个 prop
  tabItemClassName=&#34;text-lg&#34;    // 想改文字大小?加个 prop
  activeTabClassName=&#34;text-blue&#34; // 想改选中态颜色?再加个 prop
  renderTabBarExtraContent={新建} // 想在右边加个按钮?又要加 prop
  tabPosition=&#34;top&#34; // 想把 Tab 放左边?还得加逻辑
/>

这就叫**“配置地狱”**。 你试图通过 props 暴露出所有的 UI 细节,结果就是这个组件变得巨臃肿,且极难复用。如果我想给第二个 Tab 加个红点(Badge),你是不是还得给 items 数组的数据结构里加个字段?

src=http___image109.360doc.com_DownloadImg_2023_04_1510_264406834_10_20230415101010334.gif&refer=http___image109.360doc.gif 兄弟,别再折磨自己了。今天我们来学学 Compound Components(复合组件模式) 。看看人家 HTML 原生标签是怎么教我们做人的。

灵感来源:向 `` 致敬

你仔细想想,原生的 `` 标签是怎么用的?

  苹果
  香蕉

你并没有传一个 options 数组给 ,而是直接把 塞到了 `` 里面。

  • `` 负责管理状态(当前选了谁)。
  • 负责渲染每一项,并且告诉 “我被点了”。

这种**“父组件管状态,子组件管渲染,通过隐式契约通信”**的模式,就是复合组件模式。


实战重构:把 Tabs 拆开

我们要把那个臃肿的 Tabs 组件,拆成 Tabs, TabList, Tab, TabPanels, Panel 这一套乐高积木。

第一步:创建上下文 (Context)

父组件需要一个地方来告诉子组件:现在的 activeTab 是谁,以及提供一个 setActiveTab 的方法。


const TabsContext = createContext(null);

// 这是一个自定义 Hook,方便子组件拿数据,顺便做个错误检查
const useTabs = () => {
  const context = useContext(TabsContext);
  if (!context) throw new Error('Tabs 子组件必须包裹在  里面!');
  return context;
};

第二步:父组件 (Tabs) —— 状态的大管家

它不负责画 UI,只负责提供 Context。

  const [selectedIndex, setSelectedIndex] = useState(defaultIndex);

  return (
    
      <div>{children}</div>
    
  );
};

第三步:子组件 (Tab & Panel) —— 真正的打工人

Tab 按钮:

  const { selectedIndex, setSelectedIndex } = useTabs();
  const isActive = selectedIndex === index;

  return (
     setSelectedIndex(index)}
    >
      {children}
    
  );
};

Panel 内容区:

  const { selectedIndex } = useTabs();
  // 只有选中时才渲染
  return selectedIndex === index ? <div>{children}</div> : null;
};

见证奇迹的时刻:调用方式

重构完之后,我们在页面里怎么用呢?


  {/* 你可以在这里随便加 div,随便写样式,完全不受 props 限制 */}
  <div>
    <div>
      用户管理
      
      {/* 居然可以给单独某一个 Tab 加红点,甚至加 Tooltip,随你便! */}
      
        系统设置 <span>●</span>
      
    </div>

    {/* 想在右边加个按钮?直接写啊!不用传什么 renderExtraContent */}
    刷新
  </div>

  <div>
    
    
  </div>

对比一下之前的代码,现在的优势在哪里?

  1. UI 结构完全解耦:你想把 Tab 列表放在下面?想把 Panel 放在上面?随便你怎么排版 HTML,组件逻辑完全不需要改。
  2. 内容随心所欲:你想在 Tab 标题里加图标?加红点?加 loading 动画?直接在 children 里写 JSX 也就是了,不需要去改组件源码。
  3. 没有 Props Drilling:状态通过 Context 隐式传递,你不用手动把 activeTab 传给每一个 Tab。

进阶技巧:这就是 Headless UI 的雏形

聪明的你可能发现了,这种模式其实就是我在前几篇提到的 Headless UI 的一种实现方式。

像著名的 UI 库 Radix UI 或者 Headless UI (Tailwind) ,全是这个路子。

  • ``
  • ``
  • ``
  • ``

它们把组件拆得稀碎,把**“怎么组合”**的权力交还给了你。

当然,这种模式也有个小缺点:代码量变多了。 以前写个 `` 只要一行,现在要写十几行。

怎么解? 你可以基于这个复合组件,再封装一层“傻瓜式”组件给没特殊需求的场景用。但是底层的实现,一定要保持这种灵活性。


总结

当你发现你的组件需要接受 xxxStyle, xxxClassName, renderXxx 这种 props 的时候,请立刻停下来。

这说明你在试图控制你控制不了的事情(外部的 UI 展示)。

把控制权交出去。用 Compound Components 模式,让使用者像拼乐高一样组装你的组件。 你会发现,你再也不用因为设计稿改了一个 margin 或者加了一个 icon 而去改组件源码了。这才是真正的高内聚、低耦合

好了,我要去把那个传了 20 个 props 的 Modal 组件给拆了,祝大家的组件 API 永远性感。

lg_90841_1619336946_60851ef204362.png


下期预告:Tabs 切换是搞定了,但有个问题:用户刷新页面后,又回到了第一个 Tab,辛辛苦苦填的表单也没了。 还有,我想把“当前在第二个 Tab”这个状态分享给同事,怎么做? 下一篇,我们来聊聊 “URL 即状态 (URL as State)” 。教你如何把 React 状态同步到 URL 参数里,让你的应用拥有“记忆”。

❌
❌