前端简洁表单模型

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

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

前言

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

表单模型的基础概念

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

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

其中:

  • 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 内部是如何实现的(已对源码进行一些优化和注释):

tsx 复制代码
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 来介绍下路径的概念:

js 复制代码
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 变成响应式:

js 复制代码
import { define, observable } from '@formily/reactive'

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

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

js 复制代码
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

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

表单模型的规范

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

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

  • 方案1:在 城市名称 字段的 onChange 事件里通过 form.values.cityCode = hz 的形式去动态修改 城市编码 字段。
  • 方案2:在 城市编码 字段里显示的配置对 城市名称 字段的依赖,同时需要配置依赖改变时的处理逻辑,例如:
js 复制代码
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 形式

表单模型的应用层

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

  • 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 标准语言来表述而已,举个例子:

js 复制代码
// chema 形式
const formSchema = {
  name: {
    type: 'string',
    'x-decorate': 'FormItem',
    'x-component': 'Input',
    'x-component-props': {
      placeholder: '请输入名称'
    }
  }
}
jsx 复制代码
// 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 嵌套了很多层字段

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

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

js 复制代码
{
  categoryList: [
    {
      categoryName: '分类一',
      productList: [{ productName: '商品一', others: 'xxx' }],
    },
    {
      categoryName: '分类二',
      productList: [{ productName: '商品二', others: 'xxx' }],
    }
  ],
}

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

方案一

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

js 复制代码
// 设计一个大而全组件,过滤组件内部实现
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 设计的,两者的设计思路是分开的(但是在大多数场景下两者的设计结果是一致的) 那么我们的实现方式应该是这样的:

js 复制代码
// 基于 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 呢?

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

相关推荐
拖孩6 小时前
AI 帮我写的小程序「厨菜记」上线满月了,挣了两百块!!!
前端·后端·微信小程序
用户360055579006 小时前
Kestrel_VLLM - 13 · 为什么排除 MoE FFN Tiling 与 Tensor Core
前端
Hilaku6 小时前
AI 流式输出都用 SSE,什么时候必须换 WebSocket?
前端·javascript·程序员
奇牙coding6 小时前
Codex CLI 升级后多文件编辑静默失效:config.yaml 枚举变更排查与修复指南
java·前端·javascript·ai
小羊没烦恼!6 小时前
Ajax与JSON的一些总结
android·java·前端·数据库·mysql·算法·okhttp
程序员老赵6 小时前
Docker 部署 Filestash:轻松搭建浏览器文件管理平台
前端·docker·开源
夏天要喝冰可乐6 小时前
给 23 万字修仙小说造个阅读站:浏览器内打包 EPUB 分页阅读 + 三主题实战
前端·产品·全栈
茶底世界之下6 小时前
林间步道要不要加暗角?用 Awaking 把强度收在20%
前端·ios·图片资源
执子念的飞鱼6 小时前
纯前端预览 275 种文件格式是怎么做到的
前端·vue.js·react.js
行者全栈架构师7 小时前
【鸿蒙心迹】互动卡片全链路——把核心功能放到桌面(FormKit 实战)(HarmonyOS 7.x)
前端·算法·架构