积梦前端 Meson Form 的分层抽象设计

概述

这篇文章大致梳理积梦采用的表单方案做的一些尝试和回顾.
目前从用的方案是 Meson Form, 名字大致来源于 immer json:
https://github.com/jimengio/m...
目前 Meson Form 形态逐渐开始稳定了, 方案上基本还是可靠的.
过程当中的考虑有一些曲折, 大致做一些梳理.

KForm

早先我们的方案当中其实沿用了一套书写较为简便的方案, 称为 KForm.
看过 Meson Form 例子的话, 跟 KForm 的写法已经比较相似了,
主要看 items, 每个元素定义了表单当中的一项, 这个表单有 3 项:

let { dataSource, onSubmitData, formRef } = this.props;

let items: IFormItem[] = [
  { id: "name", label: lang.lblName, rules: [{ required: true }] },
  { id: "code", label: lang.lblSerialNumber, rules: [{ required: true }] },
  { id: "description", label: lang.lblDescription },
];

return <KForm ref={formRef} items={items} data={dataSource} layout={layout} onSubmitData={onSubmitData} />;

粗看这个例子, 可能觉得已经是比较成熟的表单方案了.
不过深入使用的话, KForm 存在两个问题,

第一个问题是没有类型系统的良好支持, 或者说 TypeScript 的良好支持.
KForm 的内部是基于 antd 表单的, 而控件一般都有各自的属性,
KForm 当中添加属性, 需要用 property 手动写, 这个地方是丢失类型的,
这个地方的处理, 对于真实的开发调试来说不够友好, 没有检查也没有提示,

let items: IFormItem[] = [
  {
    id: "userGroup",
    label: lang.lblUserGroup,
    rules: [{ required: true }],
    controlType: UserGroupSelectDropdown,
    controlPropsMapper: (controlProps) => {
      controlProps.plantId = plantId; // <-- 缺失类型检查
      return controlProps;
    },
  },
];

一定程度上手动添加类型或许可以作为补充的, 但是书写相对繁琐.

另一个问题是可变数据, antd 的方案是基于可变数据实现的.
React 当中倾向使用不可变数据来辅助性能优化,
同时另一方面, 不可变数据也能避免表单的对象被随意修改,
KForm 当中可变对象被传递到多处, 就引发了一些状态改变的 bug.
而且随着我们越来越多使用 immer, 两者之间的不协调就越来越明显.

另外还有个遇到的问题是 KForm 封装好以后扩展性不够.
这个就跟具体的实现有关系了, 导致不能应对一些特殊的场景.
比如自定义组件时要修改额外的字段, 就需要组件能够暴露底层操作.
但总体上感觉随着遇到不同的业务, 总觉得不够用.

Immer Form

为了能解决前面说的几个问题, 我基于 immer 开始寻找方案:

  • 整个方案围绕 immer 设计, 不应该随意出现可变数据,
  • 大部分的逻辑能够被 TypeScript 类型覆盖到, 也能够配合自动补全,
  • 能够比较灵活地定制, 用于处理一些特殊的表单.

由于没有想到清晰的方案, 早先我先尝试用简单的函数来抽离复用的代码,
比如表单的渲染, 比如错误校验, 我分离出了一些常用的函数,
然后整理出大致一套方案, 完成了我当时遇到的几个表单,
大致的代码比如:
https://gist.github.com/cheny...

回头来看, 这套代码其实比较零碎, 表单状态被暴露在外部,
也就意味着在父组件当中需要附加上若干状态个方法用于维护校验,
渲染部分相当于只有复用布局, 但是没有做封装, 基本没有限制.
这个写法好处就是没有什么限制, 各种场景要用基本都是可以用上的,
坏处就是.. 代码会比较啰嗦, 错误需要自己绑定到对应位置, 其实挺烦.

JSON 配置表单

Immer Form 的写法本来是打算逐步简化的, 但是结果用了挺久的,
一方面是没有找到好的入口, 另一方面确实业务也消耗着主要的经历,
我跟同事都是有点想念之前老代码当中用的 antd 的, 以及前面这个写法.

我觉得用 JSON 结构配置表单是正确的方向, 因为这样描述比较少冗余.
而且之前的 KForm 其实也证明对于简单的业务, JSON 形态完全够用的.
所以很自然会想到做一个组件, 将 JSON 渲染到 Form, 以及生成简单的逻辑,
以及对于特殊的场景, 提供自定义渲染或者其他配置, 用来特殊处理.

但是中间有个问题, 即便是 JSON 我依然需要保证自动补全能用,
不过, 一个巨大的 JSON 整个在 VS Code 当中错误提示, 非常感人.

比如这样一个结构,

let formItems = [
  {
    type: EMesonFieldType.Input,
    name: "name",
    label: "名字",
  },
  {
    type: EMesonFieldType.Input,
    name: "name",
    label: "名字禁用",
    disabled: true,
  },
]

我需要在 name 或者 label 位置填写错误时能够被自动提示,
同事, 对于 disabled, 我输入 dis 能看到对应的补全.
我大致知道 VS Code 有类似的功能的, 在我描述了 type 的前提下, 类似于,
https://basarat.gitbooks.io/t...

实际使用当中反而预测坑了, 我试了一下, 发现错误提示总是在整个 JSON 数组上.
后来在朋友的帮助下, 终于明确了在变量上直接加类型约束, 可以规避问题, 也就是,

let formItems: IMesonFieldItem[] = [
  {
    type: EMesonFieldType.Input,
    name: "name",
    label: "名字",
  },
  {
    type: EMesonFieldType.Input,
    name: "name",
    label: "名字禁用",
    disabled: true,
  },
];

其中 IMesonFieldItem 是借助 Union 关联在一起的多个 interface.
这样写之后, 错误提示和自动补全, 都显得相对正常了.

Meson Form

基于上面这种 JSON 的格式, 以及一些字段, 我编写了一个简单的组件,
这样, 就是一个简单的 Meson Form 的结构了.
这是一个例子 http://fe.jimu.io/meson-form/

let formItems: IMesonFieldItem[] = [
  {
    type: EMesonFieldType.Input,
    name: "name",
    label: "名字",
  },
  {
    type: EMesonFieldType.Input,
    name: "name",
    label: "名字禁用",
    disabled: true,
  },
];

return (
  <div className={cx(row, styleContainer)}>
    <MesonForm
      initialValue={form}
      items={formItems}
      onSubmit={(form) => {
        setForm(form);
      }}
    />
    <div>
      <SourceLink fileName={"basic.tsx"} />
      <DataPreview data={form} />
    </div>
  </div>
);

类似地, 对于自定义渲染的需求, 直接用上一个 render 函数插入代码,
Demo http://fe.jimu.io/meson-form/...

let formItems: IMesonFieldItem[] = [
  {
    type: EMesonFieldType.Custom,
    name: "x",
    label: "自定义",
    render: (value, onChange, form, onCheck) => {
      return (
        <div className={row}>
          <div>
            Custome input
            <Input
              onChange={(event) => {
                onChange(event.target.value);
              }}
              placeholder={"Custom field"}
              onBlur={() => {
                onCheck(value);
              }}
            />
          </div>
        </div>
      );
    },
  },
];

基于这套写法, 后面又加上了 Select Switch 等组件和样式,
目前支持的类型比较少, 经常依赖自定义渲染, 后续还要跟随业务扩展.
实际使用当中也提出了需要更多钩子用于状态修改, 慢慢也加上了.
只能说大致满足了常用的需求, 加上自定义. 在原来的基础上减少了代码量.

另一方面早期 KForm 大量的场景是跟 Modal 用在一起的,
所以 Meson Form 也加上了 Modal 的封装, 尝试覆盖一些常用的需求:
http://fe.jimu.io/meson-form/...
http://fe.jimu.io/meson-form/...

Meson Core

不过总体上说业务往往是多变的, 一个 Form 组件的形态总归是不够的.
比如说我会遇到场景, 没有文字标签, 标错的样式也有区别,
这种场景, 比如就是登录框了, 通常就不是用 Form 的样式去做的.
但是又比较明确, 它还是 Form, 有校验, 只是界面和结构有区别.

基于这一点, 我们再进一步想, 前面的 Form 的封装其实是有点仓促的,
渲染部分的组件, 实际当中时会有多种可能的, 而不单单是一种渲染,
对于 Form 来说, 更加稳定真实的其实是数据和校验的部分,
这部分可以超脱 UI 的形态, 但是表单自己基本都会有在表单项还有校验,

那么, 我就想起来用 Hooks 可以分离出表单的状态部分,
这部分包含表单的状态, 校验结构, 还有一些操作,
这部分代码可以超越表单组件本身, 被用到特殊的表单的场景, 核心的 API 比如:

let { formAny, errors, onCheckSubmit, checkItem, updateItem, forcelyResetForm } = useMesonCore({
  initialValue: submittedForm,
  items: formItems,
  onSubmit: onSubmit,
});

这里能获取 form errors, 这是渲染表单必备的数据,
然后也暴露出来其他一些用于校验和更新表单数据的函数, 甚至于重置表单的数据,
这样就得到一个例子, 可以沿用 Meson Core, 然而自己定义界面如何渲染,
http://fe.jimu.io/meson-form/...

<div className={styleFormArea}>
  {formItems.map((item) => {
    switch (item.type) {
      case EMesonFieldType.Input:
        return (
          <div className={column} key={item.name}>
            <input
              value={form[item.name] || ""}
              type={item.inputType}
              placeholder={item.placeholder}
              onChange={(event) => {
                let text = event.target.value;
                updateItem(text, item);
              }}
              onBlur={() => {
                checkItem(item);
              }}
            />
            {errors[item.name] != null ? <div className={styleError}>{errors[item.name]}</div> : null}
          </div>
        );
    }
  })}
  <div>
    <button onClick={onCheckSubmit}>Submit</button>
  </div>
</div>

基于这个思路, 当我们需要一个横向布局的表单的时候, 就可以复用了.
核心的规则和校验逻辑是可以复用的, 渲染部分完全用不同的实现,
http://fe.jimu.io/meson-form/...

所以 Meson Form 提供的 API实际上提供了两个不同的层次,
直接用 Meson Form 可以快速生成简单的 Form, 或者 Core 用于定制.

其他

当然对于业务来说, 场景可能是无穷无尽的, 前面的方案依然未必足够.

同事在使用 Meson Form 时候, 需要用到自有定义的 Footer,
这一定上跟 Meson Form 最初设定的数据流有冲突了,
于是他用 useImperativeHandle 又加上了一层封装,
目的就是为了能把一些事件抛出, 在外部找到地方去触发, 而不收到设计的限制.

另外使用当中发现校验规则不断增多, 逐渐开始有一些明显的重复,
这些规则按理说通过高阶函数还是可以进一步进行抽象,
或者不用高阶函数, 单纯用 JSON 定义规则的话, 也能够表达.
所以这部分的抽象和简化后面依然需要再补充.

按照 Meson Form 最初设想的, JSON 的格式原本极为通用,
社区有别人的例子, 用 JSON 定义表单的格式, 然后前端直接渲染,
这样如果还能在中台把表单抽象称为服务的话, 还能分担前端的工作量.
即便不能替代前端开发表单, 如果说能在一定程度上生成代码, 也是有效果的.
由于 toB 的属性本身就具备大量的表单, 这方面会有不小的需求.

总之 Meson Form 还是需要继续扩充很完善, 用来应对更多业务场景.


其他关于积梦前端的模块和工具可以查看我们的 GitHub 主页 https://github.com/jimengio .
目前团队正在扩充, 招聘文档见 GitHub 仓库 https://github.com/jimengio/h... .

阅读 1.2k

推荐阅读
题叶
用户专栏

ClojureScript 爱好者.

500 人关注
251 篇文章
专栏主页