前端简洁表单模型

4,969 阅读14分钟

gabriel-ramos-azbe3hSHNHU-unsplash.jpg

大家好,我是前端菜鸡木子

今天想和大家浅谈下前端表单的简洁模型。说起表单大家一定都不陌生,因为各自团队内部一定充斥着各种或简单或复杂的表单场景。为了解决表单开发问题,市面上也有着许多优秀的表单解决方案,例如:Formily、Ant Design、FormRender 等。这些框架的底层都维护着一套基础的「表单模型」,虽然框架不同,但是「表单模型」的设计却是基本一致,只是上层应用层的设计会随着业务的需求进行调整。今天的主题也会围绕着「表单模型」进行展开

前言

本文是偏基础层面的介绍,不会涉及到太多框架的源码解析。另外,我会以最近如日中天的 Formily 为例进行讲解,大家如果对 Formily 不太了解,可以先去了解和使用。

表单模型的基础概念

我们知道一个表单包含了 N 多个字段,每个字段都需要用户输入或者联动带出,当用户输入完成之后我们可以通过 Form.Values 的形式直接获取到表单内部 N 多个字段的值,那么这是如何实现的呢?

我们通过一张图来简单阐述下:

yuque_diagram.png

其中:

  • Form:是通过 JS 维护的一个表单模型实例,Formily 里 createForm 返回的就是这个实例,它负责维护表单的所有数据和每个字段 Field 的实例
  • Field: 是通过 JS 维护的每一个字段的实例,它负责维护当前字段的所有数据和状态
  • Component: 是每个字段对应的展示层组件,可以是 Input 或者 Select,也可以是其它的自定义组件

从图中不难看出,每个 Field 都对应着一个展示层的 Component,当用户在 Component 层输入时,会触发 props.onChange 事件,然后在事件内部将用户输入的值传入到 Field 里。同时当 Field 值变化时 (比如初始化时的默认值,或者通过 field.setValue 修改字段的值 ),又会将 Field.value 通过 props.value 的形式传入到 Component 内部,以此来达到 Componet 和 Field 的数据联动。

我们可以看下在 Formily 内部是如何实现的(已对源码进行一些优化和注释):

const renderComponent = () => {
  // 获取 Field 的 value
  const value = !isVoidField(field) ? field.value : undefined;
  
  // 设置 onChange 事件
  const onChange = !isVoidField(field)
    ? (...args: any[]) => {
        field.onInput(...args)
        field.componentProps?.onChange?.(...args)
      }
    : field.componentProps?.onChange

  // 生成 Field 对应的 Component  
  return React.createElement(
    getComponent(field.componentType),
    {
      value,
      onChange,
    },
    content
  )
}

这里面的 onChange 事件里触发了 field.onInput 的事件,在 field.onInput 内会做两件事情:

  • 将 onChange 携带的 value 赋值给 field.value
  • 将 onChange 携带的 value 赋值给 form.values

这里需要额外说明的是,一个 Form 会通过「路径」系统聚合多个 Field,每个 Field.value 也是通过路径系统被聚合到 Form.values 下。

我们通过一个简单的 demo 来介绍下路径的概念:

const formValues = {
  key1: {
    key2: 'value',
  }
};

我们通过 key1.key2 可以找到一个具体的值,这个 key1.key2 就是一个路径。在 Formily 内维护了一个高级的路径模块,感兴趣的可以去看下 form-path

表单模型的响应式

聊完表单模型的基础概念后,我们知道

  • Component 组件通过 props.onChange 将用户的数据回传到 Field 和 Form 实例内
  • Field 实例内的 value 会通过 props.value 形式传递到 Component 组件内

那么问题来了,Field 实例内部的 value 改变后,Component 组件是如何做到细粒度的重新渲染呢?

不卖关子,直接公布答案:

  • formily: 通过 formily/reactive 进行响应式跟踪,能知道具体是哪个组件依赖了 Field.value, 并做到精准刷新
  • Antd:通过 rc-field-form/useForm 这个 hook 来实现,本质上是通过 const [, forceUpdate] = React.useState({}); 来实现的

虽然这两种方法都能实现响应式,但是 Ant 的方式比较暴力,当其中一个 Field.value 发生改变时,整个表单组件都需要 render 。而 Formily 能通过 formily/reacitve 追踪到具体改变的 Field 对应的 Componet 组件,只让这个组件进行 render

formily/reactive 实现比较复杂,这边不会深入探讨具体实现方式,感兴趣的小伙伴可以看下这篇文章 从零开始撸一个「响应式」框架 (本质上是通过 Proxy 来拦截 get 和 set,从而实现依赖追踪)

接下来,我们就看下如何借助 formily/reactive 来实现响应式

第一步:我们需要在 Field 初始化时将 value 变成响应式:

import { define, observable } from '@formily/reactive'

class Field {
  constructor(props) {
    // 初始化 value 值
    this.value = props.value;
    
    // 将 this.value 变成响应式
    define(this, {
      value: observable.computed
    })
  }
}

第二步:对 Field 对应的 Componet 进行下 "包装":

import { observer } from '@formily/reactive-react'

const ReactiveComponentInernal = () => {
  // renderComponent 源码在 「基础概念」章节里
  return renderComponent();
}

export const FieldComponent = observer(ReactiveComponentInernal);

observer 内部也和 rc-field-form/useForm 类似,通过 const [, forceUpdate] = React.useState({}); 来实现依赖改变时,子组件级别的动态 render

到此为止,表单模型的响应式也基本完成了

表单模型的联动

表单联动是指表单内某些字段依赖其它字段的值时,当被依赖的字段发生改变,依赖方也需要感知并且能够触发执行一些逻辑函数。这是表单模型中较为常见的一个能力。

那么在 Antd 和 Formily 中,他们是如何实现表单联动呢?

  • Antd:通过 FormStore 类来实现了简单的依赖收集和触发逻辑
  • Formily: 通过 formily/reactive 来实现依赖的收集和自动执行

无论是 Antd 还是 Formily 本质上都是通过:依赖收集 - 监听值变化 - 执行依赖方回调函数。这是所有联动的抽象化模型 (是不是和 Vue 的响应式很像)

在 Antd 里,每个 Field 字段在初始化时都会将当前的字段存储到 FormStore 里,当某一个字段的 value 改变时会触发 FormStore 的 updateValue 函数,在函数内会找到依赖当前字段的其他字段集合(通过 props.depencies 来声明依赖),然后依次触发被依赖字段的 onStorageChange 函数。在该函数内通过 React 提供的 forceUpdate 来实现 rerender。感兴趣的小伙伴可以看下 rc-componet/form 源码。

接下来我们看下 formily 的实现方法。但是在看之前我们需要先了解下 formily/reactive 在 formily 架构中的(身份)。formily 把表单的逻辑层都抽象到了 formily/core 里,而 formily/reactive 则为 formily/core 提供了外部值与组件的响应式联动能力。本章要讲解的表单联动本质上也是个 “响应式” 联动的模型。刚好也可以借助于 formily/reactive + formily/core 来实现,具体如下:

一、收集依赖

我们可以看到在 Field 组件初始化时,会执行 Form.createField(...) 方法,在该方法内会去实例化 Field 类,然后在 Field 的构造函数内会间接调用到 createReactions 函数:

  export const createReactions = (field: GeneralField) => {
    const reactions = toArr(field.props.reactions)
    field.form.addEffects(field, () => {
      reactions.forEach((reaction) => {
        if (isFn(reaction)) {
          field.disposers.push(
            autorun(
              batch.scope.bound(() => {
                if (field.destroyed) return
                 reaction(field)
              })
             )
           )
         }
       })
     })
   }

在 createReactions 内会把当前字段依赖的 x-reactions 值取出来,然后进行是否为函数的判断,如果是函数的话,就执行,看到这里,小伙伴肯定会有疑问:

  • 1、为什么 x-reactions 是个函数呢?我们写的时候不是个对象吗?
  • 2、为什么执行的时候要在外面套一个 autorun 函数呢?

先来解决第一个问题:其实在 Field 字段初始化的时候,在解析 json-schema 阶段,就把 x-reaciton 转化为了一个函数,然后通过 props 返回给了 Field 组件,所以在执行 Form.createField 时,传入的参数就是转换后的值。

再来看第二个问题:autorun 是 formily/reactive 提供的,是想做到当前字段依赖的字段值改变时,autorun 内部的函数能够自动执行,这样就能实现表单的联动

二、监听依赖并 rerender

在依赖收集章节里,我们知道 formily 把 x-reactions 解析成了函数,并在 autorun 里执行,我们看下解析后的函数内部逻辑:

const reactions: SchemaReaction[] = toArr(schema['x-reactions']);
return reactions.map((unCompiled) => {
  return (field: Field) => {
    const baseScope = getBaseScope(field, options)
    const { when, fulfill, otherwise, target, effects } = reaction
    const run = () => {
      const scope = lazyMerge(baseScope, {
        $target: null,
        $deps,
        $dependencies,
      })
      
      setSchemaFieldState(...)
    }
    
    run()
  }
})

map 函数内返回的函数就是在 autorun 里执行的函数,这个函数内最终会执行 setSchemaFieldState 函数,setSchemaFieldState 比较复杂,大家可以简单的理解成就是通过 new Fucntion 的形式去执行我们在 x-reaction.xxx.schema | state 内填写的字符串模板。然后借助于 formily/reactive 的响应式能力,在 x-reactions.depencies 内的字段变更时,formily 能够自动执行 autorun 内的函数

无论是内部实现还是业务使用,表单联动都是表单模型中的「重中之重」,看的出来 formily 的架构分层设计的比较合理,各个模块间解耦且复用的十分不错,有机会可以和大家着重聊聊这块。

表单模型的规范

有了以上的表单模型,我们就可以构建一个简单的表单框架。但是真实的业务场景却不可能这么简单,迎面而来的第一个问题就是「联动」,举个例子:

QQ20230729-134607-HD.gif

需求:当城市名称改变后,城市编码字段需要联动带出对应的值。我们可以快速想到两种方案:

  • 方案1:在 城市名称 字段的 onChange 事件里通过 form.values.cityCode = hz 的形式去动态修改 城市编码 字段。
  • 方案2:在 城市编码 字段里显示的配置对 城市名称 字段的依赖,同时需要配置依赖改变时的处理逻辑,例如:
const formSchema = {
  cityName: {
    'x-component': 'Select',
  },
  cityCode: {
    'x-component': 'Input',
    'x-reactions': {
    dependencies: ['cityName'],
      fulfill: {
        state: {
          value: '{{ $deps[0]?.value }}',
        },
      },
    },
  },
};

无论方案 1 还是方案 2 都能实现需求,但是两个方案各有缺点

方案 1 有两个问题:

  • 问题一:打破了【表单模型的基础概念】,cityName 对应的组件的 onChange 事件里「直接」对 cityName 和 cityCode 字段进行了修改。
  • 问题二:我们不能「直观」的看到 cityCode对 cityName 字段产生了依赖,只有在看具体代码时才能知道

方案 2 也会有两个问题:

  • 问题一:schema 本身的可读性不强,且使用 formily schema 时,配置内容比较多
  • 问题二:使用 schema 配置 x-component-props 时不能使用 ts 特性

当表单逐渐复杂起来的时候,方案 1 的弊端会逐步显现出来,字段间会产生诸多的 「幽灵」依赖和控制,导致后续迭代的时候根本无从下手。所以在我自己的团队内部,我们规定出了几条「表单模型」的使用规范:

  • 规范 1: 每个 Field 对应的 Component 只对自己的字段负责,不允许通过 Form api 直接修改其他字段
  • 规范 2: 在 formSchema 里需要维护表单的所有字段配置和依赖,字段间不允许出现「幽灵」依赖
  • 规范 3: 尽量不要使用 form.setValues、form.queryField('xxx').setValue 等动态修改字段值的 Form api(特殊场景除外)
  • 规范 4: 表单涉及到的所有字段都尽量存储到表单模型中,不要使用外部变量来保存

这些规范其实是个普适性的范式,无论你在使用 Formily 也好,还是 Ant Design 也好,都需要去遵守。规范 2 里我用了 Formily 的 schema 来说明,但如果你使用的是 Ant Design,可以把 formSchema 理解为 <Form.Item reaction={{ xxx }}></Form.Item>

其实 formily 的 schema 最终会通过 RecursionField 组件递归渲染成具体的 FormItem 形式

表单模型的应用层

有了上述的「表单模型」概念和规范之后,我们就可以来构建表单模型的应用层了

yuque_diagram(1).png

  • Form Scheam: 整个表单的配置中心,负责表单各个字段的配置和联动 、校验等,它只负责定义不负责实现。它可以是个 Json Schema,也可以是 Ant Design 的 <FormItem>
  • Form Component: 表单内每个字段的 UI 层组件,可以再分为:基础组件 和 业务组件,每个组件都只负责和自己对应的 Field 字段交互
  • 业务逻辑:将复杂业务抽象出来的业务逻辑层,纯 JS 层。当然这一层是虚拟的概念,它可以存在于 Form Componet 里,也可以放在入口的 Index Component 内。如果业务复杂, 也可以放到 hooks 里或者单独的 JS 模块内部

有了应用层架构后,在写具体表单页面时,我们需要在脑海中清晰的勾勒出每层(Schema Component Logic)的设计。当页面足够简单时,也许会没有 Logic 层,Component 层也可以直接使用自带的基础表单组件,但是在设计层面我们不能混淆

表单模型的实践 - Formily

从去年开始,我们团队便引入 formily 作为中后台表单解决方案。在不断的实践过程中,我们逐步形成了一套自己的开发范式。主要有以下几个方面

Formily 的取舍

我们借助了 formily 的以下几个能力:

  • formily/reactive: 通过 reacitve 响应式框架来构建业务侧的数据模型
  • formily/schema: 通过 json-schema 配置来描述整个表单字段的属性,当然其背后还携带着 formily 关于 schema 的解析、渲染能力
  • formily/antd: 一些高级组件

同时,我们也在尽量避免使用 formily 的一些灵活 API:

  • Form 相关 API:比如 useForm、form.setValues 等,我们不希望在任何组件内部都能「方便」的窜改整个表单的所有字段值,如果当前字段对 XX 字段有依赖或者影响,你应该在 schema 里显示的声明出来,而不是偷偷摸摸的修改。
  • Query 相关 API: 比如 form.query('field'),原因同上

当然,这不代表我们绝不会使用这些 API ,比如在表单初始化时需要回填信息的场景,我们就会用到 form.setValues 。我想说明的是不能滥用!!!

静态化的 schema

我们认为 schema 和普通的 JSX 相差不大,只不过前者是通过 JSON 标准语言来表述而已,举个例子:

// chema 形式
const formSchema = {
  name: {
    type: 'string',
    'x-decorate': 'FormItem',
    'x-component': 'Input',
    'x-component-props': {
      placeholder: '请输入名称'
    }
  }
}
// jsx 形式
const Form = () => {
  return (
    <Form>
      <FormItem name"name">
        <Input placeholder="请输入名称" />
      </FormItem>
    </Form>
  )
}

schema 最终也会被 formily/react 解析成 jsx 格式。那为什么我们推荐使用 schema 呢?

  • 原因一:schema 可以做到足够的静态化,避免我们做一些灵活的动态操作 (在 jsx 里我们几乎能做通过 form 实例动态的做任何事情)
  • 原因二: schema 更容易被解析和生成,为之后的智能化生成做铺垫(不一定是低代码)

表单模型的挑战

在真实业务开发过程中,我们对表单模型的使用会出现一些问题,以两个常见的问题为例:

  • 问题 1:我们是通过表单的 UI 结构来设计 schema 还是通过表单数据结构来设计?
  • 问题 2:有时候为了简单,我们会设计出一个巨大的 Component,这个 Componet 对应的 Field 嵌套了很多层字段

下面这个案例就可能触发上述的两个问题:

demo2.gif

其中,每个分类都对应着一组商品,所以最终表单的数据格式应该是这样的:

{
  categoryList: [
    {
      categoryName: '分类一',
      productList: [{ productName: '商品一', others: 'xxx' }],
    },
    {
      categoryName: '分类二',
      productList: [{ productName: '商品二', others: 'xxx' }],
    }
  ],
}

我们提供两种思路来设计这个表单

方案一

我们发现简单的通过 ArrayTable 是实现不出这种交互的,所以我们直接设计出一个大而全的 Component,那么我们的实现方式应该是这样的:

// 设计一个大而全组件,过滤组件内部实现
const BigComponent = (props) => {
  return (
    <Row>
      <CategoryArrayTable />
      <ProductArrayTable />
    </Row>
   )
};

// schema 设计
const formSchema = {
  categoryList: {
    type: 'array',
    'x-component': BigComponent,
  }
}

在这种方案里,BigComponent 组件需要 onChange 整个表单的值(多层嵌套的对象数组),这会出现一个问题:formSchema 里看不到表单的所有字段配置,如果字段间需要有联动,那么只能在 BigComponent 组件内部去实现(违反了规范2)。

方案二

我们认为 schema 是面对表单数据结构设计的,Component 是面对 UI 设计的,两者的设计思路是分开的(但是在大多数场景下两者的设计结果是一致的) 那么我们的实现方式应该是这样的:

// 基于 formily/antd/ArrayTable + formily/react RecursionField 来实现
const CategoryArrayTable = (props) => {
  return (
    <Row>
      <ArrayTableWithoutProductList />
      <ArrayTableWithProductList />
    </Row>
  )
};

// schema 设计
const formSchema = {
  categoryList: {
    type: 'array',
    'x-component': CategoryArrayTable,
    items: {
      categoryName: {
        type: 'string',
        'x-component': 'Select',
      },
      productList: {
        type: 'array,
        'x-component': 'ArrayTable',
        items: {
          productName: {
           type: 'string',
           'x-component': 'Select',
          },
          others: {},
        }
      }
    },
  }
}

在这种方案的 schema 里能够直接反映出表单的所有字段配置,一目了然,而且真实的代码实现会比方案一简洁很多

但是呢,这个方案有个难点,需要开发者对 formily 的渲染机制,主要是 RecursionField 和 ArrayTable 的源码有一定程度的了解。

当然,还有很多其他的方案可以实现这个需求,这边只是拿出两个方案来对比下设计思路上的差异,虽然最终的方案取舍是根据团队内部协商 + 规范而定的,但是在我自己的团队里,我们一直保持着一种设计准则:

schema 是面对表单结构的,Component 是面对 UI 的

后续

在实践过程中,我们发现了一些待优化点:

1、我们发现对于复杂的表单页面,schema 的配置会非常冗长,如果 schema 足够静态化的话,我们是否可以简化对 schema 的编写,同时能提高 schema 的可读性呢?低代码平台是个方案,但是太重,是否可以考虑弄个 vsocde 插件类接管 schema ?

2、如果表单配置、表单子组件、业务逻辑都由 schema、Component、Logic Fucntion 来负责了,我们是否可以取消表单页面的入口组件 index.tsx 呢?

当然随着对表单的不断深入研究,还有很多其他问题可以优化和解决,这边就不一一列举了