快速上手 Alibaba Formily

6,955 阅读6分钟

快速上手 Alibaba Formily

Formily v1 (截止今天2021年12月,Formily v2 已经迭代到了v2.07, 相信有部分公司的项目依然是使用Formily v1版本)

目前Formily 已经更新到v2 ,Formily2 相比于 Formily1.x,差别非常大,性能显著提升,Api更加完善,存在大量 Break Change,所以不能平滑升级到v2。Formily官方的迭代计划是2021年底发布正式版,beta/rc版本持续迭代半年,由于公司项目使用的是v1 版本,本文基于Formily v1 以及项目当中使用的内容进行撰写。

如果觉得文章对你有帮助请点一个赞吧👍~

0. 中后台项目面临的问题

在中后台场景下,80%以上的页面,都是由表单组成,每个表单又是由各种形态的表单输入项所组成表单除了表单输入项,其实还有各种数据关联,逻辑联动,校验联动,这些可是实实在在的业务层,如何在这一层里更进一步提效?

同时,因为我们使用的是 React 技术栈,那我们又该如何解决表单项数量无限增加的交互操作性能问题?

还有,我们的表单能不能通过简单配置即可快速生产表单,即便非技术人员也能快速高效的开发出复杂表单页面?

1. JSON Schema

JSON-Schema更侧重于数据的描述,而非UI的描述。但是带来的问题是我们该如何去描述UI、如何去描述逻辑,Formily 给我们的解法是在原有的JSON-Schema的基础上进行拓展,为了不污染JSON-Schema属性,加入了 x-*格式来表达拓展属性

1.1 结构


首先,我们要理解,这份 Schema 是一份递归协议,它主要用于描述数据结构,但是 Formily 对其做了扩展,可以支持描述 UI:

{
  "type": "string",
  "title": "字符串",
  "description": "这是一个字符串",
  "x-component": "Input",
  "x-component-props": {
    "placeholder": "请输入"
  }
}

单个页面中有很多表单录入(不止一屏),便于业务的维护强烈建议Schema通过接口传给前端

1.2 Form Schema 联动协议 x-linkages

x-linkages 用于在协议层描述简单联动,注意,这个只是简单联动,它无法描述异步联动,也无法描述联动过程中的各种复杂数据处理。x-linkages 是一个数组结构,代表借助它可以实现 1 对多联动。每个数组项代表一个联动命令,需要指定联动类型 type 字段,也需要指定被联动的目标字段(target)

// 后端传来的JSON-Schema
{
  "x-component": "Radio", // 字段渲染的组件类型
  "triggerType": "onChange", // 字段校验的触发时机
  "display": true, // 字段是否样式可见
  "x-component-props": {  // UI 组件属性
    "title": "用户类别",
    "disabled": false,
    "data": [
      {
        "label": "个人",
        "value": "PERSONAL"
      },
      {
        "label": "企业",
        "value": "ENTERPRISE"
      }
    ]
  },
  "x-index": 10, // 字段的排序权重,UI组件在页面上渲染的顺序
  "default": "PERSONAL", // 字段的默认值
  "x-linkages": [ // 字段的联动信息
    {
      "type": "value:visible", // 由值变化控制指定字段显示隐藏
      "target": "*(MockUser.user.name,MockUser.user.cardType,MockUser.user.cardNo)", // 目标联动 FormPath的匹配查找语法
      "condition": "{{$value === 'PERSONAL'}}" // 联动条件
    },
    {
      "type": "value:visible", // 由值变化控制指定字段显示隐藏
      "target": "*(MockUser.user.enterprise.name,MockUser.user.enterprise.socialCode)",
      "condition": "{{$value === 'ENTERPRISE'}}"
    },
    {
      "type": "value:schema", // 由值变化控制指定字段的 schema
      "target": "MockProfile.deliveryDetail.invoiceTitleType",
      "condition": "{{$value === 'PERSONAL'}}",
      "schema": {
        "x-component-props": {
          "data": [
            {
              "label": "Foo",
              "value": "-1"
            },
            {
              "label": "Bar",
              "value": "0"
            }
          ]
        }
      }
    },
    {
      "type": "value:state", // 由值变化控制指定字段的状态
      "target": "MockProfile.deliveryDetail.invoiceTitleType",
      "condition": "{{$value === 'PERSONAL' && $target.value === "1"}}",
      "state": {
        "value": "-1"
      }
    }
  ],
}

type 目前有3种选项,主要有:

  • value:state,由值变化控制指定字段的状态
  • value:visible,由值变化控制指定字段显示隐藏. 相当于value:state的一种特例情况,即state.visible
  • value:schema,由值变化控制指定字段的 schema. 相当于value:state的一种特例情况,即state.props

1.3 属性

通过上面的示例可以看出Schema 拥有众多的属性,可参考官方文档 👉 Schema属性 具体各字段这里便不再赘述

下面主要x-rules属性,该属性用于表单校验,在实际项目中十分重要。

'x-rules':{
    // format 用于选择已定义好的正则表达式规则来验证。custom_format 为自定义的一个规则
    format:'custom_format', 
    //自定义函数验证
    validator:value => {
      return value === 'true' ? 'visible' : 'hidden'
    },
    message:'error message'  //如果出错,则用这个信息代替默认的提示内容
    injectVar:'注入的变量内容', //可以注入任意变量,用于在目标中使用。

    //自定义校验规则。
    customRule:true, 
    
    //其它可用规则示例如下
    //是否必填
    required: false
    //自定以正则
    pattern: /\d+/
    //最大长度规则
    max?: number
    //最大数值规则
    maximum?: number
    //封顶数值规则
    exclusiveMaximum?: number
    //封底数值规则
    exclusiveMinimum?: number
    //最小数值规则
    minimum?: number
    //最小长度规则
    min?: number
    //长度规则
    len?: number
    //是否校验空白符
    whitespace?: boolean
    //枚举校验规则
    enum?: any[]
    //自定义错误文案
    message?: string
    //自定义校验规则
    [key: string]: any  
}

2. 表单生命周期

可以看到,想要构成一个表单系统,必须要有一套完备的生命周期,才能驱动整个系统更好的运作,生命周期,就像心脏一样,它会不断的跳动,往外派发事件,借助生命周期,我们就能构建一个无限复杂的表单系统,毫不含糊的说,我们的业务逻辑,90%以上都是基于生命周期而来的。

formily 的表单生命周期有很多类型,这里不一一举例具体看官方文档 表单生命周期类型枚举

目前在项目中主要使用到的生命周期有 ON_FIELD_VALUE_CHANGE 字段值变化时触发ON_FORM_INIT 表单初始化之后触发ON_FORM_MOUNT 表单组件挂载完毕时触发

我们来看下面的案例,实现一对一联动👇

formily01.png

<Printer> {/* @formily/printer 可以打印出表单的Schema */}
    <SchemaForm // 内部会解析 json-schema 协议,同时会创建 Form 实例,支持受控模式,并渲染
        labelCol={5}
        wrapperCol={14}
        components={{ Input, Select }} // 注册表单组件的类型
        effects={($, { setFieldState }) => { // $选择器
            $(LifeCycleTypes.ON_FORM_INIT).subscribe(() => { // 通过订阅 ON_FORM_INIT 在表单初始化时设置字段状态
                setFieldState('controller', state => { // 设置控制者的状态
                    state.value = 'visible'
                })
            })
            $(LifeCycleTypes.ON_FIELD_VALUE_CHANGE, 'controller').subscribe( // 通过订阅 ON_FIELD_VALUE_CHANGE 在字段值变化时设置字段状态
                fieldState => {
                    setFieldState('controlled', state => { // 改变受控者的状态
                        state.visible = fieldState.value === 'visible'
                    })
                }
            )
            $(LifeCycleTypes.ON_FORM_MOUNT).subscribe(() => { // 通过订阅 ON_FORM_MOUNT 在表单组件挂载完毕时调用接口
                actionRef.current = action
                dispatch({
                  type: 'Foo/setAction',
                  payload: action,
                })
         	})
            $(LifeCycleTypes.ON_FIELD_VALUE_CHANGE, '*').subscribe(fieldState => {
                setTimeout(() => {
                    onFieldValueChange(fieldState)
                }, 0)
            })
        }}
        >
        <Field  // UI组件 在实际项目中更多是通过后端传来的Shcema对象来展示 前端代码不写
            type="string"
            title="控制者"
            enum={[
                { label: '显示', value: 'visible' },
                { label: '隐藏', value: 'none' }
            ]}
            name="controller"
            x-component="Select"
            />
        <Field type="string" title="受控者" name="controlled" x-component="Input" />
    </SchemaForm>
</Printer>

从上面的案例可以看出在effects中监听表单生命,通过$选择器调用订阅subscribe 根据不同的生命周期处理不同的业务需求。

接下来我们来看$选择器的两个入参:

  • LifeCycleTypes.* 表单生命周期的类型
  • matcher(可选): 匹配器 根据UI组件的name进行匹配(如上面例子中的name = "controller")

自定义组件

目前在项目中使用自定义组件的方式并不多,但是阅读formily v1 官方文档 发现这种写法还是值得学习的

之前都是使用内置的表单组件进行开发,下面我们来看使用自定义组件进行订阅表单生命周期

const { onFieldValueChange$ } = FormEffectHooks

const CostomComponent = () => {
  useFormEffects(($, { setFieldState }) => {
    setFieldState('控制者', state => {
      state.value = 'none'
    })
    onFieldValueChange$('控制者').subscribe(fieldState => {
      setFieldState('受控者', state => {
        state.visible = fieldState.value === 'visible'
      })
    })
  })
  return (
    <div>
      <FormItem
        label="控制者"
        dataSource={[
          { label: '显示', value: 'visible' },
          { label: '隐藏', value: 'none' }
        ]}
        name="控制者"
        component={Select}
      />
      <FormItem label="受控者" name="受控者" component={Input} />
    </div>
  )
}

const App = () => { // 父组件
  return (
    <Printer>
      <SchemaForm
        labelCol={5}
        wrapperCol={14}
        components={{ CostomComponent }}
      >
        <Field x-component="CostomComponent" />
      </SchemaForm>
    </Printer>
  )
}
  • 自定义组件内部使用 useFormEffects 可以订阅表单生命周期,需要注意一个地方,在 useFormEffects 内部无法监听 onFormInit 事件,因为组件渲染到自定义组件的时候,其实表单已经初始化,所以,如果我们需要做一些初始化操作,只需要在 useFormEffects 入参回调函数内直接写即可,这样代表当前自定义组件初始化时执行。
  • 自定义组件内部可以使用纯源码开发模式,使用 FormItem 组件

3. Actions

为了能够在外部控制form内部属性,formily通过 createFormActions() 来创建actions ,要调用actions 必须先把 actions 传给 SchemaForm 进行握手,否则调用不会生效,只要握手成功,则可以在任意位置调用。

const action = createFormActions();
<SchemaForm
  components={components}
  actions={actions}
  initialValues={initialValues}
  value={value}
  defaultValue={defaultValue}
  onSubmit={console.log}
  effects={effects}
  schema={schema}
  editable={false}
  expressionScope={expressionScope}
>
  <FormButtonGroup>
    <Submit>提交</Submit>
  </FormButtonGroup>
</SchemaForm>

下图为后端传来的JSON-Schema,经过node层封装后的结果

schema.png actions函数列表如下:

interface IFormActions {
  submit(onSubmit)  // 表单提交
  getFormSchema()   // 获取当前表单Schema
  clearErrors(pattern) // 清空错误消息,可以通过传FormPathPattern来批量或精确控制要清空的字段
  hasChanged(target,path) // 获取状态变化情况,主要用于在表单生命周期钩子内判断当前生命周期中有哪些状态发生了变化
  reset(options) // 重置表单
  validate(path,options) // 校验表单, 当校验失败时抛出异常.可设置遇到第一个校验错误则停止后续校验流程
  setFormState(callback,silent) // 设置表单状态
  getFormState(callback) // 获取表单状态
  setFieldState(path,callback,silent)  // 设置字段状态
  getFieldState(path,callback) // 获取字段状态
  getFormGraph() // 获取表单观察者树
  setFormGraph(graph) // 设置表单观察者树
  subscribe(callback) // 监听表单生命周期
  unsubscribe(id) // 取消监听表单生命周期
  notify(type, payload) // 触发表单自定义生命周期
  setFieldValue(path,value) // 设置字段值
  getFieldValue(path) // 获取字段值
  setFieldInitialValue(path,value) // 设置字段初始值
  getFieldInitialValue(path) // 获取字段初始值
}

在实际开发中使用最多的函数有 getFormStatesetFormStatesetFieldStategetFieldStategetFormGraph

3.1 getFormState

目前在项目中 getFormState主要是调用values属性获取表单的状态,在进行表单录入的过程中会不断的向values对象中添加值

values = {
    appPlan: {quantity: 1,selectedProps: {period: "-1", benefites: {…}}}
    applicant: {cardType: "idcard", gender: "1", name: "zhangsan", startingTime: "2021-09-02", extend: {…}, …}
	mockPlan: [{…}]
	deliveryDetail: {comInvoiceTitleType: "0"}
	mockList: [{…}]
}
const comInvoiceTitleTypeValue = action.getFormState().values.deliveryDetail?.comInvoiceTitleType

3.2 getFieldState(path,callback)

在看getFieldState这个方法时特别疑惑,因为它和getFormState实在太像了

  • getFormState 获取表单状态
  • getFieldState 获取字段状态

这两个函数到底有什么区别呢,带着这个疑问,我们先看一个例子👇

console.log(action.getFormState() === action.getFieldState()) // true
console.log(action.getFormState()) // { displayName:"FormState", {...} }
console.log(action.getFieldState()) // { displayName:"FormState", {...} }

是不是很惊讶,这两个函数完全是一个东西,同时他们的displayName都是FormState,看到这里是不是很疑惑为什么功能一样的函数要拆分为两个函数。

接下来我们来看getFieldState的两个入参

  • path: 匹配路径,基于Schema中的值(后端所传的值)
  • callback: 回调函数

上面的例子里我们没有往getFieldState里传递参数,同时编译也会提醒我们 TS2554: Expected 1-2 arguments, but got 0.,当我们传递了path参数

const cardTypePath = Applicant.applicant.cardType // JSON-Schema 中的路径
const cardType = action.getFieldState(`*(${cardTypePath})`) // { displayName: "FieldState" , {...} }

由此我们可以知道,getFormStategetFieldState的区别

  • getFieldState传入了path,这个参数可以获取到后端传来的JSON-Schema
  • getFormState可以动态获取当前表单实时修改的值。

3.3 setFiledState(path,callback,silent)

我们可以通过setFiledState 来改变表单的显隐\禁用\联动

// 生日、性别显隐\禁用
action.setFieldState(`*(${genderPath}, ${birthdayPath})`, state => {
    state.display = !isIdCard
    state.visible = isShow
    state.props['x-component-props'].disabled = isSelf
    state.props['x-props'].disabledFollowRealName = isSelf
})

❗ 通过上面的例子displayvisible 这两个属性都是用来控制字段的显隐,文档中给的描述

  • display: 字段样式是否可见 boolean
  • visible: 字段是否可见(数据+样式) boolean

getFormGraph 在下文表单节点树中提及

4. 表单路径系统

Formily 表单路径系统,我们可以说生命周期管理心脏,状态管理是肌肉,那么,路径系统就是血管,没有血管,整个体系就根本无法运作起来。

FormPath 现在基本上都是内置在 Formily 中的,我们在 Formily 中主要有几个地方会用到 FormPath

  • 监听字段变化
  • 操作字段状态
  • 解构字段
全通配 "*"
扩展匹配 "aaa~" or "~" or "aaa~.bbb.cc"
部分通配 "a.b.*.c.*"
分组通配 "*(bb,cc,dd)"
范围通配 "a.b.*[10:100]"
关键字通配 "a.b.[[cc.uu()sss*\\[1222\\]]]"

在实际项目中主要使用在setFiledState和effects 中操作表单生命周期,之前的例子都有体现这里不再赘述

5. 表单状态

每个组件(节点)字段都对应着一个状态集合。根据组件类型,分别为FormState,FieldState,VirtualFieldState三中之一。联动的目的就是操作具体字段的状态。注意,字段value也是该状态中的一个,主要方便操作

formState

注意,这里的 values 是状态名,如果是表单组件属性名,对应的是 value,而不是 values,因为状态管理是独立于 React 的,选用 value 作为 SchemaForm/Form 属性,主要是与 React 规范对齐

VirtualFieldState 代表 VirtualField 的状态,VirtualField 是 Formily 节点树中的虚拟节点,它是无值节点,所以提交的时候,不会带值,但是我们又能控制它的具体 UI 属性,所以在 Formily 中,它更多的职责是作为布局组件而存在

6.表单节点树

在说节点树之前,文中在第三点Actions中有附上经过Node层封装过的JSON-Schema对象图,通过SchemaForm解析后可以得到整个Form的状态树,可以使用getFormGraph来获取表单观察者树,具体的使用下文进行解释。

前面了解了VirtualFieldState 的基本概念,接下来我们来看Formily节点树,引用Formily文档中的案例:

img

如果存在这样一棵树的话,那么:

  • c 字段的 name 属性则是 a.c,path 属性是 a.b.c
  • b 字段的 name 属性是 a.b,path 属性是 a.b
  • d 字段的 name 属性是 a.d,path 属性是 a.d
  • e 字段的 name 属性是 a.d.e,path 属性是 a.d.e

这一来解释之后,我们就大概明白了,只要在某个节点路径中,存在 VirtualField,那么 它的数据路径就会略过 VirtualField,但是,对于 VirtualField 自身这个节点,它的 name 属性,是包含它自身的节点标识的,这就是为什么 b 字段的 name 属性是 a.b 的原因

6.1 节点树结构

我们的节点树其实就是一颗状态树,同时,它也是一颗扁平的状态树,它主要由 FormState/FieldState/VirtualFieldState 所构成

{
  "":{
    display:"FormState",
    ...
  },
  "xxx.xxx.xx":{
    display:"FieldState",
    ...
  },
  "xxx.xxx.xx":{
    display:"VirtualFieldState",
    ...
  }
}

6.2 getFormGraph

在项目中调用actions.getFormGraph()可以直接查看实时表单状态树,例如表单录入流程中操作select、input等表单组件,getFormGraph可以实时的获取表单改变的状态树,并且通过action.getFormState().values.*可以获取实时改变的状态,下图为表单录入流程中的表单状态树👇

getFormGraph().png

上图中圈出的节点的dispalyName都为VirtualFieldState 其他节点均为FieldState

7. 总结

7.1 Formily缺陷

数组型组件在未渲染Item时,使用表单的getFormGraph方法时将无法获取到数组对应的Schema值,想绕过此问题需要先渲染一个空的Item,然后再获取Schema,最后删除这个空的Item,下面代码中的isEmptyList()方法是对应的Hack方案

/**
 * 空List时,新增ListItem预置逻辑
 * 如果没有Item,则预先生成一个空的Item,以获取表单getFormGraph数据
 */
static isEmptyList(mock) {
  const isEmpty = !(mock.action.getFormState().values.mockList?.length > 0)
  const mutators = mock.action.createMutators('MockList')
  if (isEmpty) {
    mutators.push({})
  }
  return {isEmpty, mutators}
}

mutators.push({}) 产生的结果:

console.log(actions.getFormGraph());

last.png

预设了一个空的对象传入

上面的代码中使用了 IMutators Api来实现,官方文档中有给出具体使用的案例 👉玩转自增列表组件


通过搜索发现,网络上对于Mutators的介绍少之甚少,需要深入了解Mutators必须通过阅读源码,上面的代码中的关键操作在于createMutators

我们可以看到createMutators 接受一个任意类型的参数,但是在后续的代码中会判断是否是FieldState instance or FormPathPattern. 否则将会抛出错误

function createMutators(input: any) {
    let field: IField
    if (!isField(input)) {
      const selected = graph.select(input)
      if (selected) {
        field = selected
      } else {
        throw new Error(
          'The `createMutators` can only accept FieldState instance or FormPathPattern.'
        )
      }
    } else {
      field = input
    }
    function setValue(...values: any[]) {...}
    
    function removeValue(key: string | number) {...}
    
    function getValue() {...}
    
    const mutators = {change() {...}, focus() {...}, blur() {...}, push(value?: any) {...}, ...IMutators}
    return mutators
}

上面的代码中setValueremoveValuegetValue三个functionmutators 中各个Api中调用,具体内容文中不进行拓展

当我们把createMutators的入参修改为MockPlan 可以看到报错结果

const mutators = mock.action.createMutators('MockPlan') // MockPlan 是JSON-schema中的VirtualFieldState类型

7.2 IMutators

属性名说明类型默认值
change改变当前行的值change(...values: any[]): any
focus聚焦
blur失焦
push增加一行数据(value?: any): any[]
pop弹出最后一行change(...values: any[]): any
insert插入一行数据(index: number, value: any): any[]
remove删除某一行(index: numberstring): any
unshift插入第一行数据(value: any): any[]
shift删除第一行是数据(): any[]
exist是否存在某一行(index?: numberstring): boolean
move将指定行数据移动到某一行(from:number,from: number, to: number): any[]
moveDown将某一行往下移(index: number): any[]
moveUp将某一行往上移(index: number): any[]
validate执行校验(opts?: IFormExtendedValidateFieldOptions): Promise

以上为本文全部内容,文中内容涉及个人的理解,如过有错误欢迎指正探讨。