优雅处理表单联动
引言
表单之间联动一直是后台管理类项目中的高频场景,简单描述为:一个(源)表单域的变化会导致另外一个(目标)表单域的变化。
本文基于 antd,结合 get 关键字,提出了一种较为优雅地解决表单联动的手段。
(伪)计算属性
初次见到计算属性一词是在 Vue 官方文档-计算属性和侦听器一节中。
模板内的表达式非常便利,但是设计它们的初衷是用于简单运算的。在模板中放入太多的逻辑会让模板过重且难以维护。
回想我们编写的 React 代码,是否也在 jsx(render 函数)中放入了太多的逻辑导致 jsx(render 函数)过于沉重?
通过get
关键字,我们可以一样效果。
本例来源:react-cookbook-石墨文档
// 使用 getters 封装 render 所需要的状态或条件的组合
// 对于返回 boolean 的 getter 使用 is- 前缀命名
// bad
render () {
return (
<div>
{
this.state.age > 18
&& (this.props.school === 'A'
|| this.props.school === 'B')
? <VipComponent />
: <NormalComponent />
}
</div>
)
}
// good
get isVIP() {
return
this.state.age > 18
&& (this.props.school === 'A'
|| this.props.school === 'B')
}
render() {
return (
<div>
{this.isVIP ? <VipComponent /> : <NormalComponent />}
</div>
)
}
可以简单理解为:通过get
关键字,抽离计算逻辑,根据 state 与 props 计算出衍生值。
props 以及 state 的变化会导致 render 函数调用,进而重新计算衍生值。与 Vue 中的定义基本一致。
props/state => render => get
可能有同学会问为什么不直接定义一个方法调用呢?
对于 Vue 计算属性,Vue 官方文档中存在解释:"我们可以将同一函数定义为一个方法而不是一个计算属性。两种方式的最终结果确实是完全相同的。然而,不同的是计算属性是基于它们的依赖进行缓存的。只在相关依赖发生改变时它们才会重新求值。"
假使存在两个 state 属性 A.B,计算属性只依赖 A,不依赖 B。倘若 B 变化,计算属性不会重新计算。
但在 React 中,依旧会执行 render,所以 get 没有缓存,只是个语法糖。
使用计算属性实现最优雅的表单联动
假设存在业务类别以及处罚内容,并且不同类别对应不同的处罚内容,点击不同的业务类别,处罚内容是动态变化的,以业务类别为网上超市为例,见下图:
完整对应关系如下(数字为对应 code):
业务类别:网上超市-0
处罚内容:协议-0 商品-1 店铺-2 搜索-6
业务类别:在线询价-1
处罚内容:询价单报价-4
业务类别:协议供货-2
处罚内容:协议-0 商品-1
业务类别:反项竞价-4
处罚内容:发起竞价-5
现在我们来完成这个小需求 😃
准备工作-抽象对应数据结构
为了避免在代码中出现无意义的神仙数字以及大量的 if-else,我们给每一个 code 命名,同时将其文案以及对应内容进行抽象,如下(看个大概结构就好,我也觉得太长了)。
/* 业务类型code */
export const businessTypeCode = {
LUNATONE_CODE: 0, // 网超
ENQUIRY_CODE: 1, // 在线询价
PROTOCOL_CODE: 2, // 协议供货
REVERSE_CODE: 4, // 反向竞价
};
/* 处罚内容code */
export const punishContentCode = {
PROTOCOL_CODE: 0, // 协议
ITEM_CODE: 1, // 商品
SHOP_CODE: 2, // 店铺
ENQUIRY_CODE: 4, // 询价单报价
BIDDING_CODE: 5, // 发起竞价
SEARCH_CODE: 6, // 搜索
};
/* 业务类型 code 及其对应的描述 desc 以及处罚内容 code */
export const businessTypeMap = {
[businessTypeCode.LUNATONE_CODE]: {
desc: "网上超市",
matchedPunishContent: [
punishContentCode.PROTOCOL_CODE,
punishContentCode.ITEM_CODE,
punishContentCode.SHOP_CODE,
punishContentCode.SEARCH_CODE,
],
},
[businessTypeCode.ENQUIRY_CODE]: {
desc: "在线询价",
matchedPunishContent: [punishContentCode.ENQUIRY_CODE],
},
[businessTypeCode.PROTOCOL_CODE]: {
desc: "协议供货",
matchedPunishContent: [
punishContentCode.PROTOCOL_CODE,
punishContentCode.ITEM_CODE,
],
},
[businessTypeCode.REVERSE_CODE]: {
desc: "反向竞价",
matchedPunishContent: [punishContentCode.BIDDING_CODE],
},
};
/* 处罚内容 code 及其对应的描述 desc */
export const punishContentMap = {
[punishContentCode.PROTOCOL_CODE]: { desc: "协议" },
[punishContentCode.ITEM_CODE]: { desc: "商品" },
[punishContentCode.SHOP_CODE]: { desc: "店铺" },
[punishContentCode.ENQUIRY_CODE]: { desc: "询价单报价" },
[punishContentCode.BIDDING_CODE]: { desc: "发起竞价" },
[punishContentCode.SEARCH_CODE]: { desc: "搜索" },
};
巧用计算属性
通常,我们是通过监听源表单域onChange
事件,改变一个 state 属性,目标表单域则根据该 state 进行动态渲染。
譬如在此例中,我们可以在 state 中维护一个matchedPunishContent
的字段,每次改变业务类别时,根据其 code 找到匹配的处罚内容,然后 setState 保存,取出 state 渲染处罚内容。
大体流程如下:
粗粒度:ui change => state change => ui change
细粒度:Radio=> onChange => 取得code找到对应处罚内容 => setState => 重新渲染处罚内容
如果表单联动关系较多,就会维护很多个 state 以及 onChange 函数,增加维护成本。
我们可以换一种思路,既然 Radio 的 value 值改变了,那么 getFieldValue 取得对应表单域的值也改变了。
通过如下方式,我们可以动态计算出处罚内容,而不需要去维护一个中间 state 以及一个 onChange 方法,大大减少了代码量。
/* 根据 businessType 获取 punishContent */
get matchedPunishContent() {
const businessType = this.props.form.getFieldValue('businessType')
return businessTypeMap[businessType].matchedPunishContent
}
完整代码见附录。
回填表单,从未如此简单
使用计算属性,你甚至可以享受到回填表单的美妙之处。
每一次回填表单数据,最难熬的莫过于不但要把数据填上去,并且需要还原表单状态,譬如某个表单域是选中的话,会导致一个表单域 disable,另一个表单域 hide 等等,之前是需要去维护 一个一个的 state,而有了计算属性,这一切操作,你只需要进行一次setFieldsValue
(以及编写一些计算属性逻辑)。
以上文为例,回填数据时,对业务类型(bussiness)进行setFieldsValue
操作(会导致getFieldValue('businessType')
的改变),可以自动计算出处罚内容,而无需手动去还原。
实战
如图,如果勾选了永久
(permanent),该时间选择器禁用。
同时,回填时如果 permanent 字段为 PERMANENT_CODE,则要将时间选择器还原为禁用状态。
解决如下:
get isDatePickerDisabled() {
const { getFieldValue } = this.props.form;
const permanent = getFieldValue('permanent') || [];
return permanent.includes(PERMANENT_CODE);
}
你只需要这般使用:
<DatePicker disabled={this.isDatePickerDisabled} />
不论是用户手动点击亦或是开发者回填表单setFieldsValue({permanent:[PERMANENT_CODE]})
DatePicker 会自己乖乖禁用,而无需手动进行任何操作。
感谢阅读,欢迎探讨。
参考文章:
附录:
import React, { Component } from "react";
import { Form, Radio } from "doraemon";
import {
businessTypeCode,
punishContentCode,
businessTypeMap,
punishContentMap,
} from "./config";
const FormItem = Form.Item;
const RadioGroup = Radio.Group;
@Form.create()
class Test extends Component {
/* 根据 businessType 动态计算 punishContent */
get matchedPunishContent() {
const businessType = this.props.form.getFieldValue("businessType");
return businessTypeMap[businessType].matchedPunishContent;
}
/* 渲染业务类别 */
renderBusinessType = () => {
const { getFieldDecorator } = this.props.form;
const businessTypeConfig = {
rules: [{ required: true, message: "请选择业务类别" }],
initialValue: businessTypeCode.LUNATONE_CODE,
};
return (
<FormItem label="业务类别">
{getFieldDecorator(
"businessType",
businessTypeConfig
)(
<RadioGroup>
{Object.keys(businessTypeMap).map((value) => {
const { desc } = businessTypeMap[value];
return (
<Radio key={value} value={Number(value)}>
{desc}
</Radio>
);
})}
</RadioGroup>
)}
</FormItem>
);
};
/* 渲染处罚内容 */
renderPunishContent = () => {
const { getFieldDecorator } = this.props.form;
const punishContentConfig = {
rules: [{ required: true, message: "请选择处罚内容" }],
initialValue: punishContentCode.PROTOCOL_CODE,
};
return (
<FormItem label="处罚内容">
{getFieldDecorator(
"punishContent",
punishContentConfig
)(
<RadioGroup>
{this.matchedPunishContent.map((value) => {
const { desc } = punishContentMap[value];
return (
<Radio key={value} value={value}>
{desc}
</Radio>
);
})}
</RadioGroup>
)}
</FormItem>
);
};
render() {
return (
<Form>
{this.renderBusinessType()}
{this.renderPunishContent()}
</Form>
);
}
}