基于分步表单的实践探索

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。。

本文作者:修能

以下内容充满个人观点。◡ ヽ(`Д´)ノ ┻━┻

前言

基于分布表单的需求,在中后台管理中是一个非常常见的需求,通常具有如下布局:

其中,自定义需求度从高到低为,正文 > 按钮区 > 步骤条。

虽然布局类似,但是实现的方式却是天差地别,这里就探究一下究竟怎么样实现可以兼具代码的可维护性和可读性呢?

指出问题

Container

我们这里,以「指标-数据模型」的代码为例。

首先先来看看数据模型这里的代码是如何实现的?

export default () => {
  ...
  return (
    <>
      <header>
        <Steps current={current}>
          {['tab1', 'tab2', 'tab3', 'tab4', 'tab5'].map(
      	     (title, index) => (
                <Step key={index} title={title} />
             )
          )}
        </Steps>
      </header>
      <Spin>
        {stepRender(current, {
           childRef,
           modelDetail,
           globalStep: globalStep.current,
           mode,
           isModelTypeDisabled,
           setModelDetail,
           setDisabled,
           onModelNameChange: handleModelNameChange,
        })}
        <Modal>...</Modal>
      </Spin>
      <footer>
        {current === EnumModifyStep.tab1 ? (
           <Button
             onClick={() => router.push('/url')}
           >
             取消
           </Button>
        ) : null}
        ...
      </footer>
    </>
  )
}

这是数据模型编辑页面 Steps所在的容器组件的 DOM 部分的代码。

可以看出来,设计者的思路是比较明确的,通过 header,content,和 footer 进行分层, 增加代码的可读性。

在 header 中,通过声明 title 数组的方式创建 Steps 的方式简洁又不失可读性。

在 content 中,有几个问题的存在:

  1. 既然 header 和 footer 都有语义化的标签强化可读性,我认为这里其实也可以添加语义化的标签强化可读性,譬如 main或者section,当然同时还需要考虑会不会造成过深的层级。
  2. stepRender函数的实现把一大堆 params 传到子组件是否合适。
  3. 为何 content 区域内,会存在 Modal?对于没有设置 getPopupContainer 的 Modal 来说,其会通过 createPortal在 body 上创建,那么在这里不论是写在 content 还是 header,都不会影响它的渲染,所以我推荐把 Modal 写到最角落里,不影响可读性。
  4. 在 footer 中,通过 current === 步骤 的方式去定义按钮,我认为这种方式会使代码显得较为冗余。

Tab1

我们这里以指标相关代码为例,以简见深,以小见大

export default (props) => {
  ...
 const { cref, modelDetail, mode, onModelNameChange } = props;

  useImperativeHandle(cref, () => {
    return {
      validate: () => {...},
      getValue: () => {...},
    }
  });

   useEffect(() => {
     setFieldsValue({
       a: modelDetail.a,
       b: modelDetail.b,
       c: modelDetail.c,
     });
    }, [modelDetail]);

  return (
    <Form>
      <Row gutter={40}>
        <Col span={12}>
            ...
        </Col>
        <Col span={12}>
            ...
        </Col>
    </Row>
    <Row gutter={40}>
      <Col span={12}>
         ...
      </Col>
    </Row>
    </Form>
  )
}

这里我想指出的第一个问题是,ref 的使用,由于 ref 无法在 props 中传递,需要通过 forwardRef 才能拿到。然而这里通过 cref 这种比较 hack 的方式进行一个操作。我认为这是一个不推荐的做法,如果需要拿 ref 我建议是老老实实通过 forwardRef 拿。

其次是 Row 和 Col 的使用,并不是说 Col 达到 24 之后就需要再写一个 Row,你可以继续写的呀,童鞋!

这里需要提出来的一个论点是,每一个子组件里去写 Form 的方式好(即上面的这种写法),还是总体写一个 Form 的方式更好?个人认为前者存在的问题如下:

  1. 由于子组件写 Form,但是提交(或下一步)按钮在外面,那么必然需要用 ref 拿到子组件的实例,并调用相关方法。(上面是 validate 和 getValue 分别对应下一步和上一步调用)
  2. 没有遵循 single source of truth(单一事实来源)
  3. 如果多层级结构,例如 RelationTableSelect 的话,每一层都有填写内容,那么需要大量 Form + ref,降低可维护性。

除此之外,由于基础信息比较简单,所以不存在 props 层层往下传递的问题,但是复杂组件就会存在层层往下传递的情况,那么就涉及到是否需要 context 的问题了。当然,我推荐是需要 context 的。

Tab2

这里再看一眼第二步关联表的设计

interface ITab2Props {
  cref: IModifyRef;
  modelDetail?: Partial<IModelDetail>;
  mode: any;
  globalStep: number;
  updateModelDetail: Function;
  setDisabled?: Function;
}

const RelationTableSelect = (props: ITab2Props) => {}

首先,这里需要支持的一个设计思路是,通常情况下,切忌直接把 dispatch 传递给子组件

关联表这里的设计由于层级嵌套很深,子组件非常多,导致updateModelDetail不断往下传递,你完全不知道哪层组件在什么情况下会去修改这个值!!! 这对于 SSOT 来说,是毁灭性的打击。

再加上 modelDetail 是一个很复杂的数据,对于可维护性来说,属于是力中暴力地打击了。

解决问题

综上,我们设计分布表单的时候,需要规避以上的问题,遵循如下原则:

  1. SSOT
  2. 可维护性
  3. 可扩展性

首先实现如下组件:

<StepsForm
  current={current}
  onChange={setCurrent}
  titles={['tab1', 'tab2', 'tab3', 'tab4', 'tab5']}
/>

这一块代码比较简单,无非就是投传几个值到对应的组件中去。

接下来考虑底部按钮的可扩展性。

通过 submitter 属性支持定制按钮的交互属性。

<StepsForm
  current={current}
  onChange={setCurrent}
  submitter={[
    {
      [StepsForm.PREV]: {
        children: '取消',
      },
    },
    null,
    {
      [PREVIEW]: {
        danger: true,
        children: '预览',
      },
    },
  ]}
/>

接下来要解决按钮的事件,这里有两种方案,一种是将事件挂载在 Container 上(即这里的 StepsForm 组件),通过诸如 onCancel,onSubmit,onPrev等方式进行反馈。
我认为这种方式不够好,原因有如下几点

  1. 通常我们会把子组件提出来,不会和 Container 组件写在一起,这就会使得我们需要在不同的组件中写按钮的交互逻辑和 UI 逻辑,存在隔离感
  2. 有时候我们需要把 Select.Option 相关的数据一起放到数据里给到服务端,这种方式交互需要把 Option 的数据提取到 Container 中
  3. 需要通过 ref 去子组件获取值

而目前我考虑通过事件订阅对按钮事件触发,通过 useEffect 监听事件,但是这种方式的缺点如下:

  1. 不够直观,和我们通常来说的组件开发有一定相悖的思路

除了以上两种方式以外,其实还有一种方式,即通过实现 Children 组件,将 Children 组件作为 StepsForm 的子组件,从而使得将每一步相关的 title 和 onSubmit 等方式都挂载在 Children 组件上。即 ant-design-pro 中的 StepsForm 的实现方式。我认为这种方式的优点在于直观,不割裂。缺点在于如下:

  1. 为了获取 title 不得不先渲染子组件,从而导致 DOM 先渲染出来,然后通过 active 判断表单是否渲染。
  2. 导致子组件无法通过 useEffect获取数据

其中第二点我认为是无法忍受的,这和开发组件的思路完全相悖,故摒弃这种方式
暂时考虑不清楚是第一种好还是第二种好。

这里先考虑实现第二种方式后组件书写的效果:

export function () {
  ...
  StepsForm.useFooterEffect(
    ({ prev }) => {
      prev(() => {});
    },
    [StepsForm.PREV],
  );

  StepsForm.useFooterEffect(() => {
    message.info('预览')
  }, [PREVIEW]);

  StepsForm.useFooterEffect(
    ({ next }) => {
      next(() => {
        return new Promise((resolve) => {
          setTimeout(() => {
            resolve();
          }, 1000);
        });
      });
    },
    [StepsForm.NEXT],
  );

  return (
    ...
  )
}

hook 的实现方式也比较简单,基于事件订阅,结合每一个按钮都赋予一个唯一值。
实现按钮交互触发后,通过事件分发,触发当前渲染的组件中的监听 hook。

总结

本文意在探索分步表单的最佳实践,防止不同的同学在开发该类型的需求会写出五花八门的代码,从而导致降低可维护性。

本文提到的解决方案也不认为是最佳实践,其中不同的方法经过分析都存在优点和缺点。在实际的开发过程中,仍然需要根据具体的需求进行调整。

但是基于分步表单的特性和使用场景,总结出适用大部分情况下的方法论是有必要的。


最后

欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈UED团队持续为广大开发者分享技术成果,相继参与开源了欢迎star

热门相关:亿万盛宠只为你   闺范   裙上之臣   锦庭娇   万古至尊