0%

复杂大表单的处理

不积跬步,无以至千里;不积小流,无以成江河。 文章我就喜欢读长的,表单我也要处理大的。 近一年先后经历过的两个项目都有大的复杂表单处理需求。 走过路过,没有错过,借着经验留下点足迹。

提纲:

  • 问题
  • 怎么做
  • 举证
  • 代码编写过程和过程中发现&处理的问题
  • 升级为Hooks方案后需要的改进
  • 总结

表单处理是前端比较常见的需求,尤其是B端。表单处理大概可以分为基础表单(常见于数据项较少的表单场景)、分步表单(将一个冗长或用户不熟悉的表单任务分成多个步骤,指导用户完成)、比较复杂的高级大表单(根据开发业务的需要,进行大批量的、类型繁杂的数据处理表单)。这里结合我做的项目,介绍下比较复杂的大表单怎么处理。

问题

当表单项足够多时我们可以选择分步表单处理,但是当表单项之间关联或者说联动也比较多时就不是简单的通过分步就能化繁为简的了。我处理的项目多是不能通过分步简化的,所以就有了处理复杂大表单的需要。
那么如何实现一个可拆分复用、易操作伸缩、能组合通用的复杂大表单?

怎么做

可拆分复用: 大表单首先必须要能进行拆分,我们通过一套自定义约定来进行父子组件的通讯,以满足拆分复用的需要。这里我们约定父组件调用子组件时要传入values & onChange属性,作为子组件的渲染数据和父子组件通信函数使用。拆出来的公共部分就可以复用。

易操作伸缩: 复杂表单数据处理的过程中经常需要Modal或者Drawer的协助,我们可以把Modal或者Drawer中的表单元素用所用组件库的Form组件进行包裹,让这个Form收缩成父组件的元素——我称之为Form as Form.Item,这样就不用每次子组件某个数据变更时都onChange通知父组件了。我们可以在对Modal或者Drawer进行确定、提交操作时,在父组件中通过Form引用获取子组件的Form数据,这在代码逻辑上更加易于操作。当然Modal或者Drawer本身包含的是最外层的表单不用。

能组合通用: 需要我们能把拆分的组件或者组件库组件根据需要组合成一个新的组件后,仍能按照上边两条进行操作处理。其实这个过程最终会变成父组件对子组件传递的各种情况数据的支持,需要父组件能通配处理简单的原始类型、普通对象、普通数组、组合的复杂对象数组等。

举证

可拆分复用:

下边为摘出来的部分模块需求,我们拆分出来了规格列表SpecList组件,父组件调用时传入了渲染数据(供初始化或者数据变更渲染)和通信函数(onChange),子组件在数据变更时调用通信函数,数据到了父组件可以继续向上,层层反馈到最终的Form供表单提交使用,也可以用于和其他组件作数据联动(这个组件其实就和下边的SkuTable组件有联动),拆出来的组件有需要可以在多个地方复用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
/*
* SpecList组件
*/

handleDeleteItem = removeItem => {
return () => {
const { attributes } = this.state
const newAttributes = attributes.filter(item => item.key !== removeItem.key)
this.setState({ attributes: newAttributes })
const { onChange } = this.props
if (onChange) {
onChange(newAttributes)
}
}
}

handleSubmitItem = () => {
const { attributes, currentItem, attributeList } = this.state
const { form } = this.formRef.props
form.validateFields((err, values) => {
const { specCategory, keys, specs } = values
if (err) {
return
}
...
const { onChange } = this.props
if (onChange) {
onChange(newAttributes)
}
})
}

return (
<Card bordered={false} bodyStyle={{ paddingLeft: 0 }}>
{attributes.length < 3 ? (
<Button type="dashed" style={{ width: '100%', marginBottom: 8 }} icon="plus" onClick={this.handleNewItem}>
添加规格类别
</Button>
) : (
''
)}
<List
size="large"
rowKey="id"
dataSource={attributes}
// itemLayout="vertical"
renderItem={(item, index) => (
<List.Item
key={index}
actions={[
<a onClick={this.handleEditItem(item, index)}>编辑</a>,
<Popconfirm
title="删除该规格后,当前的规格相关数据将丢失,确认要删除吗?"
onConfirm={this.handleDeleteItem(item)}
okText="确定"
cancelText="取消"
>
<a>删除</a>
</Popconfirm>,
]}
>
<ListContent data={item} />
</List.Item>
)}
/>
<Modal
destroyOnClose
visible={dialogVisible}
title="编辑规格"
onOk={this.handleSubmitItem}
onCancel={this.handleCancelSubmit}
width={800}
>
<SpecEditor initialValue={{ currentItem, attributeList }} wrappedComponentRef={this.saveFormRef} />
</Modal>
</Card>
)

父组件调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<Form.Item label="规格">
{getFieldDecorator('specList', {
initialValue: [],
rules: [{ required: true, message: '请添加规格类型' }],
})(
<>
{attributes && attributes.length === 0 ? (
<Button onClick={this.toggleSpec}>取消添加规格</Button>
) : (
''
)}
<SpecList
attributes={attributes}
attributeList={attributeList}
onChange={this.handleSpecListChange}
/>
</>
)}
</Form.Item>

易操作伸缩:

上边SpecList组件中的Modal包裹了这个组件,点击确定或者提交时,Modal就可以在onOk属性定义的this.handleSubmitItem函数的调用中,通过引用获取下边SpecEditor组件里Form表单的内容。弹窗里边的Form就相当于收缩成了外边Form的Form.Item,有拆解需要的拆解as Form.Item的Form就是伸展过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
* SpecEditor组件
*/
return (
<Card bordered={false}>
<Form>
<Form.Item label="规格分类" hasFeedback>
{getFieldDecorator('specCategory', {
initialValue: specCategory,
})(
<Select style={{ width: '60%', marginRight: 8 }} placeholder="请选择">
{attributeList &&
attributeList.map(item => (
<Option value={item.name} key={item.id}>
{item.name}
</Option>
))}
</Select>
)}
</Form.Item>
<Form.Item label="添加图片">
{getFieldDecorator('isAddPicture', { valuePropName: 'checked', initialValue: isAddPicture })(
<Switch disabled={!canAddPicture} />
)}
</Form.Item>
{formItems}
<Form.Item>
<Button type="dashed" onClick={this.add} style={{ width: '60%' }}>
<Icon type="plus" /> 添加规格
</Button>
</Form.Item>
</Form>
<Modal visible={previewVisible} footer={null} onCancel={this.handleCancel}>
<img alt="example" style={{ width: '100%' }} src={previewImage} />
</Modal>
</Card>
)

能组合通用:

下边Table是由<InputNumber /><Input /><Select />等组成,Table产生的数据是由对象组成的数组,对象由字符串、数字组成。当子组件产生其他形式的数据组合时,父组同样可以处理,这样的组合可以非常灵活,任何你想要的组件或者数据的组合都可以得到支持。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
/*
* SkuTable 组件
*/
getColumns = () => {
const renderCurrentPrice = (text, record) => {
if (record.editable) {
return (
<InputNumber
value={text}
autoFocus
min={0}
precision={2}
onChange={value => this.handleFieldChange(value, 'currentPrice', record.key)}
onKeyPress={e => this.handleKeyPress(e, record.key)}
/>
)
}
return text
}

const renderOriginalPrice = (text, record) => {
if (record.editable) {
return (
<InputNumber
value={text}
min={0}
precision={2}
onChange={value => this.handleFieldChange(value, 'originalPrice', record.key)}
onKeyPress={e => this.handleKeyPress(e, record.key)}
/>
)
}
return text
}

const renderStock = (text, record) => {
if (record.editable) {
return (
<InputNumber
value={text}
min={0}
precision={0}
onChange={value => this.handleFieldChange(value, 'stock', record.key)}
onKeyPress={e => this.handleKeyPress(e, record.key)}
/>
)
}
return text
}

const renderSkuCode = (text, record) => {
if (record.editable) {
return (
<Input
value={text}
onChange={e => this.handleFieldChange(e.target.value, 'skuCode', record.key)}
onKeyPress={e => this.handleKeyPress(e, record.key)}
/>
)
}
return text
}

const renderActions = (text, record) => {
const { loading } = this.state
if (!!record.editable && loading) {
return null
}
if (record.editable) {
if (record.isNew) {
return (
<span>
<a onClick={e => this.saveRow(e, record.key)}>添加</a>
<Divider type="vertical" />
<Popconfirm title="是否要删除此行?" onConfirm={() => this.remove(record.key)}>
<a>删除</a>
</Popconfirm>
</span>
)
}
return (
<span>
<a onClick={e => this.saveRow(e, record.key)}>保存</a>
<Divider type="vertical" />
<a onClick={e => this.cancel(e, record.key)}>取消</a>
</span>
)
}
return (
<span>
<a onClick={e => this.toggleEditable(e, record.key)}>编辑</a>
<Divider type="vertical" />
<Popconfirm title="是否要删除此行?" onConfirm={() => this.remove(record.key)}>
<a>删除</a>
</Popconfirm>
</span>
)
}

const renderSpecField = (text, record, item, index) => {
if (record.editable) {
return (
<Select
value={text}
onChange={value => {
// `specList[${index}].attributeValueName`
this.handleFieldChange(value, index, record.key)
}}
>
{item.keys.map(key => (
<Option value={item.specs[key].specName} key={item.specs[key].specName}>
{item.specs[key].specName}
</Option>
))}
</Select>
)
}
return text
}

const renderWeight = (text, record) => {
if (record.editable) {
return (
<InputNumber
value={text}
min={0}
precision={2}
onChange={value => this.handleFieldChange(value, 'weight', record.key)}
onKeyPress={e => this.handleKeyPress(e, record.key)}
/>
)
}
return text
}

const columns = [
{
title: '* 现价',
dataIndex: 'currentPrice',
key: 'currentPrice',
width: 90,
render: renderCurrentPrice,
},
{
title: '原价',
dataIndex: 'originalPrice',
key: 'originalPrice',
width: 90,
render: renderOriginalPrice,
},
{
title: '* 库存',
dataIndex: 'stock',
key: 'stock',
width: 90,
render: renderStock,
},
{
title: '商品编码',
dataIndex: 'skuCode',
key: 'skuCode',
width: 150,
render: renderSkuCode,
},
{
title: '操作',
key: 'action',
width: 120,
render: renderActions,
},
]

console.log('getColumn specList: ', this.state.data, this.state.specList)
// 循环规格列表将每个规格项插入columns,
this.state.specList.forEach((item, index) => {
// 遍历的是规格类别
if (item) {
const columnItem = {
title: `*${item.specCategory}`,
dataIndex: `specList[${index}].attributeValueName`, // 对应的skus中的specList
key: index,
width: 160,
render: (text, record) => renderSpecField(text, record, item, index),
}
columns.unshift(columnItem)
}
})

// 根据运费在倒数第二个插入列
const { isWeightItemShow } = this.props
if (isWeightItemShow) {
// 在倒数第二列插入重量
columns.splice(columns.length - 1, 0, {
title: '* 重量',
dataIndex: 'weight',
key: 'weight',
render: renderWeight,
})
}
return columns
}

render() {
const { loading, data } = this.state
return (
<div>
<Table
loading={loading}
columns={this.getColumns()}
dataSource={data}
pagination={false}
rowClassName={record => (record.editable ? 'editable' : '')}
/>
<Button
style={{ width: '100%', marginTop: 16, marginBottom: 8 }}
type="dashed"
onClick={this.newItem}
icon="plus"
>
新增一项
</Button>
</div>
)
}

父组件的调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
<Form.Item label="价格与库存">
{getFieldDecorator('skuTable', {
initialValue: [],
rules: [{ required: true, message: '请添加、保存信息' }],
})(
<SkuTable
specList={attributes}
skus={skus}
isWeightItemShow={isWeightItemShow}
onChange={this.handleSkuTableChange}
/>
)}
</Form.Item>

代码编写过程和过程中发现&处理的问题

  1. 拆分组件:比如上边拆分的SpecList规格组件,SkuTable价格与库存组件,SpecEditor组件等
  2. 初始化组件:需要后台数据做初始化的,请求后台接口,获取到数据后把数据拆合处理成前端需要结构的数据,再分配到对应的组件,不需要的传空数据或者视情况传递需要的数据
  3. 拆分的组件对各自的数据做个性化处理
    案例:
    SkuTable 组件产生数据是个list,处理list item数据时触发的动作有:编辑、删除;添加、删除;保存、取消
  4. 和父组件通信:拆分的组件,更新数据时,验证通过后通知父组件(onChange);删除、编辑数据(会更新数据的编辑状态)时通知父组件(onChange),父组件收到数据后作进一步的传递和联动,未拆分组件部分的数据在总表单提交时获取。
  5. 提交表单:汇总表单未拆分出组件部分和拆件为组件部分的数据,作必要的校验,按接口结构组合数据,发起请求,重置表单等。

问题1: 上边动作的处理过程中,对item的定位,交互展示,编辑态等该怎么处理?
答: 对新增数据(newItem)或初始化的数据加入额外标识字段方便管理
比如加入这些字段 {key: ‘NEW_TEMP_ID_${this.index}’、isNew: true、editable: true}。
key: item定位唯一标识符。list数据增删会造成item定位混乱,给list item加key后无论如何增删编辑item,都可以通过key对item准确定位 。
isNew: 是用来区分交互的。true为添加item,false为修改item。
editable:标识是否正在编辑item数据。在进入编辑状态时要cache item原始数据,这样取消编辑还能找回;如果有item数据还处于编辑状态,则无法提交表单,提交时给出相应提示。

问题2: 数据校验应该是什么思路?
答: list item数据在保存时就要进行合理性验证,分条逐级校验,这样可以避免表单整体提交时大范围校验的复杂度。

问题3: 拆分的组件通常包含的方法有哪些?
答:
常包含的方法有增加、删除、修改、保存,
有Modal的话在父组件中还会有 submit行为处理 和 取消submit行为处理的方法,
通用的list item定位方法(eg. getRowByKey)。
下边列出SkuTable组件渲染之外的代码,供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
/*
* SkuTable组件
*/
class SkuTable extends React.Component {
constructor(props) {
super(props)
this.index = 0
this.cacheOriginData = {}
// 父组件传过来需要有这两个值
const { specList = [], skus = [] } = props
const mySkus = this.generateMySkus(skus)
this.state = {
data: mySkus,
specList,
value: mySkus,
loading: false,
}
}

componentWillReceiveProps(nextProps, nextContext) {
console.log('SkuTable: ', this.state, this.props, nextProps)
const { skus, specList } = this.props
if (!isEqual(specList, nextProps.specList)) {
this.setState({
specList: nextProps.specList,
})
}
if (!isEqual(skus, nextProps.skus)) {
this.index = 0
const mySkus = this.generateMySkus(nextProps.skus)
console.log('SkuTable111:', nextProps.skus, mySkus)
this.setState({
data: mySkus,
value: mySkus,
})
}
}

generateMySkus(skus = []) {
return skus.map(item => {
item.key = `NEW_TEMP_ID_${this.index}`
this.index += 1
return item
})
}

getRowByKey(key, newData) {
const { data } = this.state
return (newData || data).filter(item => item.key === key)[0]
}

/**
* 切换当前行的编辑状态
* @param e
* @param key
*/
toggleEditable = (e, key) => {
e.preventDefault()
const { data } = this.state
const newData = data.map(item => ({ ...item }))
const target = this.getRowByKey(key, newData)
if (target) {
// 进入编辑状态时保存原始数据
if (!target.editable) {
this.cacheOriginData[key] = { ...target }
}
target.editable = !target.editable
this.setState({ data: newData })
const { onChange } = this.props
if (onChange) {
onChange(newData)
}
}
}

/**
* 新增一项
*/
newItem = () => {
const { data = [], specList = [] } = this.state
const newData = data.map(item => ({ ...item }))

// 把规格分类转换为新增项的specList数据项
const skusSpecList = specList.map(item => ({
attributeName: item.specCategory,
attributeValueName: null,
}))
newData.push({
key: `NEW_TEMP_ID_${this.index}`,
originalPrice: null,
currentPrice: null,
stock: null,
skuCode: '',
editable: true,
isNew: true,
specList: skusSpecList || [], // 类别的列表
})
this.index += 1
this.setState({ data: newData })
}

/**
* 删除当前行
* @param key
*/
remove = key => {
const { data } = this.state
const newData = data.filter(item => item.key !== key)
this.setState({ data: newData })
const { onChange } = this.props
if (onChange) {
onChange(newData)
}
}

/**
* 处理回车操作,保存当前行
* @param e
* @param key
*/
handleKeyPress(e, key) {
if (e.key === 'Enter') {
this.saveRow(e, key)
}
}

handleFieldChange(value, fieldName, key) {
const { data } = this.state
const newData = data.map(item => ({ ...item }))
const target = this.getRowByKey(key, newData)
if (target) {
if (typeof fieldName === 'number') {
target.specList[fieldName].attributeValueName = value
} else {
target[fieldName] = value
}
this.setState({ data: newData })
}
}

/**
* 保存当前行
* @param e
* @param key
*/
saveRow = (e, key) => {
e.persist()
this.setState({
loading: true,
})
setTimeout(() => {
if (this.clickedCancel) {
this.clickedCancel = false
return
}
const target = this.getRowByKey(key) || {}
const isInvalid = target => {
const { isWeightItemShow } = this.props
const { specList: stateSpecList } = this.state
const { stock, currentPrice, originalPrice, weight } = target
let isSpecInvalid = false
console.log('saveRow: ', target, stateSpecList)
target.specList.forEach(spec => {
if (!spec.attributeValueName) {
isSpecInvalid = true
}
})
if (stateSpecList.length === 0) {
message.error('请添加规格后再进行操作')
return true
}
if (
(!stock && stock !== 0) ||
(!currentPrice && currentPrice !== 0) ||
isSpecInvalid ||
(isWeightItemShow && !weight && weight !== 0)
) {
message.error('请检查现价、库存等必填项是否填写')
return true
}
if (target.currentPrice > target.originalPrice) {
message.error('现价不能高于原价')
return true
}
}
if (isInvalid(target)) {
e.target.focus()
this.setState({
loading: false,
})
return
}
delete target.isNew
this.toggleEditable(e, key)
const { data } = this.state
const { onChange } = this.props
if (onChange) {
onChange(data)
console.log('SkuTable onChange: ', data)
}
this.setState({
loading: false,
})
}, 500)
}

/**
* 取消编辑
* @param e
* @param key
*/
cancel(e, key) {
this.clickedCancel = true
e.preventDefault()
const { data } = this.state
const newData = data.map(item => ({ ...item }))
const target = this.getRowByKey(key, newData)
if (this.cacheOriginData[key]) {
Object.assign(target, this.cacheOriginData[key])
delete this.cacheOriginData[key]
}
target.editable = false
this.setState({ data: newData })
this.clickedCancel = false
}

getColumns = () => {
// 见文章上边
}

render() {
// 见文章上边
}
}

export default SkuTable

升级为Hooks方案后做的一些改进

下边我在子组件中主动对父暴露了两个方法(就是说没有通过onChange方法和父组件通信),这样的话父组件可以像前边的Modal一样通过子组件的引用调用到子组建暴露的这两个方法,这样做子组件和父的耦合更小,封装的更好。

函数组件给父组件暴露方法的方式和类组件不同,核心思路如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import React, { useRef, forwardRef, useImperativeHandle } from 'react';

function FancyInput(props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({ // 在使用 ref 时自定义暴露给父组件的实例值
focus: (value) => {
inputRef.current.focus();
},
showValue: value => {
alert(111 + value.toString())
}
}));
return <input ref={inputRef} />;
}

const NewFancyInput = forwardRef(FancyInput);

export default () => {
const fancyInputRef = useRef();
return (
<>
<button onClick={
() => {
fancyInputRef.current.showValue(true);
fancyInputRef.current.focus();
}
}>点我focus</button>
<NewFancyInput ref={fancyInputRef}></NewFancyInput>
</>
)
};

总结

通过上边的讲解,我相信你已经有了应对大表单的处理思路。实际上上边方法的扩展性很强,理论上可以处理任何大的表单,所以你准备好了吗😊。