前端简洁表单模型

 
 

大厂技术  高级前端  Node进阶

点击上方 程序员成长指北,关注公众号
回复1,加入高级Node交流群

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

前言

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

表单模型的基础概念

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

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

79888a4ba6978ac9cd150dc0ef968679.png

其中:

  • Form:是通过 JS 维护的一个表单模型实例,FormilycreateForm 返回的就是这个实例,它负责维护表单的所有数据和每个字段 Field 的实例

  • Field: 是通过 JS 维护的每一个字段的实例,它负责维护当前字段的所有数据和状态

  • Component: 是每个字段对应的展示层组件,可以是 Input 或者 Select,也可以是其它的自定义组件

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

我们可以看下在 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[4]

表单模型的响应式

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

  • Component 组件通过 props.onChange 将用户的数据回传到 FieldForm 实例内

  • 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 实现比较复杂,这边不会深入探讨具体实现方式,感兴趣的小伙伴可以看下这篇文章 从零开始撸一个「响应式」框架[5] (本质上是通过 Proxy 来拦截 getset,从而实现依赖追踪)

接下来,我们就看下如何借助 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

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

表单模型的联动

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

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

  • Antd:通过 FormStore 类来实现了简单的依赖收集和触发逻辑

  • Formily: 通过 formily/reactive 来实现依赖的收集和自动执行

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

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

接下来我们看下 formily 的实现方法。但是在看之前我们需要先了解下 formily/reactiveformily 架构中的(身份)。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 时,传入的参数就是转换后的值。

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

二、监听依赖并 rerender

在依赖收集章节里,我们知道 formilyx-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 的架构分层设计的比较合理,各个模块间解耦且复用的十分不错,有机会可以和大家着重聊聊这块。

表单模型的规范

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

c3c8842704971221767cc69e7693be1a.png

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

  • 方案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 事件里「直接」对 cityNamecityCode 字段进行了修改。

  • 问题二:我们不能「直观」的看到 cityCodecityName 字段产生了依赖,只有在看具体代码时才能知道

方案 2 也会有两个问题:

  • 问题一:schema 本身的可读性不强,且使用 formily schema 时,配置内容比较多

  • 问题二:使用 schema 配置 x-component-props 时不能使用 ts 特性

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

  • 规范 1: 每个 Field 对应的 Component 只对自己的字段负责,不允许通过 Form api 直接修改其他字段

  • 规范 2: 在 formSchema 里需要维护表单的所有字段配置和依赖,字段间不允许出现「幽灵」依赖

  • 规范 3: 尽量不要使用 form.setValuesform.queryField('xxx').setValue 等动态修改字段值的 Form api(特殊场景除外)

  • 规范 4: 表单涉及到的所有字段都尽量存储到表单模型中,不要使用外部变量来保存

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

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

表单模型的应用层

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

78d1c39dd8d76979a82b3feb289d87d6.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:比如 useFormform.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 嵌套了很多层字段

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

db93173a8dc9074ffa3ff5f105debc31.png

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

{
  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 的渲染机制,主要是 RecursionFieldArrayTable 的源码有一定程度的了解。

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

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

后续

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

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

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

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

参考资料

[1]

https://formilyjs.org/: https://link.juejin.cn/?target=https%3A%2F%2Fformilyjs.org%2F

[2]

https://ant-design.antgroup.com/index-cn: https://link.juejin.cn/?target=https%3A%2F%2Fant-design.antgroup.com%2Findex-cn

[3]

https://xrender.fun/form-render: https://link.juejin.cn/?target=https%3A%2F%2Fxrender.fun%2Fform-render

[4]

https://core.formilyjs.org/zh-CN/api/entry/form-path: https://link.juejin.cn/?target=https%3A%2F%2Fcore.formilyjs.org%2Fzh-CN%2Fapi%2Fentry%2Fform-path

[5]

https://juejin.cn/post/7201314551576690749: https://juejin.cn/post/7201314551576690749

[6]

https://github.com/react-component/field-form/blob/master/src/useForm.ts: https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Freact-component%2Ffield-form%2Fblob%2Fmaster%2Fsrc%2FuseForm.ts

作者:木与子

来源:https://juejin.cn/post/7261262567304921146

Node 社群

 
 

我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。

951200f2f17f693f1cf57b8a06a73be2.png

“分享、点赞、在看” 支持一下
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值