- A+
工作少不了写“增删改查”,“增删改查”中的“增”和“改”都与 Form 有关,可以说:提升了 Form 的开发效率,就提升了整体的开发效率。
本文通过总结 Form 的写法,形成经验文档,用以提升团队开发效率。
1.布局
不同人开发的表单,细看会发现:表单项的上下间距、左右间距有差别。如果 UE 同学足够细心,挑出了这些毛病,开发同学也是各改各的,用独立的 css 控制各自的表单样式。未来 UE 同学要调整产品风格,开发需要改所有表单样式,代价极高。
解决这个问题的办法是:统一布局方式:Form + Space + Row & Col。
以下图表单为例,进行说明。
const App = () => { const [form] = Form.useForm(); return ( <Form form={form} labelCol={{ span: 4 }} wrapperCol={{ span: 20 }} requiredMark={false} onFinish={console.log} > <Form.Item name="name" label="名称" rules={[Required]}> <Input /> </Form.Item> <Form.Item label="源IP" style={{ marginBottom: 0 }}> <Address namePathRoot="src" /> </Form.Item> <Form.Item label="目的IP" style={{ marginBottom: 0 }}> <Address namePathRoot="dst" /> </Form.Item> <Form.Item label=" " colon={false}> <Space> <Button type="primary" htmlType="submit"> 确定 </Button> <Button>取消</Button> </Space> </Form.Item> </Form> ); };
antd 采用的是 24 栅格系统,即把宽度 24 等分。以下代码设置了:标签占 4 个栅格,内容占 20 个栅格。
<Form labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}> ... </Form>
确定、取消按钮中间的间隔,通过 Space
组件来实现,不写样式。
<Space> <button>确定</button> <button>取消</button> </Space>
按钮和上方的输入框左对齐,靠的是:设置 Form.Item
的 label
为一个空格,并且不显示冒号。
<Form.Item label=" " colon="{false}"> <Space> <button>确定</button> <button>取消</button> </Space> </Form.Item>
还有一种做法是用栅格系统的 offset
,让 offset
值等于 Form labelCol
的 span
。这种做法形成了依赖关系,以后调整 Form labelCol
的 span
,还需要调整 offset
,因此不建议这样使用。
<Form.Item wrapperCol={{ offset: 4 }}>...</Form.Item>
<Form labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}> ... </Form>
再来看 Address
组件。
Address
组件被用在两个地方:
<> <Form.Item label="源IP" style={{ marginBottom: 0 }}> <Address namePathRoot="src" /> </Form.Item> <Form.Item label="目的IP" style={{ marginBottom: 0 }}> <Address namePathRoot="dst" /> </Form.Item> </>
const Address = ({ namePathRoot }) => { return ( <Row gutter={[8, 8]}> <Col span={24}> <Form.Item name={[namePathRoot, "type"]} initialValue="ip" noStyle> <Select> <Select.Option value="ip">IP地址</Select.Option> <Select.Option value="iprange">IP地址段</Select.Option> </Select> </Form.Item> </Col> <Col flex={1}> <Form.Item name={[namePathRoot, "version"]} initialValue="v4"> <Select> <Select.Option value="v4">IPV4</Select.Option> <Select.Option value="v6">IPV6</Select.Option> </Select> </Form.Item> </Col> <Col flex={2}> <Form.Item dependencies={[ [namePathRoot, "type"], [namePathRoot, "version"], ]} noStyle > {({ getFieldValue }) => { const type = getFieldValue([namePathRoot, "type"]); const version = getFieldValue([namePathRoot, "version"]); if (type === "ip") { return ( <Form.Item name={[namePathRoot, "ip"]} dependencies={[ [namePathRoot, "type"], [namePathRoot, "version"], ]} validateFirst rules={[Required, version === "v4" ? IPv4 : IPv6]} > <Input placeholder="请输入IP地址" /> </Form.Item> ); } else { return ( <Row gutter={8} style={{ lineHeight: "32px" }}> <Col flex={1}> <Form.Item name={[namePathRoot, "iprange", "start"]} dependencies={[ [namePathRoot, "type"], [namePathRoot, "version"], ]} validateFirst rules={[Required, version === "v4" ? IPv4 : IPv6]} > <Input placeholder="请输入起始IP" /> </Form.Item> </Col> -<Col flex={1}> <Form.Item name={[namePathRoot, "iprange", "end"]} dependencies={[ [namePathRoot, "type"], [namePathRoot, "version"], [namePathRoot, "iprange", "start"], ]} validateFirst rules={[ Required, version === "v4" ? IPv4 : IPv6, buildMultiFieldsRule( [ [namePathRoot, "iprange", "start"], [namePathRoot, "iprange", "end"], ], (start, end) => ipToInt(end) > ipToInt(start), "结束IP需要大于起始IP" ), ]} > <Input placeholder="请输入结束IP" /> </Form.Item> </Col> </Row> ); } }} </Form.Item> </Col> </Row> ); };
注意 Address
组件中第一个 Form.Item
有属性 noStyle
,noStyle
让 Form.Item
没有样式,这样 Form.Item
就不会有 margin
了,Form.Item
之间就会更紧凑了。
对比一下有和无 noStyle
的区别:
有 noStyle
:
无 noStyle
:
下面来看如何用 Row
& Col
实现两行的布局。
第一行包含一个下拉框;第二行分为两部分:左侧部份是下拉框,右侧部份根据第一行下拉框的选中条件渲染。
<Row gutter={[8, 8]}> <Col span={24}>第一行</Col> <Col flex={1}>第二行左侧部分</Col> <Col flex={2}>第二行右侧部分</Col> </Row>
gutter={[8, 8]}
指定 Col
之间的水平间隔和垂直间隔。
<Col span={24}>第一行</Col>
,antd 采用 24 栅格系统,因此该 Col
占满整行。Row
默认自动换行 wrap={true}
,所以后面的 Col
会换行。
<Col flex={1}>第二行左侧部分</Col> <Col flex={2}>第二行右侧部分</Col>
第二行的实现有个细节,两个 Col
的宽度用的不是 span
,而是 flex
。如果用 span={8}
和 span={16}
,那么这两个 Col
的宽度会固定为 1:2。
这里的设计是:第二行左侧部分【下拉框】的宽度是变化的,当第二行右侧部分展示两个输入框时候,第二行左侧部分宽度变小。
Col
使用 flex
指定宽度可以实现这个效果,对应的 css 样式是如下:
Col:第二行左侧部分 | Col:第二行右侧部分 |
---|---|
flex={1} |
flex={2} |
flex-grow:1; flex-shrink: 1; flex-basis: auto; |
flex-grow:2; flex-shrink: 2; flex-basis: auto; |
这样的效果是:
- 如果
组件默认宽度总和
小于行宽
,剩余的宽度根据flex-grow
的比例来分配; - 如果
组件默认宽度总和
大于行宽
,超出的宽度根据flex-shrink
的比例来缩小。
我们的目标是在项目中统一布局方式,不要把“不写样式”作为规则规范,那会让我们束手束脚。
实际上这个表单也写了两处样式。
源 IP、目的 IP 的 Form.Item
设置了 marginBottom: 0
。
<Form.Item label="源IP" style={{ marginBottom: 0 }}> <Address namePathRoot="src" /> </Form.Item>
这是因为输入框的错误要显示在输入框的正下方,这样 Address
组件内的输入框就不能写 noStyle
。
如果设置 noStyle
, 它的错误会向上传递:
但不写 noStyle
,它就会有 marginBottom
,因此需去除包裹 Address
的 Form.Item
的 marginBottom
。
<Form.Item label="源IP" style={{ marginBottom: 0 }}> <Address namePathRoot="src" /> </Form.Item>
起始、结束 IP 中间的横杠,为了垂直居中,在 Row
上设置了 line-height
。
<Row style={{ lineHeight: "32px" }}>...</Row>
2.name 重名
<> <Form.Item label="源IP"> <Address namePathRoot="src" /> </Form.Item> <Form.Item label="目的IP"> <Address namePathRoot="dst" /> </Form.Item> </>
上图的 Address
组件在表单中出现两次,如何保证 Form.Item
的 name
不重名?
有的同学把所有 Form.Item
的 name
作为 props
传入组件。这种方法固然可行,但比较费事,更好的做法是利用 NamePath
。
<Form.Item name={["a", "b", "c"]}> <Input /> </Form.Item>
Form.Item
的 name
不仅可以是字符串,也可以是字符串数组,即 NamePath
。这样表单项生成的 value 会是嵌套结构:
{ a: { b: { c: "xxxx"; } } }
我们只需要让两个 Address
实例 NamePath
的根不同,就可以做到区分,就像指定了不同的命名空间。
<> <Form.Item label="源IP"> <Address namePathRoot="src" /> </Form.Item> <Form.Item label="目的IP"> <Address namePathRoot="dst" /> </Form.Item> </>
const Address = ({ namePathRoot }) => { return ( <Row gutter={[8, 8]}> <Col span={24}> <Form.Item name={[namePathRoot, "type"]}>...</Form.Item> </Col> ... </Row> ); };
有的同学问:实际项目中,后台数据是扁平结构的怎么办?
我的建议是:前台在 action
层做数据转换。
3.条件渲染
下拉框选择不同,后面的表单项也会不同。遇到这种需求,有的同学使用 state
来实现:
const Address = () => { const [option, setOption] = useState("ip"); return ( <> <Form.Item name="type" onChange={setOption}> <Select> <Select.Option value="ip">IP地址</Select.Option> <Select.Option value="iprange">IP地址段</Select.Option> </Select> </Form.Item> {option === ip ? "IP地址表单项" : "IP地址段表单项"} </> ); };
实现条件渲染,这种做法需要在 3 处写代码:声明 state
、设置 state
、根据 state
条件渲染,逻辑是割裂的,会给阅读和维护代码造成麻烦。更好的方式是采用 renderProp
。
Form.Item
的 children
传一个函数:
<Form.Item> {form => { const type = form.getFieldValue("type"); if (type === "ip") { return "ip地址表单项"; } else { return "ip地址段表单项"; } }} </Form.Item>
除此以外,还需要在 Form.Item
上说明,在什么情况下,需要执行 children
函数。
<Form.Item shouldUpdate> {(form) => { ... }} </Form.Item>
以上代码相当于设置 shouldUpdate={true}
,即每次 render
,都重新渲染 children
,显然这样性能不好。
<Form.Item shouldUpdate={(preValue, curValue) => preValue.type !== curValue.type}> {(form) => { ... }} </Form.Item>
当表单值发生变化时,检查 type
值是否改变,改变了才重新渲染 children
。这种做法消除了性能问题,但还不是最好的做法。
<Form.Item dependencies={["type"]}> {(form) => { ... }} </Form.Item>
上述 dependencies
表示:该表单项依赖 type
字段,当 type
发生改变时,需要重新渲染 children
。这种声明式的写法更清晰高效。
4.校验
从经验来看,能在各个项目中复用的校验逻辑是 isXyz
:
declare function isXyz(str: string): boolean;
如:
- isIPv4
- isIPv4NetMaskIP
- isIPv4NetMaskInt
- isIPv6
- ...
这些原子的校验函数库做好规范后,我们利用函数式的写法,通过 and
、or
、not
来组合出更强大的校验函数。如一个输入框可以输入 IPv4 也可以输入 IPv6,那校验函数就是:
or(isIPv4, isIPv6);
在校验函数之上,我们再提供 buildRule
方法,将校验函数转成 antd 的 Rule
。
const buildRule = (validate, errorMsg) => ({ validator: (_, value) => validate(value) ? Promise.resolve() : Promise.reject(errorMsg), });
还有一种比较复杂的情况,是多个表单项的关联校验,如起始 IP 和结束 IP,结束 IP 的要大于起始 IP。
这个需求核心的校验逻辑是判断 IP 的大小:
(start, end) => ipToInt(end) > ipToInt(start);
这个函数能正常执行的前提是:起始 IP 和结束 IP 输入框都输入了合法的 IP。
<> <Form.Item name="start" validateFirst rules={[Required, IPv4]}> <Input placeholder="请输入起始IP" /> </Form.Item> <Form.Item name="end" dependencies={["start"]} validateFirst rules={[ Required, IPv4, buildMultiFieldsRule( ["start", "end"], (start, end) => ipToInt(end) > ipToInt(start), "结束IP需要大于起始IP" ), ]} > <Input placeholder="请输入结束IP" /> </Form.Item> </>
我们让 Rule 有层层递进的关系:
[ Required, IPv4, buildMultiFieldsRule( ["start", "end"], (start, end) => ipToInt(end) > ipToInt(start), "结束IP需要大于起始IP" ), ];
先校验填了,再校验是 IPv4,最后校验大小合适。
同时,我们设置了 Form.Item
的 validateFirst
,顺序执行 Rule,有一个出错了,后续的就不执行了。
在 buildMultiFieldsRule
方法中,封装判断各个 field
都填写正常的逻辑:
const buildMultiFieldsRule = (fields, validate, errorMsg) => ({ getFieldValue, isFieldTouched, getFieldError }) => ({ validator: () => { if (fields.some(f => !isFieldTouched(f) || getFieldError(f).length > 0)) { return Promise.resolve(); } else { return validate(...fields.map(getFieldValue)) ? Promise.resolve() : Promise.reject(errorMsg); } }, });
5.总结
以上总结了项目中开发 Form 的好的实践。这类总结经验的文章,需要是活的,能随着项目经验积累不断进化,而不是一写下来就死了。