使用react+antd3开发的复杂、超大数据量级的form表单

一、前言

        本方案肯定不是最佳实践,从产品层面到代码层面都有优化的空间。因为当时在需求评审时,由于疏忽,没有和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,将所有数据进行整合。

      

  • 32
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
React中,清空表单可以通过Antd的Form组件提供的`resetFields`方法来实现。具体步骤如下: 1. 在表单的父组件中引入`Form`组件,并将表单的所有控件都用`Form.Item`包裹起来,每个`Form.Item`需要设置`name`属性,这个属性值需要与`getFieldDecorator`方法中的`id`参数保持一致。 ```jsx import { Form, Input, Button } from 'antd'; class MyForm extends React.Component { render() { const { getFieldDecorator } = this.props.form; return ( <Form> <Form.Item label="用户名" name="username"> {getFieldDecorator('username')(<Input />)} </Form.Item> <Form.Item label="密码" name="password"> {getFieldDecorator('password')(<Input.Password />)} </Form.Item> <Form.Item> <Button type="primary" onClick={this.handleSubmit}>提交</Button> <Button onClick={this.handleReset}>重置</Button> </Form.Item> </Form> ); } } ``` 2. 在表单的父组件中定义`handleSubmit`和`handleReset`方法。`handleSubmit`方法用于提交表单,`handleReset`方法用于清空表单。 ```jsx class MyForm extends React.Component { handleSubmit = e => { e.preventDefault(); this.props.form.validateFields((err, values) => { if (!err) { console.log('Received values of form: ', values); } }); }; handleReset = () => { this.props.form.resetFields(); }; render() { //... } } ``` 3. 在表单的父组件中将`MyForm`组件包裹在`Form.create`函数中,生成一个新的高阶组件,并将其导出。 ```jsx const WrappedMyForm = Form.create({ name: 'my_form' })(MyForm); export default WrappedMyForm; ``` 这样,当用户点击表单中的“重置”按钮时,表单中的所有控件都会被清空。如果想要清空表单中的某一个控件,可以通过`setFieldsValue`方法来清空,具体可见前面的回答。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值