我正在参加「掘金·启航计划」
一、背景
表单在前端开发场景中是非常常见的一个功能,在很多需要用户输入的地方都要用到,而通常大多数简单的表单手写时又有较多重复的工作,因此这里我们写一个简单好用体积小的表单引擎,用来快速配置出简单的表单,提高表单开发效率。
在之前的图形编辑器
系列文章svg实现图形编辑器系列十:工具栏&配置面板里,我们在最后也提到了为了扩展图形的属性配置项,需要使用到表单配置面板,那么表单引擎就可以用来实现这样的配置面板。
首先我们看一下效果图:
可以看到配置数据中每个表单项
都被抽象成了一个描述对象
,渲染时就会变成输入框、下拉框等表单项,使用非常简单,仅需要关注必要的配置属性即可。
名词解释
名词 | 含义 |
---|---|
setter | 设置器,也即输入组件,就是用来生产数据的,可以是Input、Select、Checkbox等等 |
field | 字段 |
二、实现原理
1. 输入组件(Setter)的抽象
想要实现用数据来生成表单,用逆向思维来考虑,首先我们需要将各种输入方式抽象成为数据
。
以最典型的输入框
为例,我们先分析下输入框通常都有哪些用户常用的配置:
- 输入框在表单里对应的属性名是什么
- 标题
- 输入框默认值
- 占位提示符
- 是否必填
- 是否禁用
- 提示信息
最大输入长度
- 其他…
再以下拉框
为例和输入框进行对比,下拉框用户常用的配置有:
- 输入框在表单里对应的属性名是什么
- 标题
- 输入框默认值
- 占位提示符
- 是否必填
- 是否禁用
- 提示信息
选项列表
- 其他…
对比可以很容易的发现有些属性配置是相同的,有些属性配置是不同的,例如输入框需要最大输入长度,而下拉框不需要;下拉框需要选项列表,而输入框则不需要。
因此我们将通用的属性配置抽象在首层,每个设置器(Setter)
独有的属性放在setterProps
中,因此输入框和下拉框的描述数据分别为:
// 输入框配置
const inputConfig = {
name: 'username',
setter: 'input',
title: '用户名',
placeholder: '请输入',
tips: '这是输入框',
defaultValue: '张三',
setterProps: {
maxLength: 20,
},
};
// 下拉框配置
const selectConfig = {
name: 'role',
setter: 'select',
title: '角色',
placeholder: '请选择',
tips: '这是下拉框',
defaultValue: 'citizen',
setterProps: {
options: [
{ label: '普通用户', value: 'citizen' },
{ label: '管理员', value: 'admin' },
{ label: '会员', value: 'member' },
{ label: '内部用户', value: 'internal_user' },
],
},
};
按照上面的规则,我们就可以很方便的继续写出别的类型的输入组件了,如:
- 数字输入框(InputNumber)
- 文本域(TextArea)
- 单选框(Radio)
- 多选框(Checkbox)
- 开关(Switch)
- 开关(Switch)
- 日期(Date)
- 时间(Time)
- 其他…
最后我们给出描述的接口interface
/**
* 字段配置接口描述
*/
interface IFieldSetting {
/**
* 字段属性
*/
name: string;
/**
* 字段标题
*/
title: string;
/**
* 输入组件类型,可以是名字,也可以直接是一个组件
*/
setter: string | ISetterComp;
/**
* 输入组件属性,可以是名字,也可以直接是一个组件
*/
setterProps?: Record<string, any> & IFieldSetterProps;
/**
* 默认值
*/
defaultValue?: any;
/**
* 是否禁用
*/
disabled?: boolean;
/**
* 是否只读
*/
readOnly?: boolean;
/**
* 提示文案
*/
tips?: string;
/**
* 是否必填
*/
required?: boolean;
/**
* 是否显示
*/
condition?: boolean;
/**
* 布局模式,水平或者垂直
*/
layout?: 'horizontal' | 'vertical';
}
将输入组件进行抽象后,我们就可以很容易写出表单的字段配置,以按钮的属性配置表单为例,配置项如下:
const buttonFieldConfig = [
{
name: 'content',
title: '文字',
setter: 'input',
defaultValue: '按钮',
required: true,
},
{
name: 'size',
title: '尺寸',
setter: 'radio',
defaultValue: 'middle',
setterProps: {
options: [
{ label: '大', value: 'large' },
{ label: '中', value: 'middle' },
{ label: '小', value: 'small' },
],
},
},
{
name: 'type',
title: '类型',
setter: 'select',
setterProps: {
options: [
{ label: '默认', value: 'default' },
{ label: '主要', value: 'primary' },
{ label: '背景透明', value: 'ghost' },
{ label: '虚线', value: 'dashed' },
{ label: '链接', value: 'link' },
{ label: '文本', value: 'text' },
],
},
},
htmlType: {
type: 'string',
title: '原生按钮类型',
tips: '设置 button 原生的 type 值',
enum: [
{ label: '默认', value: 'button' },
{ label: '提交', value: 'submit' },
{ label: '重置', value: 'reset' },
],
},
{
name: 'disabled',
title: '禁用',
setter: 'switch',
},
{
name: 'loading',
title: '加载中',
setter: 'switch',
},
]
2. 配置数据渲染为表单项
首先是表单字段循环渲染:
- 主要职责就是把每个字段的配置进行拆分,分别分配到
表单项(FormItem)
和输入组件(Setter)
的属性中去
import { Form } from 'antd';
export const FormEngine = ({
fieldSettings = [],
onFieldChange,
onValuesChange,
setterConfigMap,
}: IFormEngineProps) => {
const [formValues, setFormValues] = useState<Record<string, any>>(
collectDefaultValues(fieldSettings),
);
const handleChange = (value: any, name: string) => {
const newValues = { ...formValues, [name]: value };
setFormValues(newValues);
onFieldChange?.(value, name);
onValuesChange?.(newValues);
};
return (
<Form>
{/** 循环渲染字段 */}
{fieldSettings.map(fieldSetting => {
const {
name,
title,
tips,
layout,
setter,
setterProps,
required,
disabled,
readOnly,
} = fieldSetting;
const value = formValues[name];
return (
{/** 表单项 */}
<Form.Item
key={name}
name={name}
layout={layout}
label={title}
required={required}
tooltip={tips}>
{/** 输入组件渲染器 */}
<SetterRender
setter={setter}
setterProps={setterProps}
setterConfigMap={setterConfigMap}
disabled={disabled}
readOnly={readOnly}
value={value}
onChange={val => handleChange(val, name)}
/>
</Form.Item>
);
}}
</Form>
);
};
其中,输入组件(Setter)的渲染器如下:
- 主要职责就是把根据setter名字找到setter的组件渲染出来
export const SetterRender = ({
type,
setter,
setterProps = {},
setterConfigMap,
readOnly,
disabled,
value,
onChange,
}: IProps) => {
const SetterComp = React.useMemo(
() => getSetterComp({ type, setter, setterConfigMap, readOnly }),
[type, readOnly, setter, setterConfigMap],
);
return (
<SetterComp
{...setterProps}
disabled={disabled}
value={value}
onChange={onChange}
/>
);
};
3. 扩展输入组件(Setter),自定义输入组件
输入组件(Setter)就相当于是表单引擎的原材料,最好是支持扩展的,这样才能实现五花八门的丰富功能,因此这里我们介绍下如何写Setter
对setter输入组件的最基本要求只有2个
,就是接受value
和 onChange
两个参数。
首先看一下简单的输入组件(Setter)如何写:
3.1 最基本的setter
import { Input, Select } from 'antd';
interface ISetterProps {
value: any;
onChange: (value: any) => void;
}
// 输入框
const InputSetterConfig: ISetterConfig = {
name: 'input',
setter: Input,
}
// 下拉框
const SelectSetterConfig: ISetterConfig = {
name: 'select',
setter: Select,
}
是不是非常简单。
3.2 定制setter值变化时机
如果你希望在失去焦点、按下回车或者其他时机触发值变化,那么你可以这样写:
// 输入框
const InputSetterConfig: ISetterConfig = {
name: 'input',
setter: ({ value, onChange, ...rest }: ISetterProps) => {
const [val, setVal] = useState(value);
useEffect(() => setVal(value), [value]);
return (
<Input
{...rest}
value={val}
onChange={setVal}
onBlur={(e) => onChange(e.target.value)}
onPressEnter={(e) => onChange(e.target.value)}
/>
);
},
}
3.3 定制其他业务setter
其他定制的setter输入组件如:
- 颜色输入
- 邮箱输入
- 搜索框
- 手机号输入
这里我们实现一个颜色输入框,输入框可以显示颜色矩形,也可以使用输入框编辑16进制颜色值。
// 字段配置
const inputFieldSetting: IFieldSetting = {
name: 'color',
setter: 'color',
title: '颜色',
}
// 颜色输入框setter
const InputSetterConfig: ISetterConfig = {
name: 'color',
setter: ({ value, onChange, ...rest }: ISetterProps) => {
return (
<Input
{...rest}
value={value}
onChange={onChange}
prefix={<ColorBlock color={value} />}
/>
);
},
}
// 矩形色块组件
const ColorBlock = ({ color }: { color: string }) => (
<div
style={{
width: 16,
height: 16,
backgroundColor: color,
}}
/>
);
点击矩形色块时可以打开弹框显示颜色选择器,颜色选择器的实现见我的另外一篇文章实现超好用的React颜色选择器组件
颜色输入框的效果图如下:
4. 一些工具函数:
/**
* 根据字段中的setter配置获取setter输入组件
*/
export const getSetterComp = ({
setter,
setterConfigMap,
}: {
type: string | ValueType;
setter?: string | React.FC<any>;
setterConfigMap: Record<string, ISetterConfig>;
}): React.FC<ISetterProps> => {
if (typeof setter === 'function') {
return setter;
} else if (typeof setter === 'string' && setterConfigMap[setter]) {
return setterConfigMap[setter].setter;
} else {
return () => (
<div style={{ backgroundColor: 'red', color: '#fff', padding: '0 8px' }}>
Setter「{setter || type}」不存在
</div>
);
}
};
/**
* 从字段配置中收集表单默认值
*/
export const collectDefaultValues = (fieldSettings: IFieldSetting[]) => {
const values: Record<string, any> = {};
fieldSettings.forEach(field => {
if (field.hasOwnProperty('defaultValue')) {
values[field.name] = field.defaultValue;
}
});
return values;
};
这样一来我们就得到了一个简单的表单渲染引擎
,虽然功能较为简单,但包含了表单渲染的核心流程, 麻雀虽小五脏俱全。
三、总结
本文介绍了如何使用配置数据的方式来快速生成一个表单,使用时只需要关注必要的配置字段,不用重复书写代码,可以在简单表单的场景下大大提高开发效率。
同时,为了方便理解,本文介绍的是一个简单的渲染主流程,表单还有校验
、联动
等等相对复杂一些的表单功能,我们会在后续的文章中继续介绍。
其他能力规划:
- 表单校验
- 表单联动
- 表单布局(垂直、水平、行内等)
- 表单额外操作(Action)
- 自定义表单内容渲染
最后,我们如果不想手写配置数据,彻底解放双手,或者向让不动写代码的运营、产品、设计等角色使用的话,可以做一个表单编辑器
,用拖拽
和配置
的方式实现一个表单编辑器,生产出上面的字段配置数据,丢给表单渲染引擎即可完成渲染。