前言
我自己在做表单业务时,常遇到以下情况:
- 表单项1选择了某个值后,导致表单项2的可选项发生改变
- 表单项2选择了某个值后,导致表单项3的值需要重置为某个默认值,或者是固定为某个值
- 表单项3选择了某个值后,导致表单项4不可用,并且需要把表单项4的值清空,且当表单项4恢复为可用时,为了操作便捷性考虑,需要把表单项4恢复到上一次的值
以上情况用代码一般是这么写:
tsx
const Test: React.FC = () => {
const [form] = Form.useForm()
const itemOneValue = Form.useWatch('item-1', form)
const itemTwoValue = Form.useWatch('item-2', form)
const itemThreeValue = Form.useWatch('item-3', form)
const [itemTwoOptions, setItemTwoOptions] = useState(['value-2-1', 'value-2-2', 'value-2-3'])
const [itemFourVisible, setItemfourVisible] = useState(true)
const [itemFourPrevValue, setItemFourPrevValue] = useState('')
useEffect(() => {
// 当表单项1的值为value-1-1时,表单项2的可选项只有value-2-2和value-2-3
// 且表单项2如果之前选择了value-2-1,则需要清空该值
if(itemOneValue === 'value-1-1') {
setItemTwoOptions(['value-2-2', 'value-2-3'])
}
const curValueIsInvalid = form.getFieldValue('item-2') === 'value-2-1'
if(curValueIsInvalid) {
form.setFieldValue('item-2', 'value-2-2')
}
}, [itemOneValue])
useEffect(() => {
// 当表单项2的值发生变化时,表单项3的值需要重置为value-3-1
form.setFieldValue('item-3', 'value-3-1')
}, [itemTwoValue])
useEffect(() => {
// 当表单项3的值为value-3-2时,表单项4不可用,且要隐藏和清空值
if(form.getFieldValue('item-3') === 'value-3-2') {
setItemfourVisible(false)
setItemFourPrevValue(form.getFieldValue('item-4'))
form.setFieldValue('item-4', undefined)
} else {
setItemfourVisible(true)
form.setFieldValue('item-4', itemFourPrevValue)
}
}, [itemThreeValue])
return <Form form={form}>
<Form.Item name='item-1'>
<Select>
<Select.Option value='value-1-1'></Select.Option>
<Select.Option value='value-1-2'></Select.Option>
</Select>
</Form.Item>
<Form.Item name='item-2'>
<Select>
<Select.Option value='value-2-1' disabled={!itemTwoOptions.includes('value-2-1')}></Select.Option>
<Select.Option value='value-2-2' disabled={!itemTwoOptions.includes('value-2-2')}></Select.Option>
<Select.Option value='value-2-3' disabled={!itemTwoOptions.includes('value-2-3')}></Select.Option>
</Select>
</Form.Item>
<Form.Item name='item-3'>
<Select>
<Select.Option value='value-3-1'></Select.Option>
<Select.Option value='value-3-2'></Select.Option>
</Select>
</Form.Item>
{itemFourVisible ? <Form.Item name='item-4'>
<Select>
<Select.Option value='value-4-1'></Select.Option>
<Select.Option value='value-4-2'></Select.Option>
</Select>
</Form.Item> : null}
</Form>
}
以上代码让我头疼的点:
- 当业务庞大时避免不了组件的拆分,联动的逻辑也随之拆分到各个组件,后续开发人员想要理解业务就要跟着组件拆分的路线进行研究,理解成本高
- 联动逻辑对组件的侵入性太高。视图和联动逻辑不一定是唯一对应的,同样一份视图可以用到其他页面上,但联动逻辑可能不一样了,这将导致需要对联动逻辑进行打补丁的操作,这违反了开放封闭原则,不利于维护。函数内部应该就只负责视图的渲染
- 联动逻辑都是useEffect去修改UI的副作用,脱离了组件就无法工作了。试想一下,如果页面太庞大了需要对组件做懒加载,部分表单组件还没加载和渲染,用户修改了某个输入,导致表单的值需要联动进行改变,但由于你的组件还没加载useEffect根本不可能执行,此时用户点了表单提交会把一些错误值提交上去,我们不能强行要求用户往下滚动把所有组件都加载完再去提交
分析与思考
我们可以这么设想:每一种联动逻辑就是一种策略,一个视图可能对应一种或多种策略,上面说到的违反了开放封闭原则的原因是策略的使用和策略的实现都结合在一起了。那解决办法很简单,就是把他们分离开来:
- 首先把联动逻辑通过某种方式整合在一起,从而实现一种策略,以解决开发人员需要分散到各个地方理解业务、成本高的问题。
- 策略实现完了,那么就需要抽象出一层调度器,用它来接收策略以及用户的输入。 可以先简单理解为需要封装一个函数来使用策略,该函数是可以脱离组件使用的,解决上面说的懒加载的问题。
- 而且该函数可以把策略的实现和视图分隔开,如果需求变更,导致联动逻辑要改动,则去修改策略的实现。如果要新增页面采用另一套联动逻辑,则新增一个策略,解决侵入性的问题。
实现
- 最终我想到的方式是通过json的形式去描述一种策略:
tsx
const relationInfo = [
{
conditions: [{
key: 'item-1', // Form Item的name属性
value: 'value-1-1' // 可支持回调函数: (val, allFormData) => boolean
}],
// 当表单项1的值为value-1-1时,表单项2的可选项只有value-2-2和value-2-3
// 且表单项2如果之前选择了value-2-1,则需要把值重置为value-2-2
relation: {
'item-2': {
disableOptions: ['value-2-1'],
resetValue: 'value-2-2'
}
}
},
{
conditions: [{
key: 'item-2',
value: 'value-2-2'
}],
// 当表单项2的值为value-2-2时,表单项3的值需要重置为value-3-1
relation: {
'item-3': {
resetValue: 'value-3-1'
}
}
},
{
conditions: [{
key: 'item-3',
value: 'value-3-2'
}],
// 当表单项3的值为value-3-2时,表单项4不可用,且要隐藏和清空值
relation: {
'item-4': false
}
},
{
conditions: and(
[{
key: 'item-3',
value: 'value-3-2'
},{
key: 'item-4',
value: 'value-4-1'
}]
),
// 用or and合并多种条件
relation: {
/** ... */
}
}
]
最终效果:
- 先安装form-relation以实现策略的调度
js
npm install @ppeng/form-relation
- 在组件中使用
tsx
import { Form, Select } from '@ppeng/form-relation';
const Test = () => {
const formProps = {} // 与antd Form组件props一致
const [form] = Form.useForm()
const relationInfo: FormRelationType[] = /** 策略 */
const onRelationValueChange = (effect) => {
console.log('1s后 表单项3的值自动变为value-3-1!!', effect['item-3'] === 'value-3-1')
console.log('2s后 表单项4的值自动被清空了,且组件会自动隐藏了!!', effect['item-4'] === undefined)
}
useEffect(() => {
// 模拟用户 修改表单项2的值
setTimeout(() => {
form.setFieldsValue({
'item-2': 'value-2-2'
})
console.log('1s后 单项2的值被用户修改为:value-2-2')
}, 1000)
}, [])
useEffect(() => {
// 模拟用户 修改表单项3的值
setTimeout(() => {
form.setFieldsValue({
'item-3': 'value-3-2'
})
console.log('2s后 单项3的值被用户修改为:value-3-2')
}, 2000)
}, [])
return <Form
form={form}
relationInfo={relationInfo}
onRelationValueChange={onRelationValueChange}
>
<Form.Item name='item-1'>
<Select>
<Select.Option value='value-1-1'></Select.Option>
<Select.Option value='value-1-2'></Select.Option>
</Select>
</Form.Item>
<Form.Item name='item-2'>
<Select>
<Select.Option value='value-2-1'></Select.Option>
<Select.Option value='value-2-2'></Select.Option>
<Select.Option value='value-2-3'></Select.Option>
</Select>
</Form.Item>
<Form.Item name='item-3'>
<Select>
<Select.Option value='value-3-1'></Select.Option>
<Select.Option value='value-3-2'></Select.Option>
</Select>
</Form.Item>
<Form.Item name='item-4'>
<Select>
<Select.Option value='value-4-1'></Select.Option>
<Select.Option value='value-4-2'></Select.Option>
</Select>
</Form.Item>
</Form>
}
组件只需要接收策略relationInfo和输入数据form,用户修改了数据之后,会通过onRelationValueChange输出最终的数据,视图和业务逻辑已经解耦了
- 脱离组件使用
ts
import { initRelationValue } from '@ppeng/form-relation/esm/util'
const formData = {
'item-1': 'value-1-2',
'item-2': 'value-2-1'
}
// 模拟用户修改表单项1的值
formData['item-1'] = 'value-1-1'
const effect = initRelationValue(
relationInfo, // 策略
formData, // 输入数据
{}
)
// 且表单项2的值变为了value-2-2
console.log('effect', effect['item-2'] === 'value-2-2')
const newFormData = {
...formData,
...effect
}
和在组件上使用的思路是一样的,接收策略relationInfo和输入数据formData,最后输出effect,生成新的表单数据newFormData。用户点击提交后就可以把newFormData传给服务端了
最后
- 把思路套用过来,我们思考一下表单校验逻辑,以往是写在Form.Item的rules里面,视图和业务逻辑也是混在了一起,其实也可以整理成json形式从视图当中分离出来的。
- 设计模式是双刃剑,以上的方案虽然解决了上面说的问题点,但是需要维护一份json配置,业务庞大json也跟着变大,如果不写注释也不一定好理解。写法简单、声明式的背后源码必定是命令式且复杂的,如果没有一定的功底,后续维护源码是一项很头疼的事,当然也可以用第三方包@ppeng/form-relation,让别人去维护。利和弊孰大孰小见仁见智。
- 我自己在开发业务时总结下来,业务逻辑和视图分离,在某些类型的业务上它是有利于自动化。以上例子中,通过用户修改的表单项1的值和一种策略,就可以生成一份提交给接口的数据。当然,要实现自动化,一种策略是远远不够的,随着时间的积累,策略会逐渐丰富。前期用户需要手动设置所有表单,到最后策略代替了用户操作。因为代码是脱离视图的,自动化的逻辑既可以写在前端,也可以迁移到后端的