若只需要实现跨行表格可以查看此文章
话不多说,先上效果,用户选中不同选项时,表格就行不同的更迭,其中进价与售价为固定项,每一行的进价可以自定义填写
先准备如下数据(为了方便识别这里用枚举类型EItem来表示form的类型)
export enum EItem {
scheme = 'scheme',
attr1 = 'attr1',
attr2 = 'attr2',
attr3 = 'attr3',
}
// form表单
export const formGroup: { label: string, key: EItem }[] = [ { label: '套餐', key: EItem.scheme }, { label: '内存', key: EItem.attr1 }, { label: '颜色', key: EItem.attr2 }, { label: '运行内存', key: EItem.attr3 },]
// 选项内容
export const selectOptions: Partial<Record<EItem, { label: string, value: number }[]>> = {
[EItem.scheme]: [
{ label: '套餐一', value: 11 },
{ label: '套餐二', value: 12 },
],
[EItem.attr1]: [
{ label: '32G', value: 4 },
{ label: '64G', value: 5 },
{ label: '128G', value: 6 },
],
[EItem.attr2]: [
{ label: '红色', value: 1 },
{ label: '黄色', value: 2 },
{ label: '绿色', value: 3 },
],
[EItem.attr3]: [
{ label: '32G', value: 7 },
{ label: '64G', value: 8 },
{ label: '128G', value: 9 },
],
}
接着简单写一个form表单数据如下
const form = reactive<Partial<Record<EItem, number[]>>>({
[EItem.scheme]: [11],
[EItem.attr1]: [4, 5],
[EItem.attr2]: [],
[EItem.attr3]: [7, 9],
})
筛选要展示的列
首先通过form筛选出要展示的列,至少选中其中一项才会展示对应的列(如上方的颜色没有选中,故而不会展示),这里逻辑比较简单 就是要筛选出有选中项的数据
function getDynamicColumn(): TColumnProps {
return formGroup
.filter((item) => form[item.key]!.length > 0)
.map((item) => {
return {
dataIndex: item.key,
title: item.label,
}
}) as TColumnProps
}
form的value与label映射,以便后续通过选中值找到中文名字
// 设置form的value与label映射
const formValueLabelMap: Record<number, string> = {}
const selecKeys = Object.keys(selectOptions) as EItem[]
selecKeys.forEach((key: EItem) => {
const valueList = selectOptions[key]
valueList!.forEach((item) => {
formValueLabelMap[item.value as number] = item.label
})
})
然后监听form表单做如下操作,
watch(
() => form,
() => {
dynamicColumn = getDynamicColumn()
resultList.value = permuteForm()
resultColumns.value = getColumns()
},
{
deep: true,
immediate: true,
},
)
permuteForm 生成表格数据
先拿到上一步动态列中的所有key值
const keys: EItem[] = dynamicColumn.map((item) => item.dataIndex as EItem)
接下来
将选中的key进行排列组合得到我们需要的所有组合类型
let result: number[][] = [[]]
for (const key of keys) {
const values = form[key]
const newResult: number[][] = []
for (const prevCombination of result) {
for (const value of values!) {
newResult.push([...prevCombination, value])
}
}
result = newResult
}
比如我们这个选项就会生成如下结果
const form = reactive<Partial<Record<EItem, number[]>>>({
[EItem.scheme]: [11],
[EItem.attr1]: [4, 5],
[EItem.attr2]: [],
[EItem.attr3]: [7, 9],
})
[
[11, 4, 7],
[11, 4, 9],
[11, 5, 7],
[11, 5, 9]
]
最后我们把产生的result整合设置成表格数据
return result.map((combination, index) => {
const obj: { [key: string]: unknown } = { price: 0, price2: 0 }
keys.forEach((key, keyIndex) => {
obj[key] = formValueLabelMap[combination[keyIndex]]
})
return obj
})
result形成出来的数组中的项就会如下
{
attr1: '32G',
attr3: '32G',
price: 0,
price2: 0,
scheme: '套餐一',
}
存在的问题
但是这个过程存在一个问题,就是加入我填好了商品的价格,然后修改form的时候就会导致填好的价格被清除掉,这个情况我们需要斟酌一下
分两个情况
- 修改form然后不改变行数
- 修改form然后不改变行数
如下
加了个颜色--不会影响结果
加了个内存--会影响结果
对于行数不改的情况 我们直接从行数据中拿到对于的索引赋值即可,
如下
return result.map((combination, index) => {
const obj: { [key: string]: unknown } = { price: 0, price2: 0 }
obj.price = resultList.value[index].price as number
obj.price2 = resultList.value[index].price2 as number
keys.forEach((key, keyIndex) => {
obj[key] = formValueLabelMap[combination[keyIndex]]
})
return obj
})
对于会改变的数据,我们需要找到之前这一行数据的值,然后给原原本本还原回去
事实上换个角度分析一下,是另外两种情况
- 修改行
- 修改列
且两者不会同时存在,
我们知道之前每一列排列组合出来的result的每一个数组都是唯一的,如下
[
[11, 4, 7],
[11, 4, 9],
[11, 5, 7],
[11, 5, 9]
]
于是我们可以将这些属性的key值通过join合并起来,接下来用来映射每一条数据,从而将其缓存,如下
let colsTableMap: Record<string, Record<string, unknown>> = {}
function permuteForm(): Record<string, unknown>[] {
...
let type: 'add' | 'del' | 'udpate' = 'add'
if (resultList.value.length === result.length) {
type = 'udpate'
}
// 需要重置原有的值
const colsTableMapCpoy = colsTableMap
colsTableMap = {}
return result.map((combination, index) => {
const obj: { [key: string]: unknown } = { price: 0, price2: 0 }
const colsValue = combination.join('')
// 行数不变属于update,赋值原有的数据
if (type === 'udpate') {
obj.price = resultList.value[index].price as number
obj.price2 = resultList.value[index].price2 as number
} else if (type === 'add') {
// 这一行若是完全对应之前的行则直接返回
if (colsTableMapCpoy[colsValue]) {
colsTableMap[colsValue] = colsTableMapCpoy[colsValue]
return colsTableMap[colsValue]
}
}
keys.forEach((key, keyIndex) => {
obj[key] = formValueLabelMap[combination[keyIndex]]
})
colsTableMap[colsValue] = obj
return obj
})
}
这一部分的代码抽离如下
// 设置form的value与label映射
const formValueLabelMap: Record<number, string> = {}
const selecKeys = Object.keys(selectOptions) as EItem[]
selecKeys.forEach((key: EItem) => {
const valueList = selectOptions[key]
valueList!.forEach((item) => {
formValueLabelMap[item.value as number] = item.label
})
})
// 需要动态设置的列
dynamicColumn = getDynamicColumn()
// 缓存表格数据
let colsTableMap: Record<string, Record<string, unknown>> = {}
function permuteForm(): Record<string, unknown>[] {
if (dynamicColumn.length === 0) {
return []
}
const keys: EItem[] = dynamicColumn.map((item) => item.dataIndex as EItem)
let result: number[][] = [[]]
// 将选中的数据就行排列组合
for (const key of keys) {
const values = form[key]
const newResult: number[][] = []
for (const prevCombination of result) {
for (const value of values!) {
newResult.push([...prevCombination, value])
}
}
result = newResult
}
//方便后续复用用户修改时就行的判断
let type: 'add' | 'del' | 'udpate' = 'add'
if (resultList.value.length === result.length) {
type = 'udpate'
}
const colsTableMapCpoy = colsTableMap
colsTableMap = {}
return result.map((combination, index) => {
const obj: { [key: string]: unknown } = { price: 0, price2: 0 }
const colsValue = combination.join('')
if (type === 'udpate') {
obj.price = resultList.value[index].price as number
obj.price2 = resultList.value[index].price2 as number
} else if (type === 'add') {
if (colsTableMapCpoy[colsValue]) {
colsTableMap[colsValue] = colsTableMapCpoy[colsValue]
return colsTableMap[colsValue]
}
}
keys.forEach((key, keyIndex) => {
obj[key] = formValueLabelMap[combination[keyIndex]]
})
// 通过选型构成的值缓存数据
colsTableMap[colsValue] = obj
return obj
})
}
getColumns整合columns
如下
function getColumns() {
const columnSpans = getSpansColumn()
const columnConfig = calculateRowSpans(columnSpans)
const column = generateMergedColumns(columnConfig)
return column
}
最后将计算出来的数据用上一版本提到的思路就行行的合并即可,文章链接
进阶
上一讲的方法还是很暴力的
只要修改了了form表单,整个column重新计算,然后所有要创建表单,虽然用数据进行了缓存,但也是要遍历所有单元格。下面分享一下分情况讨论来进行优化
首先是第一次进入的时候,这里的行和列是要完整生成,这个无可避免
关键是再form的变化选择上进行的改变,即update时
我们将情况归类为以下类型
- 删除行数据,即某个属性有多项,然后删掉了其中一项
- 添加行数据,即某个属性有多项,然后增加了其中一项
- 只存在列更新,即某个属性原先没有,然后增加了一个选项;或者原先只有一项,现在给删除没了
只存在列更新这个可能不好理解,下面给个图说明一下
于是在修改时我们做如下操作,在数据更新时分三种情况就行判断
let oldForm = JSON.parse(JSON.stringify(form)) // 进入页面时就把oldForm保存
// 以下时更新时的操作
let dynamicIndex = 0
// 查看每个formitem的选择情况
for (let i = 0; i < formGroup.length; i++) {
const { key } = formGroup[i]
const oldLength = oldForm[key].length
const newLength = form[key].length
//记录旧值中的位置
if (oldLength > 0) {
dynamicIndex++
}
// 增加或删除一个form选项都是更行列
if (newLength + oldLength === 1) {
return onlyColumnUpdate(key, oldLength, dynamicIndex)
} else if (oldLength !== newLength) {
// 否则就是删除或增加行
return oldLength > newLength
? delRow(key, dynamicIndex)
: addSomeRow(formGroup[i - 1]?.key, key, newLength, dynamicIndex)
}
}
delRow
删除行数据的情况我们只需要对比form表单的变化情况,将被删除的那一项的key与对应选项的value找出,找到record[key]===value的项去除即可。
例如开头gif中删除了颜色的红色选项,则对比前后form,找到被删除的是颜色选项,在找到被删除的是红色,接着便利数组,表格数据中存在红色的行进行删除。
m:list.length
之前:时间复杂度a * b * c * d (排列组合所有行)abcd为每一个选项的下拉个数+m+构造列
现在: 时间复杂度a+b+c+d + 构造列+m
/**
* 删除行
*/
function delRow(key: EItem, dynamicIndex: number): TColumnUPdateArg {
const delValue = findDelValue(oldForm[key], form[key])
resultList.value = resultList.value.filter((item) => {
return item[key] !== formValueLabelMap[delValue]
})
updateOldForms()
return {
handlerType: 'ListUpdate',
key,
index: dynamicIndex,
}
}
// list1长 list2短
function findDelValue(list1: number[], list2: number[]) {
let l = 0,
r = 0
while (l < list1.length && r < list2.length) {
if (list1[l] === list2[r]) {
l++
r++
} else {
return list1[l]
}
}
return list1[l]
}
返回值是用在后续统一处理行的合并时使用的的,这里只处理了行,但对于column中的rowspan还未做处理
addSomeRow
以前 时间复杂度a * b * c * d (排列组合所有行)abcd为每一个选项的下拉个数+m+构造列
现在 a+b+c+d+m+构造列
添加行数据
先说一下原理
把表单中每一项拉出来比较,其中某一项的选中值在新旧表单中不一致,且旧表单选中数量较小则触发addSomeRow
需要如下参数
/**
* form改变导致添加多行
* @param {EItem|undefined} keyBefore 前一列的key ,
* @param key 影响到的key
* @param newLength 修改后from长度
* @param dynamicIndex 影响到的列索引
*/
我们要在原有的基础上构造出新的列表数组
如下 我们在只有红色属性的基础上增加一个黄色时,红色所在行的数据和新增的黄色行数据是一致的,也就是我们可以将红色的拷贝之后直接插入到对应数据的下方,然后将红色换成黄色
规则是一路查阅下来,同属性的上一个份数据的值所在的行直接拷贝,除此之外前一列的值必须相同,若二者有一个不同则停止拷贝,开始插入,具体代码如下,同样,返回值在后面会用到
/**
* form改变导致添加多行
* @param {EItem|undefined} keyBefore 前一列的key ,
* @param key 影响到的key
* @param newLength 修改后from长度
* @param dynamicIndex 影响到的列索引
*/
function addSomeRow(
keyBefore: EItem | undefined,
key: EItem,
newLength: number,
dynamicIndex: number,
): TColumnUPdateArg {
const newArr = [...resultList.value]
// 新插入之后数据会有偏移量,需要记录下来
let offSet = 0,
count = 0
let lastColumnValue: string = resultList.value[0]?.[keyBefore!]
resultList.value.forEach((item, index) => {
// 与当前新增选项所属同个属性 说明除当前选项外都相同
const isSameAttr = item[key] === formValueLabelMap[form[key][newLength - 2]]
// 用于判断前一列的值是否相同
const isSameKeyBefore = item[keyBefore!] === lastColumnValue
if (isSameAttr && isSameKeyBefore) {
count++
} else if (count > 0) {
// 将具有同等属性的列拷贝下来
const insertOption = resultList.value.slice(index - count, index)
insertOption.forEach((insertItem) => {
// 最好deepclone
newArr.splice(index + offSet, 0, {
...insertItem,
price: 0,
price2: 0,
[key]: formValueLabelMap[form[key][newLength - 1]],
})
offSet++
})
count = isSameAttr ? 1 : 0
lastColumnValue = item[keyBefore!]
}
})
// 若遍历完之后发现还有未插入的值直接push到末尾
if (count > 0) {
const insertOption = [...newArr.slice(-count)]
insertOption.map((item) => {
newArr.push({
...item,
price: 0,
price2: 0,
[key]: formValueLabelMap[form[key][newLength - 1]],
})
})
}
resultList.value = newArr
updateOldForms()
return {
handlerType: 'ListUpdate',
key,
index: dynamicIndex,
}
}
onlyColumnUpdate
只存在列修改
新增列时我们可以做到精准添加行属性中对应的列,并且赋值前一列的customCell一比一复制(只操作列的话当前列和前一列的customCell值是相同的)
更新列的时候不需要对行就行增减,我们只需要在对应的行将其属性删除或者新增对应的值即可
/**
* 找到被影响的列信息,同时修改行
* @param key 影响到的key
* @param oldLength 修改前from长度
* @param dynamicIndex 影响到的列索引
*/
function onlyColumnUpdate(
key: EItem,
oldLength: number,
dynamicIndex: number,
): TColumnUPdateArg {
resultList.value = resultList.value.map((item) => {
if (oldForm[key].length === 0) {
item[key] = formValueLabelMap[form[key][0]]
} else {
delete item[key]
}
return item
})
const handlerType = oldLength === 0 ? 'addColumn' : 'delColumn'
updateOldForms()
return {
handlerType,
key,
index: dynamicIndex,
}
}
接下来根据上面返回值判断继续操作
const handlerColumnArgs = updateList()
if (handlerColumnArgs) {
updateColumns(handlerColumnArgs)
}
新增列则直接找到对应的列并就行插入,
1、但如果新增的是第一列则需要创建数据,第一列的话新增的列跨所有行
2、若类型是删除列,则直接找到对应列的索引就行删除
3、若是新增或删除行的操作,则需要对所有的列做处理,要调用原有的getColumns
此步骤中主要是第一步和第二部节省了大量运算
代码如下
function updateColumns({
handlerType,
key: handlerKey,
index: handlerIndex,
}: {
handlerType: 'addColumn' | 'delColumn' | 'ListUpdate' | 'update'
key?: EItem
index?: number
}) {
if (handlerType === 'addColumn' && typeof handlerIndex === 'number') {
const customCell =
handlerIndex > 0
? resultColumns.value[handlerIndex - 1].customCell
: (_, index?: number) => ({
// 第一列的话新增的列跨所有行
rowSpan: index! > 0 ? 0 : resultList.value.length,
})
// @ts-expect-error 忽略一下报错
resultColumns.value.splice(handlerIndex, 0, {
dataIndex: handlerKey!,
title: formGroup[handlerIndex].label,
customCell,
})
} else if (handlerType === 'delColumn') {
resultColumns.value.splice(handlerIndex! - 1, 1)
} else {
resultColumns.value = getColumns()
}
}