实现动态表单
需求背景
项目是一个电商类站点。
售卖的商品都是虚拟化物品或服务。
当用户在购买站点售卖商品时,需要填写一些信息。
而根据商品类目的不同,所需要填写的信息也会有所不同。
比如在商品类目 A,用户需要填写邮箱,名称即可。
而在商品类目 B,用户需要填写邮箱,平台,平台账号,平台密码等信息。
随着商品类目的增加,所需要的信息收集表单也会越来越多。
目前,这些信息收集表单都是由前端开发人员手动维护的。
这会导致以下问题:
- 表单创建依赖开发人员,随着商品类目增加,开发成本和维护成本将越来越高。
- 表单出错或不完整,运营人员无法及时更改,导致用户无法购买商品。
- 因为极度依赖开发人员进行配置,会导致商品上架将无法及时响应,让业务运营受限。
因此,我们需要一个通用的信息收集表单,可以让运营人员自行配置,而无需开发介入。
需求分析
需求目标
构建一个可动态配置的信息收集表单系统。可让运营人员自行配置,而无需开发人员介入。
需求详情
- 可以动态配置表单字段。
- 支持常见的表单选项,如文本框,单选框,复选框,下拉框等。
- 可以配置选项的属性,如占位提示,格式,长度等。
- 将是可复用,可拓展的,便于后续增加新选项类型。
- 支持表单验证。
预期效果
除需要增加新的选项类型外,其他的功能都可以复用,无需开发人员介入。运营人员可以自由配置表单内容,可以随时更改。运营人员上新更加便捷,可以自行操作,加速商品上架。
设计方案
基于以上需求,设计方案如下:
- 放弃使用
state
管理字段数据,改而使用浏览器原生的表单进行收集数据。 - 每个表单组件只会接收
props
配置,不接收callback
回调。 - 组件字段数据将由浏览器原生的表单管理。
- 通过监听
submit
事件,获取表单信息和创建订单。
将表单划分为四个层级:
- Form
- 是整个表单的根组件。
- 负责表单渲染,表单数据收集,表单验证,表单提交等。
- 表单排列遵从 行列 格式。
- Form Row
- 是表单的行。
- 仅负责行的排列渲染。
- 不参与数据收集,验证提交和具体组件实现。
- Form Column
- 是表单的列。
- 仅负责列的排列渲染。
- 不参与数据收集,验证提交和具体组件实现。
- 列内容为:前缀组件,内容组件,后缀组件三部分。
- Form Item
- 是表单的具体组件。
- 负责具体组件的功能实现。
- 仅接受
props
配置。 - 不接受
callback
回调。
设计实现
因为需要监听 submit
事件来获取表单信息和创建订单。而在页面中,表单和提交按钮并不在同个位置。是分开的。
因此,将使用 form
元素,包裹整个页面,让表单和提交按钮都在 form
元素中。
但页面中布局会比较复杂,因此,使用了占位符技术,当渲染页面时,表单将替代占位符。
以上功能被封装。
下面是简单示例:
import { Form, FormProvider } from 'dynamicform'
function PageContent(props) {
const onSubmit = (submitter, data) => {
// do something...
}
const onChange = (formData) => {
// do something...
}
return (
<Form
rows={formConfig}
CustomColumn={CustomColumn}
onSubmit={onSubmit}
onChange={onChange}
>
<PageHeader />
<main>
<aside>
<Form.Placeholder />
</aside>
<div>
支付明细
<button type='submit'>提交</button>
</div>
</main>
<PageFooter />
</Form>
)
}
export default function Page(props) {
return (
<FormProvider>
<PageContent {...props} />
</FormProvider>
)
}
以上例子中:
rows
属性是表单的配置。CustomColumn
属性是自定义的表单列组件。onSubmit
属性是表单提交时的回调。onChange
属性是表单数据变化时的回调。
表单配置示例
一份简单的,含有用户邮箱和用户手机号,采用一行两列排列的表单配置如下:
;[
{
id: '1743386518708',
gap: 10,
columns: [
{
id: '1743386518709',
width: '50%',
prepend: {
id: '1743386518710',
type: 'icon',
props: {
alt: 'email icon',
icon: 'https://image.xxx.xx/email.png',
},
},
component: {
id: '1743386518711',
type: 'input',
props: {
name: 'email',
placeholder: 'Email',
mode: 'email',
},
},
},
{
id: '1743386518712',
width: '50%',
prepend: {
id: '1743386518713',
type: 'icon',
props: {
alt: 'phone icon',
icon: 'https://image.xxx.xx/phone.png',
},
},
component: {
id: '1743386518714',
type: 'input',
props: {
name: 'phone',
placeholder: 'Phone',
mode: 'tel',
},
},
},
],
},
]
自定义列
在四个层级中,我们可以仅关注 Form Column 和 Form Item 这两层。而 Form
Row 和 Form 这两层,我们可以不关注,交由内部自动处理。
自定义列的代码如下:
import Image from 'next/image'
import { lazy, Suspense } from 'react'
const components = {
input: lazy(() => import('./Input')),
checkbox: lazy(() => import('./Checkbox')),
icon: lazy(() => import('./Icon')),
select: lazy(() => import('./Select')),
radios: lazy(() => import('./Radios')),
}
const isNotEmptyProps = (obj) => obj && obj?.props && Object.keys(obj.props).length > 0
const transformList = (list) => (Array.isArray(list) ? list : list ? [list] : [])
const filterComponent = (list) => transformList(list).filter(isNotEmptyProps)
const renderComponent = (data) =>
transformList(data).map((component, index) => {
const Component = components[component.type]
return Component ? (
<Component
key={component.id || `${component.type}-${index}`}
{...component.props}
/>
) : null
})
export default function Column(props) {
const prepend = filterComponent(props.prepend)
const append = filterComponent(props.append)
const component = filterComponent(props.component)
const hasInput = component.some((component) => component.type === 'input')
const needBackground = hasInput || component.some((component) => ['input', 'select'].includes(component.type))
return (
<div
className={`cart-form-custom-column relative my-2 ${props.width === '100%' ? '' : 'only:mr-1 only:pr-1'}`}
style={{ width: props.width }}
>
<span className='text-nowrap text-[13px] text-yellow-300'>{props.tips}</span>
<div
className={
'relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center justify-between gap-1 rounded-md ' +
(needBackground ? 'bg-[#151825] px-2 focus:bg-[#151825]' : '')
}
>
<Suspense fallback={<></>}>{renderComponent(prepend)}</Suspense>
<Suspense fallback={<></>}>{renderComponent(component)}</Suspense>
<Suspense fallback={<></>}>{renderComponent(append)}</Suspense>
</div>
</div>
)
}
在自定义列中,负责了以下功能:
- 接收
props
配置。 - 处理
props
并提取出prepend
,append
和component
三个部分。 - 渲染
prepend
,append
和component
三个部分。
自定义表单组件
在自定义列中,我们已经将 prepend
,append
和 component
三个部分都渲染出来了。
而这三个部分内容都是一致的。因此,我们只需要实现一次即可。
这里我们以 Input
组件为例,实现代码如下:
import { memo } from 'react'
export default memo(function Input(props) {
return (
<input
name={props.name}
type={props.mode}
placeholder={props.placeholder}
min={props.min}
max={props.max}
minLength={props.minLength}
maxLength={props.maxLength}
required
pattern={props.pattern}
className='h-[45px] w-auto bg-transparent px-1 text-[#fff] placeholder:text-[16px] placeholder:text-[#8392B1] autofill:bg-transparent focus:outline-none'
/>
)
})
可以看到,Input
组件仅需接收 props
配置即可。而不需要使用 state
管理状态,不需要使用 callback
回调传递数据。这些功能都由浏览器原生的表单管理。
总结
以上就是支付页动态表单的实现。通过以上方案,我们可以实现一个可动态配置的信息收集表单系统。可以让运营人员自行配置,而无需开发人员介入。同时,我们也可以通过自定义列和自定义表单组件,实现更多的功能。