如果你接手过一个字段多一点的业务表单,应该遇到过这种尴尬:切换一个下拉选项,页面愣一下才反应过来。最开始我习惯把这归咎于“组件太多”“接口太慢”,直到有一次在订单录入表单里排查性能,才发现真正拖后腿的不是数据量,而是联动逻辑的写法。
表单联动在 React 世界里有很多种写法,核心矛盾集中在两个 API 上:useEffect和 AntdForm.Item的shouldUpdate。useEffect是 React 自带的副作用钩子,shouldUpdate则是 Antd 表单体系里控制字段级更新时机的开关。理解这两者的区别,不仅能回答不少 React 面试题里关于“状态管理与性能优化”的追问,更直接决定了你写出来的表单在字段变多后的真实表现。
这篇文章会从一次卡顿排查讲起,拆解 Antd 表单的更新机制,然后拿同一个联动场景做双路径实测,最后给出迁移时的注意点和取舍准则。
1. 为什么表单联动场景里 useEffect 不是首选方案?——从一次卡顿排查说起
1.1 一次真实的表单卡顿:现象与初步定位
之前我维护过一个销售订单录入页面,表单字段大概二十几个,里面有币种、金额、费率、折算价这一串联动。用户切换币种时要重置汇率,金额变化时要实时算折算价。最初的实现很直接:每个联动都用useEffect监听字段,再更新其他字段或本地 state。
在开发环境里一切正常,到了生产环境,低端机和数据量稍微大一点的客户浏览器上,卡顿就出现了。表现是:切换币种后,页面要顿一下才能输入下一个字段;快速修改金额时,数字在输入框里会跳动。
用 React DevTools 的 Profiler 录制了一次操作,发现切换一次币种,整个表单区域在几毫秒内被 commit 了两到三次。再往下看,参与重渲染的组件不仅是我写联动逻辑的那个父组件,还有大量订阅了表单上下文的Form.Item。
这里要补充一个背景知识:Antd 的Form通过 FormContext 向下分发FormInstance,字段值存在内部的 FormStore 里。任何一个字段变化,FormStore 都会通知订阅者。你的useEffect一旦在里面setFieldsValue或setState,就相当于在 React 的渲染循环之外又踢了一脚,整个表单上下文里的订阅者都得再醒一次。
1.2 useEffect 响应表单变化的两个隐藏成本
第一个成本是“第二波更新”。useEffect是在组件 commit 之后才执行的,它里面如果调用了setFieldsValue或setState,意味着 React 已经完成了一次提交,马上又有一个新的更新进入调度。表单联动里常见的是两个useEffect叠着写:一个监听币种重置汇率,一个监听金额重算折算价。每多一个useEffect,就可能多一轮 commit。
第二个成本是触发范围天然偏大。联动逻辑通常在拥有Form的页面组件里写,这里一个setState会把这个组件和它下面的整棵子树都重新渲染一遍。虽然普通Form.Item(有 name 的字段)内部有字段级判断,不会每个都真正 commit UI,但所有 Field 都会收到 FormStore 的通知,都要去执行一次“我这个字段的 value 有没有变”的判断。判断本身也有开销,字段越多越明显。
用个生活类比:useEffect像是“先把作业交上去了,再发现题目改了,又回来重写一遍”。而表单联动里很多需求并不是要“做某件异步的事”,只是要“根据这个字段的值算出另一个值”,这种派生型需求根本不需要等 commit 之后再做。
1.3 “渲染期声明式”与“提交后副作用”的本质差异
这里必须把两类需求分开:
- 派生型:B 字段的展示值、禁用状态、校验规则,是由 A 字段“算”出来的;
- 副作用型:A 变化后需要请求接口、写缓存、跳转、打点。
useEffect的定位是副作用型需求,而派生型需求的最佳位置是渲染期,即Form.Item的 render prop——通过shouldUpdate或dependencies让组件在渲染期直接读取 FormStore 里的最新值,算出新 UI 返回。
渲染期声明式的核心价值在于:它不引入新的 state,不产生“第二次渲染”,而是把“数据变化 -> 计算派生值 -> 渲染新 UI”这条链路压缩在同一个渲染周期内。字段越多,省下来的渲染轮数越明显。
2. 拆解 Antd Form.Item 的细粒度更新机制:VM 与订阅分发
2.1 rc-field-form 的 FormStore:状态从哪来,更新往哪里去
Antd 的 Form 底层是 rc-field-form。所有字段值保存在一个叫 FormStore 的内部对象中,Form.useForm()拿到的form实例,本质上就是这个 Store 的管理器。
每个带name的Form.Item在挂载时会把自己注册进 Store,Store 更新时再通知所有注册者。这个订阅分发机制是 rc-field-form 自己维护的,和 React 的 Context 不是一回事。普通Form.Item收到通知后,会根据本次变化是否涉及自己的name或dependencies,决定要不要更新。这套机制已经比“父组件 setState,全树重渲染”精确得多。
但业务代码里常见的问题是:很多人没有利用这层细粒度分发,而是在组件外部用useEffect写联动,再把结果塞回 form 或其他 state。这相当于把一条本来应该在内部管道里完成的路线,硬生生拽到 React 的渲染生命周期里绕一圈。
2.2 shouldUpdate 的真面目:是否重新执行 render prop 的阀门
Form.Item的shouldUpdate是控制“FormStore 变化后,这个 render prop 要不要重新执行”的开关。它通常配合 children 函数使用,也就是这种写法:
<Form.Item shouldUpdate={(prev, cur) => prev.currency !== cur.currency} noStyle> {({ getFieldValue }) => <div>{getFieldValue('currency')}</div>} </Form.Item>shouldUpdate有两种取值:
true:任意字段变化都重新执行 render prop,适合“整个表单状态决定某块展示区”的场景,比如合计金额面板、提交按钮的禁用状态;- 函数:
(prevValues, curValues) => boolean,返回true才重新执行,适合只关心特定字段的场景。
注意一个细节:render prop 重新执行只意味着重新调用 children 函数生成新的 React 元素,并不是把这段 DOM 卸载重挂。但你在函数里做的计算复杂度,会直接影响每次 store 变化时的开销。
2.3 dependencies 和 shouldUpdate 的分工
Form.Item 还有一个dependencies属性,它声明“当前字段或区域依赖哪些字段”。和shouldUpdate的区别在于:
dependencies的粒度是“依赖列表里的字段是否发生变化”,更适合字段间关系明确的场景;shouldUpdate的粒度是“由你来决定要不要更新”,更灵活,但也更容易写宽泛。
经常的组合是:无name的Form.Item加上dependencies,children 里用getFieldsValue取相关字段的值,渲染联动区域。这样只有依赖字段变化时才重新执行 render prop,性能上比shouldUpdate(true)更精准。
3. 同一场景双路径实测:useEffect vs shouldUpdate 的渲染链路对比
3.1 实测案例:币种切换带动汇率与换算结果更新
为了说清楚两条路径的差异,我做了个最小可复现场景。业务规则很简单:
- 选择结算币种;
- 输入金额;
- 根据币种映射得到汇率;
- 展示换算结果。
字段就是currency、amount、rate,另外需要展示一个换算结果。这个场景恰好可以同时用两种路径实现,而且足够看出渲染链路差异。
3.2 双路径实现在代码层面长什么样
路径 A:useEffect 版本
function CurrencyForm() { const [form] = Form.useForm(); const currency = Form.useWatch('currency', form); const amount = Form.useWatch('amount', form); const [rate, setRate] = useState(1); const [converted, setConverted] = useState(0); useEffect(() => { const nextRate = RATE_MAP[currency] || 1; setRate(nextRate); form.setFieldsValue({ rate: nextRate }); }, [currency, form]); useEffect(() => { setConverted((amount || 0) * rate); }, [amount, rate]); return ( <Form form={form}> <Form.Item name="currency" label="币种"> <Select options={CURRENCY_OPTIONS} /> </Form.Item> <Form.Item name="amount" label="金额"> <InputNumber /> </Form.Item> <Form.Item name="rate" label="汇率"> <InputNumber /> </Form.Item> <div>换算结果:{converted}</div> </Form> ); }这个版本已经暴露了问题:汇率既存在本地 state,又写回表单 store,两处都要更新。第一个useEffect里setRate触发一次组件更新,form.setFieldsValue又触发一次 FormStore 通知;第二个useEffect依赖rate和amount,又补一刀。一个用户操作,React 在 commit 之后又跑了两三轮更新。
路径 B:Form.Item dependencies + render prop 版本
function CurrencyForm() { const [form] = Form.useForm(); return ( <Form form={form} onValuesChange={(changed) => { if ('currency' in changed) { form.setFieldsValue({ rate: RATE_MAP[changed.currency] || 1 }); } }} > <Form.Item name="currency" label="币种"> <Select options={CURRENCY_OPTIONS} /> </Form.Item> <Form.Item name="amount" label="金额"> <InputNumber /> </Form.Item> <Form.Item name="rate" label="汇率"> <InputNumber /> </Form.Item> <Form.Item dependencies={['currency', 'amount']} noStyle> {({ getFieldsValue }) => { const all = getFieldsValue(); const rate = RATE_MAP[all.currency] || 1; return <div>换算结果:{(all.amount || 0) * rate}</div>; }} </Form.Item> </Form> ); }路径 B 没有新增任何 state,没有useEffect。汇率写回 store 的动作放在onValuesChange事件回调里,换算结果的展示放在渲染期由依赖驱动。用户操作币种时,事件回调里改 store,渲染期直接算结果,整个链路只走一次提交。
3.3 用 render 计数测量发生了什么
我建议你在自己项目里做同样的验证,方法很简单:在关键组件里加一个模块级的计数器,re-render 一次就加一,操作完看日志。
let renderCounter = { current: 0 }; function CurrencyForm() { // 每次渲染都执行 renderCounter.current++; console.log('CurrencyForm render:', renderCounter.current); // 省略其余代码 }也可以直接在 React DevTools Profiler 里看 commit 次数和火焰图。实测结果是:在字段数量约 20 个、操作一次切换币种时,路径 A 出现了 2 到 3 轮 commit,且所有订阅了 FormStore 的 Field 都要响应两次 store 通知;路径 B 基本是 1 轮 commit,只有依赖currency和amount的那块 render prop 重新计算。
两者对比可以列成一张表:
| 指标 | 路径 A(useEffect) | 路径 B(dependencies + render prop) |
|---|---|---|
| 额外 commit 轮次 | 2 轮以上 | 0 |
| 新增本地 state | rate、converted 等 | 无 |
| store 通知范围 | 所有 Field 都要响应 | 仅依赖字段相关区域 |
| 联动逻辑位置 | 分散在多个 effect 中 | 集中在事件回调 + 渲染区域 |
| 异步副作用能力 | 强 | 弱 |
| 适合场景 | 调接口、跳转、打点 | UI 派生、字段联动展示 |
注意这里的“所有 Field 都要响应”不是指所有 Field 都会重新渲染 DOM,而是说它们都要在通知里做一次“我的值变没变”的判断。在字段量很大的表单里,这种判断大量执行,本身就会拉长交互响应时间。
4. 把联动逻辑搬进 Form.Item:五个边界问题与迁移清单
4.1 能派生的 UI 就不要额外存 state
迁移第一步是识别“派生型联动”。典型的场景有:
- B 字段的值等于 A 字段乘以一个系数;
- B 字段的禁用状态取决于 A 字段的选中项;
- 合计、差额、折扣金额这类实时展示;
- 切换某个开关后,另一块区域的显隐。
这些需求在原来的代码里通常会写成“useEffect监听 A,然后setState一个计算结果”。正确做法是把这个计算结果放到渲染期去“算”,而不是“存”。getFieldsValue在 render prop 里就是干这个的——它从 FormStore 里读取所有字段的最新值,你的 JSX 直接用这些值计算即可。
4.2 写回 store 的联动,放到事件回调而不是 useEffect
如果联动结果还需要写回表单并一起提交,比如根据币种自动填汇率,那就需要真正调用setFieldsValue。很多人的第一反应是放进useEffect,但这正是多余 commit 的来源。
更好的位置是字段的事件回调或 Form 的onValuesChange。因为用户切换币种这个动作本身就在一个事件链路里,你在onValuesChange里同步写入其他字段,相当于在数据源头就把值修正了。渲染周期只发生一次,而不是 commit 之后再来一轮。
<Form form={form} onValuesChange={(changed) => { if ('currency' in changed) { form.setFieldsValue({ rate: RATE_MAP[changed.currency] || 1 }); } }} >要注意一个问题:onValuesChange里setFieldsValue修改 rate 后,如果修改后的 rate 又被判断为“字段变化”,可能再次触发onValuesChange。所以最好在写入前判断新值和旧值是否一致,或者只在changed的 key 集合里包含currency时才写。否则遇到某些循环依赖写法,会出现意料外的重复执行。
4.3 render prop 里不能写副作用:setState 与 setFieldsValue 是禁区
这是最容易踩的坑。Form.Item的 render prop 是在渲染阶段执行的函数,里面不能触发setState,不能无条件调用form.setFieldsValue。如果在渲染期触发 state 更新,React 会给出 “Cannot update a component while rendering a different component” 的警告,严重点就是无限循环。
正确做法是:render prop 里只读表单值、做计算、返回 JSX。所有“写入”动作都放到事件回调。这个边界守住了,基本就不会把联动写到死循环里去。
4.4 shouldUpdate 比较函数怎么写才不拖累性能
如果你用了shouldUpdate={(prev, cur) => boolean},比较函数的执行频率很高——FormStore 每一次通知变化,它都要跑一遍。所以比较函数必须写得轻量。
我在实践中的经验是:
- 优先用
dependencies,让框架内部判断字段是否变化,省掉自己写比较逻辑; - 如果确实需要
shouldUpdate函数,不要对整个 values 对象JSON.stringify。字段一多,字符串拼接的开销比字段比较本身还大; - 关注真正变化的字段集合,可以用
cur里与上一次不同的 key 来做判断,时间复杂度控制在与检查字段数相关即可。
比如只需要关心currency和amount是否变化:
<Form.Item shouldUpdate={(prev, cur) => prev.currency !== cur.currency || prev.amount !== cur.amount } noStyle > {({ getFieldsValue }) => { // 渲染联动区域 }} </Form.Item>4.5 字段显隐和区块联动:dependencies 优先于 shouldUpdate(true)
表单里常见“勾选某个开关后显示一整块额外区域”的需求。很多人会图省事写<Form.Item shouldUpdate>,也就是任一字段变化就重算这个区域。字段一多,这种“全量订阅”会让这个区块的 render prop 频繁执行,哪怕和它毫无关系的字段变了也要跟着算一遍。
更合适的做法是用dependencies把范围收敛到真正相关的字段:
<Form.Item dependencies={['needInvoice']} noStyle> {({ getFieldsValue }) => { const needInvoice = getFieldsValue().needInvoice; if (!needInvoice) return null; return <InvoiceInfoFields />; }} </Form.Item>只有needInvoice变化时,这块区域才会重新计算。其他字段输入时,这个区块完全不需要醒过来。
5. 取舍准则与性能预算:什么时候仍然用 useEffect?
5.1 异步副作用清单:这些场景不要用 render prop
shouldUpdate和 render prop 是纯同步机制,不能在渲染期开异步任务。下面这些场景必须回到useEffect或事件处理器:
- 切换币种后从服务端拉取实时汇率;
- 输入关键字后做防抖搜索请求;
- 把表单值同步给路由参数、全局 store、localStorage;
- 提交成功后执行跳转、埋点、弹窗;
- 基于远程数据再驱动其他字段的二次联动。
换句话说,useEffect的真正价值在“时机敏感的操作”,而不是“值依赖的展示”。把这两类需求分开,你才能安全地判断什么时候可以不用useEffect。
5.2 性能决策树:一个字段变化时,让谁重渲染?
我给自己总结了一套判断流程,每次写表单联动都先过一遍:
- 变化的字段是否需要保存到表单 store?需要,就正常用带
name的Form.Item。 - 其他字段是否需要跟着更新值?需要,且值是可以推导的,写在事件回调里
setFieldsValue。 - 某块区域是否只需要展示派生结果?只需要展示,用
dependencies或shouldUpdate的 render prop。 - 是否涉及异步?是,才考虑
useEffect或事件处理器加setFieldsValue。
这套流程做完,大部分表单联动都不会用到useEffect。
5.3 React.memo 与 useWatch 的进阶配合,以及我的最终体会
最后补充两个进阶工具。
一个是Form.useWatch('field', form)。它能订阅单个字段的变化,并直接在当前组件里拿到最新值。和useEffect监听相比,它不会触发 commit 后的第二轮更新,适合把字段值传给子组件或放进自定义 Hook 里做派生逻辑。
另一个是React.memo。render prop 每次执行都会生成新的 React 元素,如果这里的子树比较复杂,可以考虑把子树抽成一个独立组件,用React.memo包一下。只要传给它的 props 没变,React 会跳过这次 diff,这也是在 Antd 表单内部再做一层性能隔离的常用手段。
我个人实际维护这套订单表单的体会是:useEffect不是不可以用,而是它更适合做时机敏感的事;表单里的 UI 派生,最好让数据源头直接决定渲染结果。把这两件事切割干净后,这个表单在低端机上切币种终于不再一卡一顿。你迁移自己项目时,可以先用最小场景跑一遍对比,再决定哪些联动值得从useEffect换到Form.Item的 render prop 体系里。