一、前言
本方案肯定不是最佳实践,从产品层面到代码层面都有优化的空间。因为当时在需求评审时,由于疏忽,没有和pm确认该页面的数据量级,以至于在提测之后才知道该页面需要承载的压力之大,所以各方均没有时间再做大量的改造,只能从现有的代码上尽力做下优化。
二、功能介绍
一个嵌套了多个子层级,每一个层级均具有动态增、删、上移、下移、复制功能的form表单,且该表单也支持回显编辑再提交。页面结构大致可以分为两大部分:
1、配置页面头部:
2、模块设置(核心组件):
(1)pc端模块设置和移动端模块设置内部用的是同一个组件,所以只拿单个模块设置说明。
(2)在pc端模块设置内部,点击【+新增模块】可以无限增加多个模块;点击模块上方的编辑或模块本身可以将模块展开。
(3)在单个模块内部右侧有一个【继续添加元素】按钮,点击后向模块内部添加子元素。
(4)在元素块内部,通过切换元素类型,会展示相应的组件。
(5)选择元素类型为明细,则展示如下组件,点击【+】则可添加多条列表数据
三、代码结构及后续优化:
最初的结构构思,(由于没有和产品确认数量级,所以把这个需求当成是一个普通的Form表单去对待,也没有好好做下设计)我是将页面拆分成3个form组件:分别为头部、pc模块设置、移动模块设置。
测试中发现这种结构的代码在大数据量回显(3000个form.item元素)的时候,页面直接就加载不出来崩掉了。但上线后实际配置的数据量大约在7000个元素节点,也就是说当前代码根本不满足业务要求。
所以我做了第一次优化:拆!(如下图)
头部不变,主要拆解大模块的form结构,从原本将所有子模块放在一个form中,变成每一个子模块都是一个form。拆解后页面可以可以正常渲染出来了,但随之又发现了一个新的问题:当一个子模块内部的元素较多的时候,如果用户修改某一个元素的值,渲染速度是非常慢的(比如我在一个input框中输入‘你好世界’,要等20s左右才会渲染到页面上)。这是由于在antd3中:当修改一个表单项的值时,整个表单组件会重新渲染,并且会触发整个表单的重新渲染。
所以我又做了进一步优化:继续拆!(如下图)
拆成以元素块为一个form表单。此时页面的性能除首次加载比较慢之外(因为要渲染全量数据所以比较慢),在做一些增删改的操作时,完全没有任何问题。
看到这可能小伙伴们会有疑问:为什么不能分模块加载,为什么不能展开哪个模块再对哪个模块做相应的加载,或者借助于例如react-virtualized库去可见区域渲染。原因还是因为开发到这已经是在测试阶段了,无论采用哪种方案,前端和后端的代码结构都有着十分大的改动,所以只能亡羊补牢。而且当前的页面设计是:数据是存在各个子form表单中的,点击保存按钮遍历所有form表单,通过 getFieldsValue() 取到所有的值再进行聚合。如果不将全部数据首次渲染出来,当点击保存时,就会导致未被渲染的数据丢失。
四、关键代码实现
1、数据处理的逻辑
首先我是给每一个元素绑定了一个唯一id,在点击保存的时候,所有的数据都是平铺在一个对象内,如下图所示:
可以看到,每一个模块最上方的模块名称和备注的key名是由:`${模块Id}_${元素名称}`拼成的;而元素的key名则是由:`${模块Id}_${元素Id}_${元素名称}`拼成。这样我就可以截取split('_'),根据返回值的长度,依次逐个的将数据处理成后端需要的数据格式 如下图:
同时,我也和后端约定,将前端生成的模块id、元素id存到数据库中,以便回显数据时使用。
(1)数据处理代码(提交时)
下面直接贴上【提交时】数据处理函数的代码:(内部有一些对特殊字段的处理逻辑)
const processData = (data: { [key: string]: any }, sortArr, modules, moduleName = 'pcModule'): Result => {
//data:数据、sortArr:用于存放模块和元素顺序的一个数组、modules:模块数组、moduleName:用来区分是pc端模块设置还是移动端模块设置
const result: Result = {};
Object.keys(data).forEach((key) => {
const parts = key.split('_');
if (parts.length === 1) {
result[key] = data[key];
}
if (parts.length === 2) {
const moduleKey = parts[0];
const attribute = parts[1];
if (!result[moduleName]) {
result[moduleName] = [];
}
let moduleObj = result[moduleName].find((module) => module.onlyKey === moduleKey);
if (!moduleObj) {
moduleObj = { name: moduleKey, onlyKey: moduleKey };
result[moduleName].push(moduleObj);
}
moduleObj[attribute] = data[key];
}
if (parts.length === 3) {
const moduleKey = parts[0];
const eleId = parts[1];
const attribute = parts[2];
if (!result[moduleName]) {
result[moduleName] = [];
}
let moduleObj = result[moduleName].find((module) => module.onlyKey === moduleKey);
if (!moduleObj) {
moduleObj = { name: moduleKey, onlyKey: moduleKey };
result[moduleName].push(moduleObj);
}
if (!moduleObj.children) {
moduleObj.children = [];
}
let eleObj = moduleObj.children.find((ele) => ele.eleId === eleId);
if (!eleObj) {
eleObj = { eleId };
moduleObj.children.push(eleObj);
}
//因后端要的值比较多,所以在select选择时,将多个值转成string,赋给select value。所以在提交时,要把string转成对象传给后端
if ((attribute === 'withPayrollItemCode' || attribute === 'SalaryElement') || attribute == 'salaryGroup' && data[key]) {
eleObj[attribute] = JSON.parse(data[key])
} else if ((attribute === 'elementWithPayrollItem' || attribute === 'salaryGroupList') && data[key]) {
eleObj[attribute] = data[key]?.map((item) => (
JSON.parse(item)
))
} else if (attribute === 'elementValue' && Array.isArray(data[key])) {
//对元素为图片的值进行特殊处理,只有图片的值是一个数组,其余的值都是字符串
eleObj[attribute] = data[key][0]?.url || ''
} else {
eleObj[attribute] = data[key];
}
}
if (parts.length === 4) {
const moduleKey = parts[0];
const eleId = parts[1];
const subId = parts[2];
const attribute = parts[3];
if (!result[moduleName]) {
result[moduleName] = [];
}
let moduleObj = result[moduleName].find((module) => module.onlyKey === moduleKey);
if (!moduleObj) {
moduleObj = { name: moduleKey, onlyKey: moduleKey };
result[moduleName].push(moduleObj);
}
if (!moduleObj.children) {
moduleObj.children = [];
}
let eleObj = moduleObj.children.find((ele) => ele.eleId === eleId);
if (!eleObj) {
eleObj = { eleId, subId };
moduleObj.children.push(eleObj);
}
if (!eleObj.elementWithPayrollItem) {
eleObj.elementWithPayrollItem = [];
}
let subElementObj = eleObj.elementWithPayrollItem.find((sub) => sub.subId === subId);
if (!subElementObj) {
subElementObj = { subId, salaryElementCode: '', salaryElementName: '', sourceSystem: '', elementWithName: '', elementWithUnit: '', showWhenZero: '', showLevelTwoDetails: '' };
eleObj.elementWithPayrollItem.push(subElementObj);
}
if ((attribute === 'withPayrollItemCode') && data[key]) {
const { salaryElementCode, salaryElementName, sourceSystem } = JSON.parse(data[key])
subElementObj.salaryElementCode = salaryElementCode;
subElementObj.salaryElementName = salaryElementName;
subElementObj.sourceSystem = sourceSystem;
} else {
subElementObj[attribute] = data[key]
}
}
});
//获取module的排序数组 : [module2,module3]
const moduleOrder: string[] = [];
modules?.map((item) => {
return moduleOrder.push(item.id)
})
//先对模块的数据进行排序
result[moduleName]?.sort((a, b) => {
const aModuleIndex = moduleOrder.indexOf(a.onlyKey);
const bModuleIndex = moduleOrder.indexOf(b.onlyKey);
return aModuleIndex - bModuleIndex;
});
//再对元素数据进行排序
result[moduleName]?.forEach((module) => {
const moduleName = module.onlyKey;
const sortedChildren = sortArr[moduleName];
if (sortedChildren) {
module.children?.sort((a, b) => {
return sortedChildren.indexOf(`${moduleName}_${a.eleId}`) - sortedChildren.indexOf(`${moduleName}_${b.eleId}`);
});
}
});
return result;
};
(2)数据处理代码(回显时)
回显时将后端返回的数据,处理成前端所需要的平铺格式。
这里也有一个后期优化的小点。优化前:所有的回显数据揉成一团放在一个对象中,没有做分类。优化后:回显数据细化,精确到模块->元素块->子元素列表块
这样可以先根据模块id找到对应模块需要的数据;再根据对应元素id找到模块->元素块需要的数据,以此类推。这样可以减少不必要的数据遍历。
【回显时】逻辑处理代码:
const reverseProcessData = (processedData: Result): reverseProcessParams => {
const reversedData: { [key: string]: any } = [];
const modulesArr: { id: string, expend: boolean }[] = []
const elesArr: string[] = []
const twoPayrollItemArr: string[] = []
//把最外层的参数直接push进对象,不用处理
for (const key in processedData) {
if (!Array.isArray(processedData[key])) {
reversedData['header'][key] = processedData[key]
}
}
const processModule = (module: any) => {
if (!reversedData[`${module.onlyKey}`]) {
reversedData[`${module.onlyKey}`] = {}
}
for (const key in module) {
if (key === 'children') {
for (const child of module.children) {
processChild(child, module.onlyKey);
}
} else if (key !== 'onlyKey') {
reversedData[`${module.onlyKey}`][`${module.onlyKey}_${key}`] = module[key];
}
}
};
const processChild = (child: any, moduleKey: string) => {
const elementKey = `${moduleKey}_${child.eleId}`
if (!reversedData[moduleKey][elementKey]) {
reversedData[moduleKey][elementKey] = {}
}
for (const key in child) {
if ((key === 'withPayrollItemCode' || key === 'SalaryElement' || key == 'salaryGroup') && child[key]) {
reversedData[moduleKey][elementKey][`${moduleKey}_${child.eleId}_${key}`] = JSON.stringify(child[key]);
} else if ((key === 'salaryGroupList') && child[key]) {
reversedData[moduleKey][elementKey][`${moduleKey}_${child.eleId}_${key}`] = child[key]?.map((item) => (
JSON.stringify(item)
));
} else if (key === 'elementValue' && child.elementType == '7') {
const filenameSubstring = child[key].substring(child[key].lastIndexOf("/") + 1)
let data = [
{
"uid": filenameSubstring,
"name": filenameSubstring,
"url": child[key]
}
]
reversedData[moduleKey][elementKey][`${moduleKey}_${child.eleId}_${key}`] = data;
} else if (key !== 'eleId' && key !== 'elementWithPayrollItem') {
reversedData[moduleKey][elementKey][`${moduleKey}_${child.eleId}_${key}`] = child[key];
}
}
if (child.elementWithPayrollItem) {
child.elementWithPayrollItem?.map((subElement) => processSubElement(subElement, elementKey, moduleKey));
}
elesArr.push(`${moduleKey}_${child.eleId}`);
};
const processSubElement = (subElement: SubElementObj, elementKey: string, moduleKey: string) => {
const subElementKey = `${elementKey}_${subElement.subId}`
if (!reversedData[moduleKey][elementKey][subElementKey]) {
reversedData[moduleKey][elementKey][subElementKey] = {}
}
for (const key in subElement) {
if (key === 'salaryElementCode' || key === 'salaryElementName' || key === 'sourceSystem' && subElement[key]) {
let obj = {}
obj['salaryElementCode'] = subElement['salaryElementCode']
obj['salaryElementName'] = subElement['salaryElementName']
obj['sourceSystem'] = subElement['sourceSystem']
reversedData[moduleKey][elementKey][subElementKey][`${subElementKey}_withPayrollItemCode`] = JSON.stringify(obj);
} else if (key !== 'subId') {
reversedData[moduleKey][elementKey][subElementKey][`${subElementKey}_${key}`] = subElement[key];
}
}
twoPayrollItemArr.push(`${subElementKey}`);
};
(processedData.pcModule || processedData.h5Module || [])?.map((module: any) => {
modulesArr.push({ id: module.onlyKey, expend: false });
processModule(module);
});
return { reversedData, modulesArr, elesArr, twoPayrollItemArr };
};
2、如何保证每一个元素id的唯一性
说到这可能会有疑问🤔️,在可以动态增删改查的表单中,怎么保证模块id、元素id的唯一性?
关键点:以模块id为例,moduleCount是一个只增不减的值,新增模块时该值加1;删除模块时该值不变。这样能用最低的成本保证id唯一性质,因为在此之前我选择了使用时间戳+随机数的方式去拼接唯一id,但发现处理起来比较麻烦,并且拼出的值也过于长了。
//以模块id举例
//当我点击新增模块时,我会修改两个state,分别为:
const [modules, setMoudles] = useState<expendType[]>([]) //里面存的是每个模块的唯一id
const [moduleCount, setModuleCount] = useState(0) //该值只增不减,用一拼接模块唯一id
const addModule = () => {
const newElementId = `module${moduleCount + 1}`;
setMoudles([...modules, { id: newElementId, expend: true }]);
setModuleCount(moduleCount + 1)
}
//在删除时执行handleDeleteModule
const handleDeleteModule = (id: string) => {
const updatedModules = modules.filter((element) => element.id !== id);
delete sortedData[id];
changeElementId({}, updatedModules); // 传入最新的 modules 值
}
const changeElementId = (arr, updatedModules) => {
setSortedData({ ...sortedData, ...arr });
setMoudles(updatedModules); // 更新 modules 状态
}
3、上移下移功能
模块、元素块的上移下移功能就是传统的操作数组:
//模块上移、下移
const handleModuleMove = (id: string, direction: string) => {
const array = Array.from(modules);
const index = array.findIndex((element) => element.id === id);
const isUp = direction === 'up';
if (isUp && index > 0) {
const temp = array[index];
array[index] = array[index - 1];
array[index - 1] = temp;
} else if (!isUp && index < array.length - 1) {
const temp = array[index];
array[index] = array[index + 1];
array[index + 1] = temp;
}
changeElementId({}, array); // 传入最新的 modules 值
};
再说下子元素块内部表单的上移下移功能的实现方式,下图蓝色框内:
这部分在执行上移下移操作时,我是直接将每一条中的五个元素值进行了上下替换,这样做的好处就是可以避免操作该列表所对应的数组,减少一次重新渲染。具体代码:
//需要移动元素值的key
const fieldsToSwap = ['withPayrollItemCode', 'elementWithName', 'elementWithUnit', 'showWhenZero', 'showLevelTwoDetails'];
//元素移动
const handleModuleMove = (subId: string, direction: string) => {
const isUp = direction === 'up';
const array = Array.from(twoPayrollItem);
const index = array.findIndex((element) => element.id === subId);
if ((isUp && index <= 0) || (!isUp && index >= array.length - 1)) {
// 如果是第一个或最后一个元素,不进行移动操作
return;
}
//不操作元素,直接操作元素的值,将两个值互换位置
const replaceId = isUp ? array[index - 1].id : array[index + 1].id;
const moveData: { [key: string]: any } = {};
fieldsToSwap.forEach((field) => {
const subIdField = `${id}_${subId}_${field}`;
const replaceIdField = `${id}_${replaceId}_${field}`;
moveData[subIdField] = getFieldsValue()[replaceIdField];
moveData[replaceIdField] = getFieldsValue()[subIdField];
});
setFieldsValue(moveData);
};
五、总结
面对这种大数据量的表单功能,开发时应该注意:1.细化form表单结构;2.细化数据;3.增加判断或使用hooks,减少不必要的重复渲染。
最后,如果大家在今后遇到类似的需求时,一定要记得问清楚该页面需要承载的数据量,如果数据量巨大,可以建议从产品层面进行拆分,例如:每次只能配置、回显、编辑单个模块,后端根据每个id,将所有数据进行整合。