react 动态表单的思路和实现

3,748 阅读14分钟

小知识,大挑战!本文正在参与「程序员必备小知识」创作活动。

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

这几天接了一个需求,需要实现一个动态的报表,基于 react hook + antd。这里总结一下思考和实现过程里的重点。

需求是这样的:根据后端回传的 json 动态生成表单,表单的绝大多数字段都是数字输入,并且有个关键需求:某些字段之间存在依赖性,例如 field3 = field1 + field2。需要在被依赖字段更新时自动计算。

除此之外还有个问题,后端提供的 json 只包含了必要的属性,比如字段名、字段 label、字段的依赖关系,但是一些个性化的配置,比如某些字段需要禁用、某个表单项的 placeholder 之类的就给不了了。

ok,接下来一步步的分析如何设计这个动态报表。相关代码已经上传至 codesandbox - 自动计算的动态报表,欢迎试玩~

第一步:需求分析,明确动态的边界

有很多同学一听不就是动态么,.map + jsx 遍历一把梭不就好了,然后直接上手开始写。这样基本后面都会小重构个几次。写好代码的第一步还是要心里把所有代码都跑通了再开始动手。特别是对于我们这种边界很不清晰的“动态报表”,在跟负责人确认之后,明确了几个核心功能点:

  • 字段的依赖自动计算:核心功能,并且要注意精度问题。
  • 表单结构简单:所有表单项都是单行输入框,也不存在表单项里套表单项,或者占地面积大的表单项。
  • 可折叠的表单项:对于一些自动填写,或者从外部数据来的表单项,可以默认折叠来减少显示的表单数量。

好,明确了这几个点之后,我们就知道动态的边界在哪里了,我们这次要做的表单是 允许表单项相互关联、支持折叠部分字段、只需要满足基本样式需求 的表单。如果有一天一个同事过来对你说哎呀你也太菜了,连 xxx 都实现不了还叫什么动态表单,这时候就可以叫他直接爬了。

这里要明确一点,动态表单肯定是自由度越高越好。但是业务实现还是要衡量工作时间,如果已经有了相关的组件沉淀,那么直接拿来用或者二次封装显然是更好的。但是如果需要从头写的话,对于不必要或者未来才有可能出现的需求,我们可以选择更灵活一点的方法来“兼容”,具体设计下面会提到。

第二步:设计组件,隔离“脏东西”

文章一开头也提到了,后端只能提供一些必要的表单配置项,而那些更个性化的配置项没办法给,比如现在所有的表单项都是必填的,突然有一天有一个表单不必填了,难道数据库整张表还要再给你加个 required 字段?

那么既然要前端写死,我们就要开动一下小脑瓜了,我们都知道当一个表单变动态之后,那其中表单项的设置就都要从最初的配置项 json 中来。但是仔细想想,对于动态表单组件来说,它不需要关系配置项是怎么来的,它只做一件事,props 里接收配置项,画出来报表页面。也就是说,我们可以在从后端读到配置项之后、在 render 动态报表组件之前,把前端写死的数据注入进去。而不是直接写死到动态报表组件内部:

通过这种方式,我们可以把写死的这部分“脏东西”独立出去,从而保持了动态报表组件的纯洁性。

然后就是第一步结尾提到的,如何兼容未来可能会出现的表单项个性化渲染需求?其实很简单,把表单项的渲染函数独立出来,也通过 props 传给动态表单组件,如果不传的话就用默认表单项渲染函数。记住,如果发现组件的某些渲染部分日后有可能会改,那就把这部分渲染封装到函数里,给 prop 加上这个入参并把这个函数设置为默认值

再用单一职责分析一下,我们就可以得到下面这张组件依赖图:

image.png

有的同学可能会好奇为什么要拆成业务页面和动态表单两个组件。

这是因为有些功能内容是无关于表单的,比如表单默认值的接口获取、切换折叠按钮的样式位置,产品一拍脑子加上的一些其他功能等等。这些内容和我们的动态表单是无关的,所以需要把这部分“动”代码从“静”的表单组件里分离出去,来提高功能的可复用性。

第三步:准备就绪,开始写码!

OK!设计完成开始写码!注意,下面包含的代码只是用于讲解思路,详细代码在 codesandbox - 自动计算的动态报表,边读文章边看例子效果更佳哦,你可以在 /FormPage/service.js 看到“后端”给过来的表单配置项 json 是什么样子的。

1、实现自动计算 hook

首先让我们把精力放在核心需求:依赖项的自动计算上。在上图中可以看到是分了两个模块,模板表达式计算里是纯粹的纯函数算法,这部分的具体介绍可以看我的这篇文章 掘金 - 使用 js 实现加减乘除模板计算,上面例子里已经包含了这部分的代码,这里就简单介绍一下流程:

  • strToToken:将后端提供的依赖表达式字符串解析成token数组:即 s1 + s2 - 100 => ['s1', '+', 's2', '-', 100]
  • tokenToRpn:将 token 数组修改为后缀表达式数组,纯算法,没什么好讲的。
  • calcRpn:传入后缀表达式数组和 kv 对象。将后缀表达式中的“变量名”替换成 kv 对象里的实际值,然后执行计算,计算支持加减乘除四则,由 big.js 提供精度修复。

而自动计算 hook 则是在上面这些工具函数的基础上进行封装,让其可以应用在 react 函数组件里。我们现在来实现一下这个。这个 hook 应该 接受表单配置项、表单初始值、表单 ref 作为参数,并返回表单数据变更 listener 和包含自动计算结果的表单初始值。我们从返回值开始来推导为什么需要这些参数。

首先,需求是“当被依赖项更新时自动计算”,那么有且只有在 form 的数据发生变化时才需要检查有没有依赖项变更并运行计算,所以说我们让 hook 返回 form 在触发 onValuesChange 时需要的回调函数即可。

那"包含自动计算结果的表单初始值"是啥?表单初始值好理解,就是赋值给 form 的 initialValue。但是我们不能直接把后端返回的初始值直接扔给 form,因为 此时还没有经过 onValuesChange 计算依赖字段值! 所以我们需要先运行一遍自动计算,并把自动计算的结果当作初始值的一部分一起传递给 form,这时才不会出现“所有被依赖字段都有值,但是自动计算字段是空的”的问题出现。

于是,hook 的骨架就出现了:

/**
* 自动计算 hook
* @param {object} layoutInfo 表单配置项
* @param {object} initialValues 初始值对象
* @param {Ref} formRef 表单操作 ref
*/
export const useAutoCalc = (layoutInfo, initialValues, formRef) => {
    // 表单里具有依赖项的字段
    const dependence = useMemo(() => {
        // 从 layoutInfo 中提取依赖信息
    }, [layoutInfo]);


    // 用初始值进行首次自动计算
    const initialValuesWithCalc = useMemo(() => {
        const autoChangeValues = /** 调用自动计算函数,并传入依赖项和默认值进行计算 */;
        return {
            ...initialValues,
            ...autoChangeValues
        };
    }, [layoutInfo, initialValues, dependence]);


    /**
    * 监听表单字段变更,更新自动计算字段
    * @param {object} changedValues 变更的表单数据
    * @param {object} allValues 表单里的所有数据
    */
    const onFormValuesChange = useCallback((changedValues, allValues) => {
        const autoChangeValues = /** 调用自动计算函数,并传入依赖项和本次变更值进行计算 */;

        // 把新的数据更新会 form
        formRef && formRef.setFieldsValue(autoChangeValues);
    }, [dependence, formRef]);

    return [initialValuesWithCalc, onFormValuesChange];
};

可以看到大致分为三部分:先提取依赖信息,根据依赖信息和初始值进行首次自动计算,然后根据依赖信息和 formRef 新建 onVluesChange 的回调函数,这里对所有的东西都进行了缓存,因为一个表单内的依赖信息很难发生改变,并且这些计算开销都比较昂贵。

从上面的 hook 里我们可以找到两个需要实现的地方:收集依赖信息根据依赖信息自动计算

  • 收集依赖信息其实很简单,遍历后端提供的表单配置项中的所有 formItem,找到其中有依赖性表达式字符串的 formItem,然后用上面提到的 strToTokentokenToRpn 进行解析,最后生成一个如下数组:
{
    // 字符串,要自动计算的字段名
    target: 'result',
    // 字符串数组:这个字段依赖于那些字段值
    sources: getDependKeys(tokens),
    // 字符串数组,计算所需的后缀表达式 token 数组。
    rpn: tokenToRpn(tokens)
}

注意这里我们预先解析好了计算所需的后缀表达式而不是直接保存表达式字符串,之后自动计算的时候就可以直接拿来用而不需要重复的进行解析。

  • 根据依赖信息自动计算这个就稍微复杂一点,因为 依赖项有可能是复杂嵌套的,比如下面这样:

image.png

想要计算字段E的值,先得有字段C的值,而计算字段C的值则需要字段A和字段B的值。也就是说,我们需要递归执行自动计算来解决这个问题,具体流程如下:

  1. 遍历上面收集到的依赖信息,找到那些字段需要进行计算
  2. 对这些字段进行求值
  3. 检查这些新计算出来的字段值是否会导致新的字段需要计算
  4. 如果有的话,那新计算出来的字段值进行递归运算

这部分完整的代码在 DynamicForm/useAutoCalc.js - CodeSandbox,看起来很复杂但是加上 hook 本体总共也就一百多行,而且行为比较纯,结合例子很容易就可以理解。

2、实现表单项渲染函数

现在最难(相对来说)的部分已经结束了!接下来让我们写一些轻松的:表单项渲染函数

这个之前也提到了,它是一个函数,接受表单配置项,返回表单的 jsx。简单,策略模式梭一把:

/**
 * 渲染动态表单的表单项组件
 *
 * @param {object} formItemInfo 要渲染的表单项配置
 * @param {boolean} showCollapse 是否显示该表单
 */
export const renderFormItem = (formItemInfo, showCollapse) => {
    const { compType } = formItemInfo;

    const render = compType in formItemRenders
      ? formItemRenders[compType]
      : formItemRenders.InputNumber;

    return render(formItemInfo, showCollapse);
};

/**
 * 表单项 compType 到实际渲染函数的映射
 */
export const formItemRenders = {
    // 日期输入组件
    DatePicker: (formItemInfo, showCollapse) => (/** ... */),
    // 数字输入组件
    InputNumber: (formItemInfo, showCollapse) => (/** ... */),,
    // 字符串输入组件
    Input: (formItemInfo, showCollapse) => (/** ... */),
};

可以看到,这里不仅提供了表达你的配置项,还提供了一个 showCollapse 用于指定当前是否要展示折叠的表单项(即外部的展开折叠项 switch 按钮)。

还有一个需要注意的地方是,当表单项被折叠时应该怎么处理,我们来看看下面这两种做法:

// 折叠时不渲染
(formItemInfo, showCollapse) => {
    const { collapse } = formItemInfo;
    
    if (!showCollapse && collapse) return null;

    return (
        <Col>
            <Form.Item>
                <Input />
            </Form.Item>
        </Col>
    );
}
// 折叠时 hide
(formItemInfo, showCollapse) => {
    const { collapse } = formItemInfo;

    return (
        <Col>
            <Form.Item hidden={!showCollapse && collapse}>
                <Input />
            </Form.Item>
        </Col>
    );
}

两种都可以实现需求,但是哪一种更好呢?

有编程经验的同学会直接指出是第二个,因为采用 hidden 方式的组件不需要重复销毁创建,相对性能更好。并且第二种还可以让 form 提供更好的字段校验和使用体验。因为,如果我们在不显示时直接 null 掉表单项,那么 form 就不会对这个表单项进行校验,并且也不会保存对应的字段值,也就是说我们需要再额外 useState 一个对象来保存这些被隐藏掉的值,对比使用 hidden 方式的编码体验会差很多。

本部分的完整代码见 /DynamicForm/formItemRenders.js - CodeSandbox

3、实现动态表单组件

终于要开始写我们的核心组件!不用担心,因为上面我们已经准备好了所有需要的内容,所以现在实现起来会相当的轻松。先想想我们的动态表单组件都需要哪些 props 呢?

表单配置项 layoutInfo表单初始值 initialValues 是必备的,有这两个我们才能使用上面设计好的 useAutoCalc hook。除此之外还需要一个 表单提交回调 onFinish,当我们点击提交按钮后将触发这个回调把结果丢给外层组件进行下一步操作。还要有个可选项 表单项渲染函数 renderFormItem,就是我们上一小节完成的这个,为了节省组件调用时的代码量,我们需要将其设置为默认值。哦对了,还需要一个 是否展开折叠项 showCollapse,我们需要他来协助渲染表单项。

实际上还可以继续添加 prop 来提高灵活度,例如渲染表单小节头部、渲染表单提交按钮区域等,这里只包含了一些必要的 prop。

有了这些 prop,我们很轻易的就可以写出来组件的逻辑(毕竟这部分就是文章开头时一把梭会写出来的代码),这里只提一下大致的逻辑:

  1. 引入 useAutoCalc,并将返回的回调和初始值设置给 form 组件。
  2. 给保存按钮添加一个 loading
  3. 加个判断,当一个小节里所有字段都隐藏时,隐藏这个小节的标题。
  4. 遍历配置项渲染小节和表单项
  5. 添加保存按钮

完整代码见 DynamicForm/DynamicFormView.js - CodeSandbox

4、实现业务页面组件

动态表单组件搞定了,那么我们就可以在实际业务中调用这个组件了。这个业务组件里的内容更简单了:

  1. useEffect 访问接口拿到表单配置项和默认值
  2. 将前端写死的数据注入进表单配置项
  3. 添加一个展示全部数据的按钮
  4. 调用上面的动态表单组件

当然,当动态表单组件有了足够的沉淀之后,这个业务页面才会是我们开发的主战场,称其为最重的组件也毫不为过。

完整代码见 FormPage/FormPageView.js - CodeSandbox,注入写死数据的函数则在 injectStaticData.js - CodeSandbox

写在最后

写到这里,我们本文重讲述的动态报表就已经开发完成了,通过合理的规划和设计,绝大多数的职责都被解耦到了不同的文件模块中。每个函数的长度均保持在二三十行左右。再加上一些必要的思路注释,一道大餐就此完成

其实本文中除了纯粹的写码,也有一部分内容是如何和同事打交道,包括最开始的某些字段后端无法提供需要前端自己写死存起来的问题。后端可以存么?可以,但是会很麻烦,最后导致工期拖延,然后就要开始加班,后面还要承受后端同学的白眼,这真的值得么?

对于业务需求来说,目标是最终的交付。

而对于交付来说,如何 少加班 将开发时间分配到具体的任务上则是重中之重。而我们平时所学的设计模式、编程知识经验,不应该成为桎梏束缚住我们的思想,而是应该在面对一些不得不妥协的坏需求时,可以拿来协助我们相对更加优雅的进行解决。

当然,这是在时间不够的情况下,如果有足够的时间,必须是怎么规范怎么来,不然现在偷的懒最终都会还回去。