嵌套表单
@sinoui/rx-form-state的表单域name
属性支持
JsonPath形式,以支持嵌套表单的场景。如:
表单值数据:
{"address": {"city": "北京","street": "海淀"}}
地址相关的两个表单项:
<Field name="address.city" /><Field name="address.street" />
列表类型嵌套表单的数据结构:
{"contacts": [{"userName": "张三","telephone": "13443565678"},{"userName": "李四","telephone": "13312341234"}]}
我们可以这样定义表单域:
第一个联系人:<Field name="contacts[0].userName" /><Field name="contacts[0].telephone" />第二个联系人:<Field name="contacts[1].userName" /><Field name="contacts[1].telephone" />
@sinoui/rx-form-state 还提供了useFieldArray和FieldArray辅助嵌套表单的开发。
本篇教程我们主要以以下三种嵌套表单为例,阐述不同场景嵌套表单的实现方式:
- 简单的嵌套表单 -- 填写地址
- 列表类型的嵌套 -- 添加联系人
- 深层嵌套 -- 添加常用联系人
简单的嵌套表单
简单的嵌套表单不需要过多复杂的处理,只需要指定正确的name
值即可。
基本用法:
import React from 'react';import {useFormState,FormStateContext,Field,FormValueMonitor,} from '@sinoui/rx-form-state';function FormDemo() {const formState = useFormState();return (<FormStateContext.Provider value={formState}><form formState={formState}><div><label>用户名</label><Field as="input" name="userName" required /></div><label>地址:</label><div><label>城市</label><Field as="input" name="address.city" required /></div><div><label>区/县</label><Field as="input" name="address.region" required /></div><div><label>街道</label><Field as="input" name="address.street" required /></div><FormValueMonitor>{(values) => (<div>表单值:{JSON.stringify(values, undefined, 2)}</div>)}</FormValueMonitor></form></FormStateContext.Provider>);}
运行效果:
列表类型的嵌套表单
可以借助useFieldArray实现列表类型的嵌套表单,我们以添加联系方式为例来说明列表类型的嵌套列表应该如何实现:
TelephoneForm.tsx
import React from 'react';import { Field, useFieldArray } from '@sinoui/rx-form-state';const types = ['家庭', '工作', 'iPhone', '手机', '主要', '工作传真', '其他'];function TelephoneForm() {const {getFieldName: name, // 获取表单域名称items, // 列表数据项insert, // 插入一条新的数据remove, // 移除一条数据push, // 在数据项最后新增一条数据swap, // 移动数据项} = useFieldArray('telephones');const handlePush = () => {if (items.length < types.length) {const idx = types.findIndex((type) => items.findIndex((item: any) => item.type === type) === -1,);if (idx !== -1) {push({ type: types[idx] });}} else {push({ type: '其他' });}};const handleInsert = (index: number) => {if (items.length < types.length) {const idx = types.findIndex((type) => items.findIndex((item: any) => item.type === type) === -1,);if (idx !== -1) {insert(index + 1, { type: types[idx] });}} else {insert(index + 1, { type: '其他' });}};return (<div style={{ paddingTop: 16, paddingBottom: 16 }}><label> 添加电话</label>{items.map((_telephone, index) => (<div// eslint-disable-next-line react/no-array-index-keykey={index}style={{display: 'flex',padding: 8,border: '1px solid green',marginTop: 8,marginBottom: 8,}}><div><Fieldname={name(index, 'type')}as="input"requiredplaceholder="类型"/></div><div><Fieldname={name(index, 'telephone')}as="input"requiredmaxLength={11}minLength={4}placeholder="电话"/></div><button type="button" onClick={() => handleInsert(index)}>+</button><button type="button" onClick={() => remove(index)}>-</button>{index > 0 && (<button type="button" onClick={() => swap(index, index - 1)}>⬆️</button>)}{index < items.length - 1 && (<button type="button" onClick={() => swap(index, index + 1)}>⬇️</button>)}</div>))}<button type="button" onClick={handlePush}>+</button></div>);}export default TelephoneForm;
FormDemo.tsx
import React from 'react';import { Field, useFormState, FormStateContext } from '@sinoui/rx-form-state';import TelephoneForm from './TelephoneForm';function FormDemo() {const formState = useFormState();return (<FormStateContext.Provider value={formState}><form><div><label>姓氏</label><Field as="input" type="text" name="firstName" required /></div><div><label>名字</label><Field as="input" type="text" name="lastName" required /></div><div><label>公司</label><Field as="input" type="text" name="company" /></div><TelephoneForm /><div><label>备注</label><Field as="input" name="note" /></div></form></FormStateContext.Provider>);}
上述示例的数据结构为:
{"firstName": "","lastName": "","company": "","telephones": [{ "type": "", "telephone": "" }],"note": ""}
运行效果:
深层嵌套
深层嵌套表单的实现过程中,我们可能需要多次使用useFieldArray
,此时需要特别注意参数的指定。
添加常用联系人的数据结构如下:
{"userName": "","topContacts": [{"userName": "","telephones": [{ "type": "", "telephone": "138xxxx0015" }]}]}
分析上述数据结构,我们要维护常用联系人以及联系人的联系方式,需要创建两层嵌套表单,分别是:联系人表单(TopContactsForm)和联系方式表单(TelephoneForm)。
我们可以使用两次useFieldArray
来实现。具体如下:
TopContactsForm.tsx
/* eslint-disable react/no-array-index-key *//* eslint-disable jsx-a11y/label-has-for *//* eslint-disable jsx-a11y/label-has-associated-control *//* eslint-disable jsx-a11y/accessible-emoji */import React from 'react';import { useFieldArray, Field } from '../../src';import TelephoneForm from './TelephoneForm';function FormInner(props: any) {const { name, index, insert, remove, swap, itemsLength } = props;return (<divstyle={{display: 'flex',padding: 8,border: '1px solid green',margin: 8,}}><div><Fieldname={name(index, 'userName')}as="input"requiredplaceholder="姓名"/></div><TelephoneForm parentName={`topContacts[${index}]`} /><button type="button" onClick={() => insert(index + 1, {})}>+</button><button type="button" onClick={() => remove(index)}>-</button>{index > 0 && (<button type="button" onClick={() => swap(index, index - 1)}>⬆️</button>)}{index < itemsLength - 1 && (<button type="button" onClick={() => swap(index, index + 1)}>⬇️</button>)}</div>);}const Item = React.memo(FormInner);function TopContactsForm() {const {getFieldName: name,items,insert,remove,push,swap,} = useFieldArray('topContacts');return (<div style={{ paddingTop: 16, paddingBottom: 16 }}><label>添加常用联系人</label>{items.map((_telephone, index) => (<Itemkey={index}index={index}name={name}itemsLength={items.length}insert={insert}remove={remove}swap={swap}/>))}<button type="button" onClick={() => push({})}>+</button></div>);}export default TopContactsForm;
TelephoneForm.tsx
/* eslint-disable jsx-a11y/label-has-for *//* eslint-disable jsx-a11y/label-has-associated-control *//* eslint-disable jsx-a11y/accessible-emoji */import React, { useCallback, useRef, useEffect } from 'react';import { useFieldArray, Field } from '../../src';const types = ['家庭', '工作', 'iPhone', '手机', '主要', '工作传真', '其他'];function FormInner(props: any) {const { name, index, handleInsert, remove, swap, itemsLength } = props;return (<divstyle={{display: 'flex',padding: 8,border: '1px solid green',marginTop: 8,marginBottom: 8,}}><div><Fieldname={name(index, 'type')}as="input"requiredplaceholder="类型"/></div><div><Fieldname={name(index, 'telephone')}as="input"requiredmaxLength={11}minLength={4}placeholder="电话"/></div><button type="button" onClick={() => handleInsert(index)}>+</button><button type="button" onClick={() => remove(index)}>-</button>{index > 0 && (<button type="button" onClick={() => swap(index, index - 1)}>⬆️</button>)}{index < itemsLength - 1 && (<button type="button" onClick={() => swap(index, index + 1)}>⬇️</button>)}</div>);}const Item = React.memo(FormInner);function TelephoneForm(props: { parentName?: string }) {const { parentName } = props;const {getFieldName: name,items,insert,remove,push,swap,} = useFieldArray(parentName ? `${parentName}.telephones` : 'telephones');const itemsRef = useRef(items);useEffect(() => {itemsRef.current = items;}, [items]);const handlePush = () => {if (items.length < types.length) {const idx = types.findIndex((type) => items.findIndex((item: any) => item.type === type) === -1,);if (idx !== -1) {push({ type: types[idx] });}} else {push({ type: '其他' });}};const handleInsert = useCallback((index: number) => {if (itemsRef.current.length < types.length) {const idx = types.findIndex((type) =>itemsRef.current.findIndex((item: any) => item.type === type) ===-1,);if (idx !== -1) {insert(index + 1, { type: types[idx] });}} else {insert(index + 1, { type: '其他' });}},[insert],);return (<div style={{ paddingTop: 4, paddingBottom: 4 }}><label> 添加电话</label>{items.map((_telephone, index) => (<Item// eslint-disable-next-line react/no-array-index-keykey={index}index={index}itemsLength={items.length}name={name}remove={remove}swap={swap}handleInsert={handleInsert}/>))}<button type="button" onClick={() => handlePush()}>+</button></div>);}export default TelephoneForm;
FormDemo.tsx
import React from 'react';import { Field, useFormState, FormStateContext } from '@sinoui/rx-form-state';import TopContactsForm from './TopContactsForm';function FormDemo() {const formState = useFormState();return (<FormStateContext.Provider value={formState}><form><div><label>用户名</label><Field as="input" type="text" name="userName" required /></div><TopContactsForm /></form></FormStateContext.Provider>);}
运行效果:
嵌套表单的性能优化建议
对于复杂的嵌套表单,我们要特别注意其性能优化。比如列表类型的嵌套表单,我们在改变其中一条记录的数据时,理想状态是只有当前的这条数据记录相关组件重新渲染。
此时,我们只需要把相关组件使用React.memo
渲染即可。具体使用可参照深层嵌套。
更多关于组件性能优化方式可参考React 组件性能优化。
嵌套表单的全局校验
简单嵌套表单的全局校验只需要注意其返回值的数据结构,具体事例可参考嵌套表单校验; 列表类型的嵌套表单在做全局校验时,建议拆分校验函数,而非在一个校验函数中写大段的逻辑处理,例如上述深层嵌套表单的全局校验函数我们使用下面的方式定义:
FormDemo.tsx
/*** 联系方式校验*/function validateTelephone(value) {const errors = {};if (value.telephone && value.telephone.length < 4) {errors.telephone = '不能少于4位';}return errors;}/*** 常用联系人校验*/function validateTopContact(topContact) {const errors = {};if (!topContact.userName) {errors.userName = '必填';}if (topContact.telephones) {errors.telephones = topContact.telephones.map(validateTelephone);}return errors;}function validate(values) {let errors = {};if (!values.userName) {errors.userName = '必填';}if (values.topContacts) {errors.topContacts = values.topContacts.map(validateTopContact);}return errors;}function FormDemo() {const formState = useFormState({}, { validate });return (<FormStateContext.Provider value={formState}><form><div><label>用户名</label><Field as="input" type="text" name="userName" /></div><TopContactsForm /><FormValueMonitor>{(values) => (<div>表单值:{JSON.stringify(values, undefined, 2)}</div>)}</FormValueMonitor></form></FormStateContext.Provider>);}
运行效果:
表单域值关联
嵌套表单的表单域值关联,跟普通表单的表单域值关联处理方式基本一致。
只是依赖函数在处理值的时候需要特别注意一下,例如调整上述的联系人示例,
有三个表单域,分别是:姓氏、名字和姓名,它们之间存在一种关系:姓名 = 姓氏 + 名字
。
数据结构:
{"topContacts": [{"firstName": "张","lastName": "三","userName": "张三"}]}
表单域的定义如下:
<Fieldname={name(index, 'firstName')}as="input"requiredplaceholder="姓氏"/><Fieldname={name(index, 'lastName')}as="input"requiredplaceholder="名字"/><Fieldname={name(index, 'userName')}as="input"requiredplaceholder="姓名"readOnlyrelyFields={[name(index, 'firstName'), name(index, 'lastName')]}relyFn={(values) => relyFn(values, index)}/>
relyFn
函数的定义如下:
/*** 计算值的依赖函数 这里需要也别注意值处理,如果层次特别深的话,建议使用lodash的get方法*/const relyFn = (values, index) => {if (values.topContacts) {const { firstName = '', lastName = '' } = values.topContacts[index];return `${firstName}${lastName}`;}return '';};
完整示例如下:
TopContactsForm.tsx
import React from 'react';import {Field,useFieldArray,useFormState,FormValueMonitor,FormStateContext,} from '@sinoui/rx-form-state';import TelephoneForm from './TelephoneForm';/*** 计算值的依赖函数 这里需要也别注意值处理,如果层次特别深的话,建议使用lodash的get方法*/const relyFn = (values, index) => {if (values.topContacts) {const { firstName = '', lastName = '' } = values.topContacts[index];return `${firstName}${lastName}`;}return '';};function FormInner(props: any) {const { name, index, insert, remove, swap, itemsLength } = props;return (<divstyle={{display: 'flex',padding: 8,border: '1px solid green',margin: 8,}}><div><Fieldname={name(index, 'firstName')}as="input"requiredplaceholder="姓氏"/></div><div><Fieldname={name(index, 'lastName')}as="input"requiredplaceholder="名字"/></div><div><Fieldname={name(index, 'userName')}as="input"requiredplaceholder="姓名"readOnlyrelyFields={[name(index, 'firstName'), name(index, 'lastName')]}relyFn={(values) => relyFn(values, index)}/></div><TelephoneForm parentName={`topContacts[${index}]`} /><button type="button" onClick={() => insert(index + 1, {})}>+</button><button type="button" onClick={() => remove(index)}>-</button>{index > 0 && (<button type="button" onClick={() => swap(index, index - 1)}>⬆️</button>)}{index < itemsLength - 1 && (<button type="button" onClick={() => swap(index, index + 1)}>⬇️</button>)}</div>);}const Item = React.memo(FormInner);function TopContactsForm() {const {getFieldName: name,items,insert,remove,push,swap,} = useFieldArray('topContacts');return (<div style={{ paddingTop: 16, paddingBottom: 16 }}><label>添加常用联系人</label>{items.map((_telephone, index) => (<Itemkey={index}index={index}name={name}itemsLength={items.length}insert={insert}remove={remove}swap={swap}/>))}<button type="button" onClick={() => push({})}>+</button></div>);}
FormDemo.tsx
function FormDemo() {const formState = useFormState();return (<FormStateContext.Provider value={formState}><form><div><label>用户名</label><Field as="input" name="userName" required /></div><TopContactsForm /><FormValueMonitor>{(values) => (<div>表单值:{JSON.stringify(values, undefined, 2)}</div>)}</FormValueMonitor></form></FormStateContext.Provider>);}
运行效果:
全局值关联
这里我们依旧采用深层嵌套表单的示例来说明全局值关联的使用。
假设现在我们需要存储一个字段来表示常用联系人总数,此时我们需要定义一个全局依赖规则,并将这个依赖规则作为useFormState
的第二个参数对象的relys
属性。具体实现如下:
const countRely = ['topContacts',(draft) => {if (draft.topContacts) {draft.count = draft.topContacts.length;}},];function FormDemo() {const formState = useFormState({}, { relys: [countRely] });return (<FormStateContext.Provider value={formState}><form><div><label>用户名</label><Field as="input" type="text" name="userName" /></div><TopContactsForm /><div><label>常用联系人总数</label><Fieldas="input"type="number"name="count"placeholder="常用联系人总数"/></div><FormValueMonitor>{(values) => (<div>表单值:{JSON.stringify(values, undefined, 2)}</div>)}</FormValueMonitor></form></FormStateContext.Provider>);}
运行效果:
如果上述示例中的联系人总数不需要存储,只是用来展示统计结果的话,我们不需要使用全局值关联,只需要借助FormValueMonitor
即可实现,具体如下:
function FormDemo() {const formState = useFormState();return (<FormStateContext.Provider value={formState}><form><div><label>用户名</label><Field as="input" type="text" name="userName" /></div><TopContactsForm /><FormValueMonitor>{(values) => (<div>常用联系人总数:{values.topContacts && values.topContacts.length}</div>)}</FormValueMonitor></form></FormStateContext.Provider>);}
运行效果: