阅读 268

创建表单框架之前的小建议

本篇是我最近对一次对表单框架升级的总结。从表单和框架本质的解析,再从数据流的解耦过程,逐步推演出一个表单框架。


温馨提示


  1. 适合有一定框架基础,至少写过框架或者深入阅读过多个框架代码,最好是表单框架
  2. 这是一篇如何创建表单框架的文章,目前并没有对外开放源码,因此会比较晦涩
  3. 要有耐心读下去,如果没有看懂不要怪自己,一定是我的语言组织有问题


表单框架的本质


表单


先谈表单是什么?表单是用于收集用户输入信息。收集过程实质上包含的要素有输入数据、提交动作、提交数据,如果从远古的定义上来讲对应的就是表单域、表单按钮、表单标签,至于为什么不用标准的定义(W3C)自己揣摩。对于表单输入的数据类型或者表现形式再或者验证、字段之间的联动什么的这些都是用来增加表单的操作体验,本质上这些都是可选功能。

image.png

对于上面这种显性的表单,可能需要处理输入的有效性校验、字段之间的联动处理等。而下面的隐性表单这些附加功能都是非必需的。


框架


框架(framework)是一个框子——指其约束性,也是一个架子——指其支撑性。是一个基本概念上的结构,用于去解决或者处理复杂的问题。干巴巴的定义放先头,然后说说我的理解,第一肯定具有局限性,不管对使用方式有特殊要求或者其它,也就是说存在边界;第二具有某个领域的复用性。从功能角度来讲框架必需具有多维度的延伸性。


表单框架


结合表单和框架,那么表单框架一定是具备表单生命周期的一系列功能或者具备易扩展的能力来补充这些功能。哪些功能是必需的哪些功能是选择性的,请参照表单的定义,而对于框架这边就需要一个统一的引擎丰富表单的能力。 表单是一个收集数据的过程,这是初心,从数据出发也许是一个非常好的开头。


从数据出发去抽象框架


一阶


数据从哪来到哪去,又如何回到界面,这是表单一个非常基本数据的闭环。 下图是用户对组件的操作,操作的数据经过。表单在处理数据时一定是双向的,既可以从界面作用数据也可以从数据驱动界面的变化。界面改变数据这个好理解每次操作时都会将操作的行为以数据的形式保存起来,数据驱动最简单的理解就是编辑表单时的数据回写。

image.png


这个阶段很容易联想到MVVM, 其实不奇怪,表单的在发展过程中这是一个非常经典的阶段。那就直接延续这样的思路。



我们可以将MVVM的ViewModel和Model的组合看成是“数据存储”。概念上我们应该是保持清晰,ViewModel应该是处理数据和业务逻辑,注意是业务逻辑并不是表单框架自身的逻辑, 比如某个操作过程时表单字段要校验或不校验,像这样的功能,表单框架应该提供组合的能力。再则就是耦合,表单组件直接操作数据这都不是一个很好的注意,这个会给后期框架扩展形成障碍。


二阶


image.png


插入表单引擎来解耦数据组件与数据存储之间的耦合,由表单引擎来控制由组件改变后数据应该怎么样处理、表单框架应该做出怎么样的响应。表单引擎的作用是编排表单框架自身的逻辑(关于表单引擎下文会再做分解)。表单引擎的代码应该处理的普世功能,不会跟业务场景的特殊要求做出改变。再举校验为例,表单引擎只会决定操作后要不要执行验证,不会去管你是用哪个校验框架,怎么样的校验规则。抽象框架的过程就是不断结藕的过程,尽量做到不与业界的框架或库耦合。不耦合又要实现一些必需的功能,那只能是可插拔。


三阶


image.png


表单组件负责生产数据,表单引擎负责调配,而中间件就是负责加工数据。框架的定义上面也提到一定是具有多维度的延伸性,做为数据加工的扩展性当然有着重大的意义。取名叫中间件顾名思义,这应该是可以被链式组合,可以根据自己的需求来扩展数据加工的中间件。


这时疑问就来了,为什么不在ViewModel里面把这些事都干掉呢? 既然都有数据加工能力。先不考虑合适与否,我想的是继续解耦,对于数据存储不应该局限在某一个框架上,比如redux/mobx或者其它框架。当然不限于用哪个框架不代表数据存储使用的框架还要通过配置来完成,而是在数据处理部分的代码可以维持不变。换句话来说如果业务线中原来都是使用redux, 现在要改成mobx,那只要开发一个使用mobx来做数据存储的对应的框架就好,在业务中开发的逻辑代码(使用中间件开发的部分)都可以保持不变。


四阶


继续解耦,表单并不关心数据的输入形式, 任何形式的输入组件只是为了用户体验更好,业界中提供的组件库太多了,框架不可能去支持所有库的适配。不管用会么形式引入,目标只有一个是否可以满足不同的组件库之间的迁移而不需要去动业务中现有代码。下文中会详解。


完整的数据流


image.png


框架组成


image.png

数据


数据存储在表单框架设计过程中是最基础的,从上面框架抽象过程也可以看出来,从第一个阶就有。而且这部分不仅在表单框架中可以使用,如果你设计的好,用的舒适相信其它地方还会再使用这部分代码。以下是我的几点建议。


  • 选型 建议跟据业务上下流中常用的框架做个判断,不需要在这上面纠结于是否能在表单中使用,业界的常用的框架完全可以满足需求。当然可以选择一个自己最了解的框架。如果你是用react那我的建议是用redux。
  • 数据结构
  • 字段的数据保持唯一标志,不要出现两个字段维一标志重复的情况,这个表单框架可以给出约定。类似的一个字段产生的数据不应该保存在多个位置,保持对应。
  • 表单数据保持扁平,这里的扁平指的是对唯一标志的解析,例如字段的唯一标志是‘a.b’,如果表单的处理时将数据保存成 {a:{b:'x'}}这样的结构化数据,那恭喜你,你在之后做很多功能开发的复杂度将上升一个数量级,这个深有体会。结构化数据确实比扁平化要牛很多,但使用场景上并不是很多,然而复杂度的增加会让框架难于维护。如果必需使用结构化数据,建议采用{‘a.b’:'x'} 的形式保存,在输出时或提交表单时做一次数据转换。
  • 数据存储规划好,使用数据的时候会变的简单很多
  • 分类存储,根据数据使用的类别进行存储,比如表单数据(formData)、字段信息(message), 这样的好处在于框架内部会经常表单数据,从而不需要从一推数据中筛选。
  • 如果你在做框架的时候清楚使用场景会出现多个表单实例,那么建议采用共享Store的方式去设计,比如下图中的‘demo’其实就是这个表单实例的名称,同一个页面会共用这一个Store,那对于调试过程就变得方便很多。
  • 指针操作 指针是把双刃剑,特别是表单框架,用不好那对性能就是灾难要么就永不渲染,用的好对于框架的性能将是极致的。对于去写个框架我的建议是一定要用,否则只能做深对比还要写一堆让人看不懂的shouldcomponentupdate来提升性能。
  • 数据绑定视图 如何让视图尽量少的渲染是非常关键的,推荐读一下源码 redux-form
  • 数据清除 不管是析构表单或者是一个字段的移除请及时将数据清除掉,一是因性能,二个是为保持数据与UI的一致性,做到看视图就能推出数据,看数据就能反推视图。
  • 合理使用undefined数据类型 准确的规划undefined在你框架数据中位置,很多地方在处理数据时会使用三方库进行合并,然而对于{}和{a:undefined}在很多时候表达的意思是相同的。


渲染


渲染层是开发者第一时间会接触的代码,好不好用每人心里都或多或少的贴上标签。


渲染协议


渲染协议是开发写的代码,也是在渲染时内部处理的DSL。协议是否友好一定会有影响,所以一定要考虑清楚以下几点


  1. 是否有平台化的计划,就是表单会不会是通过可视化的形式配置出来 (平台化)
  2. 表单数量会不会非常多 (维护性)
  3. 有没有跨设备的需要 (多端)
  4. 会不会由后端控制表单字段的输出,或者在操作过程中与服务器交互需要对表单字段进行增删(动态性)
  5. 有没有跨多种不同基础框架的需要,比如 react / vue 等 (跨基础框架)


如果都没有那就无所谓,只要设计一个易懂的协议即可,渲染协议的制定就是对使用场景边界的设定。渲染协议会分成两大类基于标签、二是基于数据


基于标签


<Form>
    <FormItem Tag="Input" id="a" label="Hello"/>
</Form>复制代码


不要计较这个例子,这一看就是react的语法,对应其它基础框架可能有一点差异,反正类似的写法都归到这边。


基于标签对于使用者的优点


  1. 学习成本很低,毫无疑问既然选择基于标签那么我认为这边语法就与基础框架(React)是一致的。如果玩疯了自己创建一种AST来处理那是另外一回事。
  2. 排版和嵌套及个性化定制,我认为这边存在着平衡,就看自己能不能说服设计师。基于标签协议来实现这些复杂设计基本无压力,这个应该很好理解,代码都是自己写的,可以各种形式的嵌套、各种姿势地排版,只要性能吃的消没有办不到。也因为这样没有规范的制约所以响应个性需求比较快 。


缺点


  1. 堆砌的代码风格无法保持一致,不同人、不同时期、不同的心情写出来的风格都会有差别,对于后期的维护成本会比较高
  2. 测试回归的成本比较高,无法解析代码反向工程测试用例
  3. 用户体验差异会比较大,不能从框架上抽象UI规范或者制约UI规范


基于标准数据(FormSchame)


const formSchame = [{
    "tag": "Input",
    "label": "Hello",
    "id": "a"
}];
<Form schema={formSchame} />复制代码


下文会将“基于标准数据”统一称为FormSchema


上面基于标签的优点乍看起来就是这个的缺点,那么就基于这个分析一下


  1. 学习成本无可厚非,不能否认FormSchema一个新事物(并不是指这种形式,而是因这个框架而制定的具体描述规则),React刚出现的时候不是也有很多人不适应,当然也有人直接投奔VUE,我认为学习是一次性的, 尽管FormSchema的数据格式千差万别,但表达的意思基本一样。
  2. 排版和嵌套及个性化定制,个人认为存在着平衡,就看自己能不能说服设计师,这些复杂的个性化设计不是基于FormSchema配置不出来,而是说需要开发大量的排版组件或者能力,而这个能力其实是可以定出规范以便今后使用。说白了就是自由与平等的博弈。


那下面分析三个常见的FormSchema,其实也我不知道有多少种,随便



const formSchema = {
  title: 'Todo',
  type: 'object',
  required: ['title'],
  properties: {
    title: { type: 'string', title: 'Title', default: 'A new task' },
    done: { type: 'boolean', title: 'Done?', default: false },
  },
};复制代码


感觉使用JSON Schema来描述一个表单很美好,但第一个节点的类型采用‘object’直接让人很失望透顶。不论如何一个表单中的字段从UI来讲一定是有序的,这显然并不能满足要求。也许你会说用Object.keys(formSchema.properties)并不会改变顺序,但事实可能并不是这么理所当然,至少从数据类型的语义上就是错的。假设我现在要动态插入一个字段hellotitledone之间,那如何用js来实现?我认为一个好的FormSchema定义一定满足与UI的正反向工程,否则就是在扯淡。显然目前 Mozilla 的这个产品不完善,个人认为 Mozilla 提供了一种构建表单的思想,也会通过 Mozilla 的影响力进一步推进 JSON Schema 在构建领域的发展。


现在的 JSON Schema 标准在表单方面的表达的确是有很多切合的地方,比如 required、dependencies(dependentRequired/dependentSchemas)等,但是这些属性的创建并不是基于表单场景上的思考,而是对 JSON 文档的规范。我个人的看法是 JSON Schema 应该先做好JSON表达的工作,目前这样的功能还不够普及。


  • Layout + Component


const formSchema = {
  root: 'root',
  layout: {
    root: ['loginInput'],
    loginInput: ['username', 'password'],
  },
  components: {
    root: {
      type: 'Struct',
    },
    loginInput: {
      type: 'Struct',
    },
    username: {
      type: 'Input',
      label: 'User Name',
      ...
    },
    password: {
      type: 'Input',
      label: 'Password',
      ...
    },
  },
};复制代码


这种 FormSchema 的特点是将布局与组件分开,下图是渲染后的效果方便于对照



这种形式不管是从UI或者从 FormSchema 的角度,都可以反推出另一个的表现形式。例子中有一个loginInput的结构节点并不好从界面反推,但存在这样的节点一定会有开发者的思考,比如登录成功后会隐藏这个节点,然后显示一个已登录的信息提示。而从语义上来讲 UI 反推出的 FormSchema 字段 'username' 和 'password' 顺序一定是固定的,反之亦然。


  • UI Struct


const formSchame = {
  tag: 'Struct',
  name: 'root',
  children: [
    {
      tag: 'Struct',
      name: 'loginInput',
      children: [
        {
          type: 'Input',
          name: 'username',
          label: 'User Name',
          ...
        },
        {
          type: 'Input',
          name: 'password',
          label: 'Password',
          ...
        },
      ],
    },
  ],
};复制代码


UI Struct 的描述形式几乎就是与界面结构基本一样或者与vDOM的结构一样,与Layout + Component也就是写法的区别,这两种形式我认为只是生产FormSchema 方式的区别。UI Struct 我认为更适合手写,Layout + Component适合与构建平台结合或者表单产品化时可以考虑。


关于协议没有强推荐,还是根据场景来。如果有高扩展性的需求那就像上面分析的那样使用 FormSchema, 特别是当表单中有一些区域需要与后端的数据进行联动时,那么 FormSchema 可以有更好的扩展性和保证交互的一致性,优势也会更明显。


组件


这里的组件单指表单可以消费的表单组件,框架不应该内置的形式将普通组件(AntD.Input)封装成表单组件,以注册和附加表单能力的方式是我目前看到最好的方案


什么普通组件?什么表单组件?


为什么不应该内置将普通组件封装成表单组件?


<FormItem Tag="Input" id="username" label="User name"/>复制代码


这就是一个典型案例,如果我们不需要FormItem提供的功能,或者要替换其中某一项功能时,那么只能重写FormItem。做框架时没有可能把交互功能可以想周全,也没有周全的可能,场景越多越是这样。 所以最好的方式就是放开,也就是前文一直提到的解耦。


Form.registerAbility('ui', UI); // 表单数据组件的UI附加能力
Form.registerAbility('ui-button', UiButton); // 表单按键组件的UI附加能力
Form.registerAbility('middleware', middleware);  // 附加操作中间件能力
Form.register('Struct', Struct); // 注册排版组件
Form.register('Input', AntD.Input, 'ui', 'middleware'); // 注册Input组件,并附加ui和middleware能力
Form.register('Button', AntD.Button, 'ui-button', 'middleware');  // 注册Button组件,并附加ui和middleware能力复制代码


不同的组件类型可能对能力有自己的要求,譬如Struct根本不需要Label这种需求。使用注册和附加能力的方式可以让表单组件与UI库之间完全解耦,功能上做到无限扩展,框架可以提供一些常用的能力注册,开发者可以根据自己的需求自定义表单组件的能力来新增或替换表单组件的功能。


表单组件分类


大致可以将表单组件分成三类


  1. 表单排版/布局组件,其特点一般不需要与表单框架不会有数据交互,或者说这部分不会生产表单提交的数据(formData)
  2. 数据组件,用来生产表单数据
  3. 按钮组件,用来交互


所有数据交互都应该通过引擎来触发


组件的要求


特指数据组件对应的组件,比如AntD.Input


  1. 组件一定满足输入的数据(设置value)与输出的数据( onChange(value) )是一致, 并且与UI表单保持一致。
  2. 一定是受控组件, 对于框架数据来源不一定来至数据组件的生产,还可能来至其它组件的联动,或者编辑表单。组件内部本身是不是并不重要,一些组件为了性能可能会做为非受控,但注册进来之前包一层受控的封装。


渲染核心


就是将渲染协议解析成界面,过程中会根据协议中配置将注册的组件进行动态合成,渲染要实现数据的绑定及引擎注入( 参考redux-form本质都是一样的 ),这部分不做详解。


引擎


引擎负责调配表单框架,表单的逻辑都由引擎来控制。一般来讲这个实例会通过表单的实例向使用者开放,因此API设计的颗粒度要掌控好。


以下是我认为一定要实现的接口


  • componentChangeFactory({ payload, name } 当字段数据改变时调用这个方法,方法通过中间件将数据做进一步加工,后面会通过dispatch(action)来保存到Store
  • componentMountFactory({ payload, name }) 挂载时将字段数据保存到Store中,后继的字段渲染都会直接使用这份数据,不会再使用原始的协议数据
  • componentUnmountFactory({ name }) 字段卸载时将表单实例、Store中对应该的所有数据移掉
  • addComponent(id, component) 保存表单组件实例
  • getComponent(id) 获取表单组件实例,以便对实例进行操作,可以控制返回的信息不一定非要把表单组件实例直接返回,建议封装组件操作的方法返回即可
  • async exce() 手动调用中间件
  • doValidator() 手动触发校验
  • getData() 获取表单数据
  • setData(data) 设置表单数据
  • studData() 析构表单数据


中间件


中间件是用来负责加工数据,他的意义是处理相关问题。这些相关的问题包括表单与服务端的数据相互、字段之间的关联处理、字段校验等等,这些功能都是与表单本质无关但对于现在表单框架都很推崇的功能。


中间件分成两类:一类是关联处理,特点是会有递归操作;另一类是触发时只运行一次比如校验。


关联处理


我认为这是表单框架最复杂的部分,通常每一次大版本迭代这部分都会占1/3以上的资源。关联处理是解析表单中两个或多个字段之间的联动并执行操作,也就是说包含解析和执行两个部分(其实所有代码的逻辑都是这样)。通常解析过程也是执行的过程,只是想让解析变得更纯粹,解析符合逻辑要求然后去执行对应的操作。举例:当性别选项选择“男”时显示年龄输入框,选择“女”时隐藏,性别选项选择值的判断就是解析过程,年龄输入框显示或隐藏就是操作过程。可以结合 FormSchame


{
  tag: 'struct',
  name: 'root',
  children: [
    {
      tag: 'radio',
      name: 'gender',
      label: 'Gender',
      props: {
        dataSource: [
          {
            value: 'male',
            label: '男',
          },
          {
            value: 'female',
            label: '女',
          },
        ],
      },
    },
    {
      tag: 'input',
      label: 'Age',
      name: 'age',
      visible: "${formData.gender} == 'male'",
    },
  ],
}复制代码


这是一个极为简单的例子,现实中在操作过程可能要比这复杂的多,比如需要后端来处理一些逻辑或源数据的处理等,那就需要指定一个处理的方式。


const formSchema =  {
  tag: 'Struct',
  name: 'root',
  children: [
    ...,
    {
      tag: 'checkbox',
      name: 'interest',
      label: '兴趣',
      depends: ['${formData.gender}'],
      methods: ['interestMethod'],
    },
    ...
  ],
};
 
 <Form
  schema={formSchema}
  name="demo"
  methods={{
    interestMethod: (engine) => (next) => (payload) =>{
      // todo
      return payload
    }
  }}
/>复制代码


interest 字段依赖 gender 的值,当gender改变是会去调用 interestMethod 方法, 而 interestMethod 方法在框架初始化时配置进去。


只要有代码的存在那就可以实现一切想要实现的功能。然而这样就存在一个问题,如果在interestMethod方法里面将interest的值改变,恰好有另外一个字段对interest的值的依赖时,那就会遗漏这次的解析,因此这边就需要递归整个表达式解析的流程,一直到处理完成为止。


关联处理是一个非常复杂的过程,有机会的话详细再写一篇,核心是考虑三点:递归、抽象所有不同关联处理、异步处理。很明显关联处理的思路都是结合了 FormSchema,事实上这就是 FormSchema 本身的价值。


校验处理


相比关联处理这个是一非常简单的过程。这里我直接从数据流转的视角上来说明一下。下面是必填的输入框,原来的值为“Jack MA”,现在把输入框的内容清空后中间件中数据变化情况,重点关注一下payload值的变化。


image.png



// 校验中间件实现
export default class Middleware extends MiddlewareBase {
  name = 'ValidatorMiddleWare';
  type = 'validator';
  // eslint-disable-next-line class-methods-use-this
  change(engine, name) {
    return (next) => (payload) => {
      const { formData = {} } = payload;
      return engine.validatorFactory.exec(name, formData[name]).then((valitionResult) => {
        const result = { ...payload };
        if (valitionResult) {
          const { isPass, message } = valitionResult;
          if (!isPass) {
            if (!result.message) {
              result.message = {};
            }
            result.message[name] = message.filter((i) => i.msg).map((i) => ({
              type: 'error',
              msg: i.msg,
            }));
          }
        }
        return next(result);
      });
    };
  }
}

// 中间件基类
export default class MiddlewareBase {
  name = 'MiddlewareBase';
  type = 'normal';
  constructor(engine) {
    this.engine = engine;
  }
  mount() {
    return (next) => (payload) => next(payload);
  }
  change() {
    return (next) => (payload) => next(payload);
  }
  unmount() {
    return (next) => () => next();
  }
}复制代码


中间件是个好东西,可以处理(改变)表单的任何数据,这也给了使用者极大的想象空间。整个框架里面也就这块内容能体现技术深度,所以在没有代码的情况很难说清楚。


总结


写框架不同于页面或者组件什么的, 你在每一个时期对框架都有新的理解,需要不断优化改进,这个过程其实是一个总结和思想的沉淀。 目前为止我至少重写过五个版本,每个版本至少有一半的代码是全新的,每次改版之前都认为自己想清楚了,然而过程总是充满惊奇,所以我认为行动很重要。 开发一个框架的价值不仅限于使用到具体的场景, 日常工作中不可能都是自己的东西,但在使用三方框架时你会从作者开发框架的思路上去切入, 放大每一个引入框架的价值。