前言
作为一个前端最常遇见的需求场景就是写表单、写表格。写多了会逐渐的积累一些开发心得,此文章根据我使用vue和react的经验记录了一些东西,抛砖引玉的给大家看看。
功能的实现尽量先看第三方组件库上是否已经提供
举例react项目,在做表单的很多时候,我都是从antd上把其中一个form组件例子复制下来,然后再看看提供了哪些api就开干了。
这样做其实会忽略很多细节方面的东西。
比如我想在rules校验的时候拿到其他item的值,可能会通过form的实例调用getFieldValue
,但其实在rules的使用中已经默认传入了该方法:
rules={[
({ getFieldValue }) => ({
validator(_, value) {
// 可以通过getFieldValue('password')获取对应其他item的值
},
}),
]}
类似的例子还有很多,所以有些需要的功能其实你细看整个form组件的页面说明后,说不定就给你找到了你想要的。
这里提供一个注册表单的例子,里面很多小细节可以注意下,没准你还有新发现:
<Form labelCol={{ span: 6 }} wrapperCol={{ span: 16 }} onFinish={onFinish}>
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ type: 'string', min: 5, max: 20, message: '字符长度在 5-20 之间' },
{ pattern: /^\w+$/, message: '只能是字母数字下划线' },
]}
>
<Input />
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label="确认密码"
name="confirm"
dependencies={['password']} // 依赖于 password ,password 变化,会重新触发 validator
rules={[
{ required: true, message: '请输入密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve()
} else {
return Promise.reject(new Error('两次密码不一致'))
}
},
}),
]}
>
<Input.Password />
</Form.Item>
<Form.Item label="昵称" name="nickname">
<Input />
</Form.Item>
<Form.Item wrapperCol={{ offset: 6, span: 16 }}>
<Space>
<Button type="primary" htmlType="submit">
注册
</Button>
<Link to={LOGIN_PATHNAME}>已有账户,登录</Link>
</Space>
</Form.Item>
</Form>
其实不仅仅是表单组件,其他组件也是一样的道理,所以建议没事可以多去组件库官网上翻翻,留些整体印象
当一个页面有多个表单组件时,就要优先考虑把值存在状态管理中
这种场景太常见了,一个页面有多个表单组件。我看很多时候大家都是把每个组件的数据状态维护在各自的组件中,其实不太好。
- 指不定你下个页面还可以返回上一步的表单填写页,如果都把数据统一放在状态管理维护,回显就很容易了。
- 各个表单组件可能是会联动的,例如a组件的某个item的值会改变b组件的一些item的显隐,有状态管理就比较容易去做。
当有一个复杂的多表单页面时,建议做页面缓存
场景举例:
- 复杂的多表单页面A —> 复杂的多表单页面B,A页面下一步到B页面,然后再返回A页面,A页面回显
- 移动端中复杂的多表单页面A ----> 某项输入项具体选择的页面C,A页面点击某项输入项到C页面,然后再返回A页面,A页面回显并且定位在刚刚的位置。
这些情况还用redux去做回显就很笨了,增加代码复杂度。
vue有官方的keepalive,react有网友给我推荐了keepalive-react-component,我个人还没使用过。
如果一些表单比较简单且能确保后续不会有复杂功能的拓展,可以使用业务组件
例如后台管理的一些列表搜索页的搜索表单区域,这种一般就不会做的很复杂,很适合用业务组件。
但当你觉得某个表单现在看来简单,但以后被改复杂的可能性很大时,就不要考虑使用业务组件了。
react表单业务类组件例子:
/* eslint-disable react-hooks/exhaustive-deps */
/* 用于列表页的搜索过滤区 类组件
necessaryField 必须返回字段值
initiative 主动第一次触发搜索
formatter: int
btnChildren 按钮插槽
config :{
formItems: [],
colSpan: [], //
formLayout
}
*/
import React, { Component } from 'react'
import {
Button,
Form,
Input,
Select,
DatePicker,
Row,
Col,
InputNumber,
} from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import moment from 'moment'
import './TableFiltersSearch.less'
import { isEffectVar, deepClone, validateNumber } from './utils'
export default class TableFiltersSearchClass extends Component {
constructor(props) {
super(props)
this.state = {
formItems: [],
colSpan: [21, 3], // 表单和按钮区的宽度设置
formLayout: 'inline',
formItemLayout: {
labelCol: {
flex: '1',
},
wrapperCol: {
flex: '1',
},
},
}
}
formRef = React.createRef()
submitRef = React.createRef()
// 布局
componentDidMount() {
/* 处理搜索区配置 */
this.init()
}
componentDidUpdate() {
// this.init()
}
init = () => {
if (this.props.config) {
// 先对子项做处理
this.setState({
formItems: this.itemConfigDeal(this.props.config.itemConfigs),
})
// 默认值处理
this.defaultFormDeal(this.props.config.itemConfigs)
// 是否需要主动初始化触发
if (this.props.initiative) {
this.queryHandle()
}
// 表单和按钮区的宽度设置
if (this.props?.config?.colSpan) {
this.setState({
colSpan: this.props.config.colSpan,
})
}
if (this.props?.config?.formLayout) {
this.setState({
formLayout: this.props.config.formLayout,
})
}
if (this.props?.config?.formItemLayout) {
this.setState({
formItemLayout: this.props.config.formItemLayout,
})
}
}
}
/* 子项的处理 */
itemConfigDeal = (configs) => {
return configs.map((item, index) => {
if (item.display === false) {
return null
}
if (!item.type || item.type === 'input') {
// 普通输入框
return (
<Form.Item
label={item.label}
name={item.name}
key={index}
rules={item.rules ? item.rules : []}
>
<Input
style={{
width: item.width ? item.width : '220px',
}}
placeholder={
item.placeholder ? item.placeholder : '请输入'
}
allowClear
onChange={(e) => {
this.inputOnChange(e, item)
}}
disabled={
item.disabled !== undefined
? item.disabled
: false
}
// {...item.attr}
/>
</Form.Item>
)
}
// 数字输入框
if (!item.type || item.type === 'inputNumber') {
return (
<Form.Item
label={item.label}
name={item.name}
key={index}
rules={item.rules ? item.rules : []}
>
<InputNumber
style={{ width: item.width ? item.width : '220px' }}
placeholder={
item.placeholder ? item.placeholder : '请输入'
}
allowClear
disabled={
item.disabled !== undefined
? item.disabled
: false
}
// onChange={e => e.target.value = validateNumber(e.target.value)}
/>
</Form.Item>
)
}
// 时间返回选择器
if (item.type === 'dateRange') {
return (
<Form.Item
label={item.label}
name={item.name}
key={index}
rules={item.rules ? item.rules : []}
>
<DatePicker.RangePicker
style={{ width: item.width ? item.width : '220px' }}
separator="至"
format="YYYY-MM-DD"
disabled={
item.disabled !== undefined
? item.disabled
: false
}
/>
</Form.Item>
)
}
// 单选下拉框
if (item.type === 'select') {
return (
<Form.Item
label={item.label}
name={item.name}
key={index}
rules={item.rules ? item.rules : []}
>
<Select
style={{ width: item.width ? item.width : '220px' }}
allowClear
placeholder={
item.placeholder ? item.placeholder : '请选择'
}
disabled={
item.disabled !== undefined
? item.disabled
: false
}
>
{item.option?.length > 0 &&
item.option.map((o, i) => {
return (
<Select.Option key={i} value={o.value}>
{o.label}
</Select.Option>
)
})}
</Select>
</Form.Item>
)
}
// 多选下拉框
if (item.type === 'selectMulti') {
return (
<Form.Item
label={item.label}
name={item.name}
key={index}
rules={item.rules ? item.rules : []}
>
<Select
style={{ width: item.width ? item.width : '220px' }}
mode="multiple"
allowClear
placeholder={
item.placeholder ? item.placeholder : '请选择'
}
disabled={
item.disabled !== undefined
? item.disabled
: false
}
onChange={(value) => {
item.onChange && item.onChange(value)
}}
>
{item.option?.length > 0 &&
item.option.map((o, i) => {
return (
<Select.Option
key={i}
value={o.value}
maxTagCount="responsive"
>
{o.label}
</Select.Option>
)
})}
</Select>
</Form.Item>
)
}
return ''
})
}
defaultFormDeal = (configs) => {
configs.forEach((item, index) => {
if (isEffectVar(item.default)) {
this.formRef.current.setFieldsValue({
[item.name]: item.default,
})
}
})
}
/* 重置 */
resetHandle = () => {
this.formRef.current.resetFields()
this.props.resetFn && this.props.resetFn() // 重置的回调
}
/* 查询 */
queryHandle = (extraData) => {
let data = this.formRef.current.getFieldsValue(true) // 返回的居然是浅拷贝 T^T
data = deepClone(data)
this.formRef.current
.validateFields()
.then((values) => {
this.queryDoing(data, extraData)
})
.catch((errorInfo) => {
return false // 返回不出去,默认有undefined判断
})
}
// 查询做的事
queryDoing = (data, extraData) => {
// 记录日期格式的子项的key
let dateTypeKeys = this.props.config.itemConfigs.map((item) => {
if (item.type === 'dateRange') {
return item.name
}
})
Object.keys(data).forEach((key) => {
// 把时间处理成后端需要的入参
if (!isEffectVar(data[key])) {
data[key] = ''
}
if (dateTypeKeys.includes(key)) {
let dateArr = [...data[key]]
if (dateArr.length) {
// 原始数据只有null或者[x,x]
data[key][0] = moment(dateArr[0]).format('YYYYMMDD')
data[key][1] = moment(dateArr[1]).format('YYYYMMDD')
}
}
})
// 如果必须要返回所有字段,空的就返回undefined
if (this.props.necessaryField) {
this.props.config.itemConfigs.forEach((item) => {
if (!isEffectVar(data[item.name])) {
data[item.name] = undefined
}
})
}
console.log('经过二次加工提交的原生数值', data)
this.props.queryFn && this.props.queryFn(data, extraData) // 重置的回调
}
getData = () => {
let data = this.formRef.current.getFieldsValue(true) // 返回的居然是浅拷贝 T^T
data = deepClone(data)
return data
}
validateFields = async () => {
const res = await this.formRef.current
.validateFields()
.catch((err) => null)
if (!res) {
return null
} else {
return this.getData()
}
}
/* input格式化 */
inputOnChange = (e, item) => {
let value = e.target.value
if (item.formatter === 'int') {
value = value.replace(/[^\d]/g, '')
}
this.formRef.current.setFieldsValue({
[item.name]: value,
})
}
onFinish = (values) => {
console.log('Success:', values)
this.queryHandle()
}
onFinishFailed = (errorInfo) => {
console.log('Failed:', errorInfo)
}
setFieldsValue = (data) => {
console.log('赋值', data)
this.formRef.current.setFieldsValue({
...data,
})
}
render() {
let { formItems, colSpan, formLayout, formItemLayout } = this.state
let { btnChildren } = this.props
return (
<div className="tfs-container">
{/* {...formItemLayout} */}
<Row>
<Col span={colSpan[0]}>
<Form
{...formItemLayout}
layout={formLayout}
ref={this.formRef}
onFinish={this.onFinish}
onFinishFailed={this.onFinishFailed}
className={
'tfs-table' + formLayout === 'horizontal'
? 'tfs-horizontal-table'
: ''
}
>
{/* 循环渲染子项 */}
{formItems.length > 0 &&
formItems.map((item) => {
return item
})}
<Button
ref={this.submitRef}
style={{ display: 'none' }}
htmlType="submit"
></Button>
</Form>
</Col>
{colSpan[1] !== 0 && (
<Col span={colSpan[1]}>
{/* 按钮区 */}
<div className="tfs-btn-container">
<Button
type={'link'}
onClick={this.resetHandle}
>
重置 <ReloadOutlined />
</Button>
<Button
type={'primary'}
className="it2-primary-button"
htmlType="submit"
onClick={() => {
this.submitRef.current.click()
}}
loading={this.props.isSearching}
>
查询
</Button>
{/* 按钮插槽 */}
{btnChildren ? btnChildren : null}
</div>
</Col>
)}
</Row>
{/* 纵向表单的自定义按钮 */}
{colSpan[1] === 0 && formLayout === 'horizontal' && (
<Row>{btnChildren ? btnChildren : null}</Row>
)}
</div>
)
}
}
所需工具函数
import moment, { isMoment } from "moment";
/* 是否是有效值 */
export function isEffectVar(val) {
return ![null, "", undefined].includes(val);
}
export function deepClone(target) {
let result;
if (typeof target === "object") {
if (Array.isArray(target)) {
result = [];
for (let i in target) {
result.push(deepClone(target[i]));
}
} else if (target === null) {
result = null;
} else if (isMoment(target)) {
result = target.clone();
} else if (target.constructor === RegExp) {
result = target;
} else {
result = {};
for (let i in target) {
result[i] = deepClone(target[i]);
}
}
} else {
result = target;
}
return result;
}
export function validateNumber(value) {
// 使用正则表达式验证输入值是否为数字
const reg = /^\d*$/;
if (!reg.test(value)) {
// 如果输入值不是数字,则清空输入框
return '';
}
return value;
}
使用配置例子:
formConfig: {
colSpan: [24, 0],
formLayout: 'horizontal',
formItemLayout: {
labelCol: {
span: 7,
},
wrapperCol: {
span: 17,
},
},
itemConfigs: [
{
label: '场景',
name: 'signScenType',
type: 'selectMulti',
option: [
{ label: '采购', value: 'P' },
{ label: '销售', value: 'S' },
{ label: '其他', value: 'O' },
],
width: '500px',
rules: [{ required: true, message: '请输入内容' }],
onChange: (value) => {
},
},
{
label: 'aaaaa',
name: 'signOtherScen', // signOtherScen?
width: '500px',
rules: [{ required: true, message: '请输入内容' }],
display: false,
},
{
label: '签署量(份/年)',
name: 'signVolume',
formatter: 'int',
width: '500px',
rules: [
{ required: true, message: '请输入内容' },
({ getFieldValue }) => ({
validator(_, value) {
if (value) {
if (Number(value) > 1000000000) {
return Promise.reject(
new Error('超出最大数值')
)
}
}
return Promise.resolve()
},
}),
],
},{
label: '联系人手机号',
name: 'contactNumber',
formatter: 'int',
width: '500px',
rules: [{ required: true, message: '请输入内容' }],
},
{
label: 'bbbbbb',
name: 'FDD',
width: '500px',
disabled: true,
default: 'aaa',
rules: [{ required: true, message: '请输入内容' }],
},
],
},
配置写的比较倡促,以后来写个完整的
vue的我之前也写过一个vue2版本的组件库例子:【业务组件二次封装】
表单做搜索区与表格怎么做联动
后台中有种页面场景很常见,就是一个表单搜索区+分页表格区+分页区(就如上面例子的图片)。这种组件怎么设计才好,并且尽量做到三者之间解耦。
还是用react举例(需要配合ahooks第三方库):
我们去设计一个这样的页面时,会用一个容器组件,里面引入表单组件,表格组件和分页组件。表单组件的值驱动着表格数据的获取(分页组件待会再说),我们首先看看表单组件怎么驱动最好。
咱们就举例一个最简单的表单,就只有一个搜索输入项,我们想做到的是:
- 刷新页面,表单原来填写的值能够回显
- 容器组件和表格组件都能拿到表单的值,且尽量解耦
如果我们把每次搜索时表单的值都更新在url上,其他组件通过router的hook就可以拿到表单值,页面刷新后表单组件也能获取到表单值进行回填。
import React, { FC, useEffect, useState } from 'react'
import type { ChangeEvent } from 'react'
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
import { Input } from 'antd'
import { LIST_SEARCH_PARAM_KEY } from '../constant'
const { Search } = Input
const ListSearch: FC = () => {
const nav = useNavigate()
const { pathname } = useLocation()
// 输入值
const [value, setValue] = useState('')
function handleChange(event: ChangeEvent<HTMLInputElement>) {
setValue(event.target.value)
}
// 获取 url 参数,回填表单数据
const [searchParams] = useSearchParams()
useEffect(() => {
const curVal = searchParams.get(LIST_SEARCH_PARAM_KEY) || ''
setValue(curVal)
}, [searchParams])
// 点击搜索
function handleSearch(value: string) {
// 跳转页面,增加 url 参数
nav({
pathname,
search: `${LIST_SEARCH_PARAM_KEY}=${value}`, // 去掉了 page pageSize
})
}
return (
<Search
allowClear
placeholder="输入关键字"
value={value}
onChange={handleChange}
onSearch={handleSearch}
/>
)
}
export default ListSearch
除了表单组件驱动表格数据,还有分页组件的分页数据也驱动着表格数据的获取,驱动方式也和表单组件一样即可:
import React, { FC, useEffect, useState } from 'react'
import { Pagination } from 'antd'
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom'
import { LIST_PAGE_SIZE, LIST_PAGE_PARAM_KEY, LIST_PAGE_SIZE_PARAM_KEY } from '../constant/index'
type PropsType = {
total: number
}
const ListPage: FC<PropsType> = (props: PropsType) => {
const { total } = props
const [current, setCurrent] = useState(1)
const [pageSize, setPageSize] = useState(LIST_PAGE_SIZE)
// 从 url 参数中找到 page pageSize ,并且同步到 Pagination 组件中
const [searchParams] = useSearchParams()
useEffect(() => {
const page = parseInt(searchParams.get(LIST_PAGE_PARAM_KEY) || '') || 1
setCurrent(page)
const pageSize = parseInt(searchParams.get(LIST_PAGE_SIZE_PARAM_KEY) || '') || LIST_PAGE_SIZE
setPageSize(pageSize)
}, [searchParams])
// 当 page pageSize 改变时,改变 url 对应参数
const nav = useNavigate()
const { pathname } = useLocation()
function handlePageChange(page: number, pageSize: number) {
searchParams.set(LIST_PAGE_PARAM_KEY, page.toString())
searchParams.set(LIST_PAGE_SIZE_PARAM_KEY, pageSize.toString())
nav({
pathname,
search: searchParams.toString(), // 除了改变 page pageSize 之外,其他的 url 参数要带着
})
}
return (
<Pagination current={current} pageSize={pageSize} total={total} onChange={handlePageChange} />
)
}
export default ListPage
我们可以把表格数据的获取放在容器组件中,只需要监听路由的变化即可,还可以专门抽离成一个hook:
import { useSearchParams } from 'react-router-dom'
import { useRequest } from 'ahooks'
import { getQuestionListService } from '../services/question'
import {
LIST_SEARCH_PARAM_KEY,
LIST_PAGE_PARAM_KEY,
LIST_PAGE_SIZE_PARAM_KEY,
LIST_PAGE_SIZE,
} from '../constant/index'
// 可能多个表单表格页面功能是一样的,可以复用,用传入的类型来区分,但是为了业务拓展性,建议还是分开写hook
type OptionType = {
isStar: boolean
isDeleted: boolean
}
function useLoadQuestionListData(opt: Partial<OptionType> = {}) {
const { isStar, isDeleted } = opt
const [searchParams] = useSearchParams()
const { data, loading, error, refresh } = useRequest(
async () => {
// 从url中解构出入参
const keyword = searchParams.get(LIST_SEARCH_PARAM_KEY) || ''
const page = parseInt(searchParams.get(LIST_PAGE_PARAM_KEY) || '') || 1
const pageSize = parseInt(searchParams.get(LIST_PAGE_SIZE_PARAM_KEY) || '') || LIST_PAGE_SIZE
const data = await getQuestionListService({ keyword, isStar, isDeleted, page, pageSize })
return data
},
{
refreshDeps: [searchParams], // 刷新的依赖项
}
)
return { data, loading, error, refresh } // 返回接口数据、是否正在请求中的状态、错误情况、手动更新函数(参数不变)
}
export default useLoadQuestionListData
代码设计来自双越老师的视频
推荐的表单库
react中推荐react-hook-form,formik,这俩个都很强大,但是其实antd提供的表单已经能满足大部分的需求了。
vue目前我只知道饿了么。
关于低代码表单
这玩意个人认为只能用于非常固定的业务场景,例如一般的问卷调查,他就是非常固定的一些表单输入项,后期也不会加复杂的东西,那就非常适合用低代码去搭一个后台。
我还遇到过一个用法,就是之前待过的一个公司里后台管理很多表单用的也是低代码生成的。机制是这样的这些表单配置都是在后台配出来的,然后前端每次渲染页面的时候会通过接口拉取表单的json配置数据,前端开发人员把配置数据传入对应的低代码组件,然后这个低代码组件有一些拓展功能要开发人员自己调试。这样做出来的表单的一个好处是,当项目上线了,突然要改表单的某些选项,直接后台通过低代码把对应的输入项做调整就可以了,完全不用再去改项目代码,反应迅速。
掘金上有篇讨论可以看看【低代码:现在我怎么样了】
关于低代码的开源项目参考:
- 这个比较成熟商用级别的:https://lowcode-engine.cn/index
- 这个比较适合用来学习简单低代码的搭建:https://buqiyuan.gitee.io/vite-vue3-lowcode/#/
关于动态表单
动态表单我个人的方案是【业务组件二次封装】里,把所有要显示的表单项id维护在一个数组中,公共组件根据这个数组去显示对应的表单。
具体可以看PzFormGeneral.vue里的displayList部分
这种方式的好处就是很灵活,基本满足所有场景,不好的地方就是每个能触发动态表单切换的表单项都要维护一个数组,稍显麻烦。
另外一种方式是从渡一教育哪里看过来的,每个组件的配置都有一个属性,这个属性是一个函数,他内部处理对应逻辑返回下一个要显示或者隐藏的表单项配置。
就类似链表的设计了,好处就是简单,都维护在配置项中,不好的地方在于链表关系,无法做到第2项影响第4项之后的表单项。
一些数据上的处理技巧
数组转字符串
有时候我们表单中的某个输入项拿到的值是一个数组,但是后端需要的是用逗号分开区分的字符串。咱们可以直接:
let arr = [1,2,3]
console.log(arr.toString()) // '1,2,3'
console.log(arr.join(',')) // '1,2,3'
日期的转换
咱们用组件库表单的数据获取api拿到的日期输入项的数据一般都是带有组件定义的格式的,例如ant-design拿到的一般是个Moment对象,后端可能需要的是yyyy-dd-mm
的格式,这些我们都可以提前准备好公共函数统一处理的。文章来源:https://www.toymoban.com/news/detail-470791.html
类似的不止日期啦,学会举一反三文章来源地址https://www.toymoban.com/news/detail-470791.html
到了这里,关于【react框架】结合antd做表单组件的一些心得记录的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!