react-formily踩坑实记

本文使用的是 formily 2.* 版本

理论

官方文档

formily 主站:https://formilyjs.org/zh-CN

formily 核心库 core:https://core.formilyjs.org/zh-CN

formily 响应式状态管理方案 reactive:https://reactive.formilyjs.org/zh-CN

formily 组件库 react:https://react.formilyjs.org/zh-CN

本文主要基于 react 进行 formily 相关实战。

formily 也有 vue 相关组件库,具体请看 https://vue.formilyjs.org/

工作相关的参考文章

在查找一些解决方案时发现了宝藏同事总结的使用方法,遂记录一下

  1. formily 2.0之表单校验
  2. formily2.0 + sugar 实战总结

其他文章

官方提供了非常详细的一些业务场景的例子,可以在官方的 codesandbox 例子中进行实验

关键组件

FormProvider:入口组件,用于下发表单上下文给字段组件,负责整个表单状态的通讯,它相当于是一个通讯枢纽。挂载 Form 实例的组件。

SchemaField:SchemaField 组件是专门用于解析JSON-Schema动态渲染表单的组件。 在使用 SchemaField 组件的时候,需要通过 createSchemaField 工厂函数创建一个 SchemaField 组件。

来自官网的简单 JSX 示例

import React from 'react'
import { createForm } from '@formily/core'
import { FormProvider, FormConsumer, Field } from '@formily/react'
import {
  FormItem,
  FormLayout,
  Input,
  FormButtonGroup,
  Submit,
} from '@formily/antd'

const form = createForm()

export default () => {
  return (
    <FormProvider form={form}>
      <FormLayout layout="vertical">
        <Field
          name="input"
          title="输入框"
          required
          initialValue="Hello world"
          decorator={[FormItem]}
          component={[Input]}
        />
      </FormLayout>
      <FormConsumer>
        {() => (
          <div
            style={{
              marginBottom: 20,
              padding: 5,
              border: '1px dashed #666',
            }}
          >
            实时响应:{form.values.input}
          </div>
        )}
      </FormConsumer>
      <FormButtonGroup>
        <Submit onSubmit={console.log}>提交</Submit>
      </FormButtonGroup>
    </FormProvider>
  )
}

从以上例子中,我们可以学到很多东西:

  • createForm 用来创建表单核心领域模型,它是作为 MVVM 设计模式的标准 ViewModel
  • FormProvider 组件是作为视图层桥接表单模型的入口,它只有一个参数,就是接收 createForm 创建出来的 Form 实例,并将 Form 实例以上下文形式传递到子组件中
  • FormLayout 组件是用来批量控制 FormItem 样式的组件,这里我们指定布局为上下布局,也就是标签在上,组件在下
  • Field 组件是用来承接普通字段的组件
    • name 属性,标识字段在表单最终提交数据中的路径
    • title 属性,标识字段的标题
      • 如果 decorator 指定为 FormItem,那么在 FormItem 组件中会默认以接收 title 属性作为标签
      • 如果指定为某个自定义组件,那么 title 的消费方则由自定义组件来承接
      • 如果不指定 decorator,那么 title 则不会显示在 UI 上
    • required 属性,必填校验的极简写法,标识该字段必填
      • 如果 decorator 指定为 FormItem,那么会自动出现星号提示,同时校验失败也会有对应的状态反馈,这些都是 FormItem 内部做的默认处理
      • 如果 decorator 指定为自定义组件,那么对应的 UI 样式则需要自定义组件实现方自己实现
      • 如果不指定 decorator,那么 required 只是会阻塞提交,校验失败不会有任何 UI 反馈。
    • initialValue 属性,代表字段的默认值
    • decorator 属性,代表字段的 UI 装饰器,通常我们都会指定为 FormItem
      • 注意 decorator 属性传递的是数组形式,第一个参数代表指定组件类型,第二个参数代表指定组件属性
    • component 属性,代表字段的输入控件,可以是 Input,也可以是 Select,等等
      • 注意 component 属性传递的是数组形式,第一个参数代表指定组件类型,第二个参数代表指定组件属性
  • FormConsumer 组件是作为响应式模型的响应器而存在,它核心是一个 render props 模式,在作为 children 的回调函数中,会自动收集所有依赖,如果依赖发生变化,则会重新渲染,借助 FormConsumer 我们可以很方便的实现各种计算汇总的需求
  • FormButtonGroup 组件作为表单按钮组容器而存在,主要负责按钮的布局
  • Submit 组件作为表单提交的动作触发器而存在,其实我们也可以直接使用 form.submit 方法进行提交,但是使用 Submit 的好处是不需要每次都在 Button 组件上写 onClick 事件处理器,同时它还处理了 Form 的 loading 状态,如果 onSubmit 方法返回一个 Promise,且 Promise 正在 pending 状态,那么按钮会自动进入 loading 状态

实战

项目中主要使用 JSON Schema 的方式去创建表单

表单字段内容设置参考链接:

schema:https://react.formilyjs.org/zh-CN/api/shared/schema

schemaField:https://react.formilyjs.org/zh-CN/api/components/schema-field

IFieldFactoryProps:https://core.formilyjs.org/api/models/form#ifieldfactoryprops

select 组件使用异步 options 数据

使用 onFieldInit 进行设置
private baseInfoForm = createForm<BaseInfoDetail>({
  initialValues: {
    ...DEFAULT_BASE_INFO,
  },
  effects() {
    onFieldInit('absId', (field: IFieldState) => {
      fetchHolidayTypeList().then((res) => {
        field.dataSource =
          res?.data?.map((holidayType) => ({
          id: holidayType.id,
          name: holidayType.name,
        })) || [];
      });
    });
  },
});
使用 onFormInit 进行设置
private baseInfoForm = createForm<BaseInfoDetail>({
  initialValues: {
    ...DEFAULT_BASE_INFO,
  },
  effects() {
    onFormInit((form: Form)) => {
      fetchHolidayTypeList().then((res) => {
        form.setFieldState('absId', (fieldState: IGeneralFieldState) => {
          fieldState.dataSource =
            res?.data?.map((holidayType) => ({
              id: holidayType.id,
              name: holidayType.name,
            })) || [];
        });
      });
    });
  },
});
在 componentDidMount 中设置

表单数据联动

参考文档:https://formilyjs.org/zh-CN/guide/advanced/linkages

https://react.formilyjs.org/zh-CN/api/shared/schema#schemareactions

schema 属性值:https://react.formilyjs.org/zh-CN/api/shared/schema#%E5%B1%9E%E6%80%A7

IGeneralFieldState:https://core.formilyjs.org/zh-CN/api/models/field#ifieldstate

SchemaReaction 类型
import { IGeneralFieldState } from '@formily/core'

type SchemaReaction<Field = any> =
  | {
      dependencies?: string[] | Record<string, string> // 依赖的字段路径列表,只能以点路径描述依赖,支持相对路径,如果是数组格式,那么读的时候也是数组格式,如果是对象格式,读的时候也是对象格式,只是对象格式相当于是一个alias
      when?: string | boolean // 联动条件
      target?: string // 要操作的字段路径,支持FormPathPattern路径语法,注意:不支持相对路径!!
      effects?: SchemaReactionEffect[] // 主动模式下的独立生命周期钩子
      fulfill?: {
        // 满足条件
        state?: IGeneralFieldState // 更新状态
        schema?: ISchema // 更新Schema
        run?: string // 执行语句
      }
      otherwise?: {
        // 不满足条件
        state?: IGeneralFieldState // 更新状态
        schema?: ISchema // 更新Schema
        run?: string // 执行语句
      }
    }
  | ((field: Field) => void) // 可以复杂联动

从类型上看有两种方式可以设置 schemaReaction 可以使用显示配置的方式或者函数控制 field 属性进行。

常用的方式为使用配置条件对象,如果有更加复杂的联动规则,推荐使用函数的方式进行联动设置。

进行联动配置时,可以配置 state、schema 和执行语句。

配置 state 时需要查看状态类型 IGeneralFieldState 中相对应的属性,schema 是需要查看 ISchema 中相对应的属性。

import React from "react";
import { createForm } from "@formily/core";
import { createSchemaField } from "@formily/react";
import { Form, FormItem, Input } from "@formily/antd";

const SchemaField = createSchemaField({
  components: {
    Input,
    FormItem
  },
  scope: {
    requiredReactions(field) {
      const requiredField = field.query("required_1").take();
      console.log("requiredField: ", Math.random(), requiredField.value);
      if (!requiredField) {
        return;
      }
      field.setState({
        value: requiredField.value
      });
    }
  }
});

const requireSchema = {
  type: "object",
  properties: {
    required_1: {
      name: "required_1",
      title: "必填",
      type: "string",
      required: true,
      "x-decorator": "FormItem",
      "x-component": "Input"
    },
    required_2: {
      name: "required_2",
      title: "必填",
      type: "string",
      "x-validator": {
        required: true
      },
      "x-decorator": "FormItem",
      "x-component": "Input"
    },
    required_3: {
      name: "required_3",
      title: "必填",
      type: "string",
      "x-validator": [
        {
          required: true
        }
      ],
      "x-decorator": "FormItem",
      "x-component": "Input",
      "x-reactions": "{{requiredReactions}}"
      // "x-reactions": {
      //   dependencies: ["required_1"],
      //   fulfill: {
      //     state: {
      //       value: "{{$deps[0]}}"
      //     }
      //   }
      // }
    }
  }
};

export default class FormPart extends React.Component {
  form = createForm({
    initialValues: {
      required_1: 1
    }
  });
  render() {
    return (
      <>
        <Form form={this.form} labelCol={6} wrapperCol={10}>
          <SchemaField schema={requireSchema} />
        </Form>
        <div onClick={() => console.log(this.form.values)}> 点击 </div>
      </>
    );
  }
}

参考链接:https://codesandbox.io/s/formily-reaction-cnuttc

Reaction 联动触发的时机

使用联动需要注意触发时机。

  • 表单初始化时
  • 依赖值初始化时
  • 依赖值改变时

Reaction 函数主要设置到被联动的字段配置上,虽然也可以设置到被依赖字段配置上,但是需要注意被依赖字段是否有其他依赖值,容易引发一系列意想不到的联动。

使用 Effect 进行联动

effect 主要是监听被依赖字段值的改变(onFieldValueChange)。

import React from 'react'
import { createForm, onFieldValueChange } from '@formily/core'
import { createSchemaField, FormConsumer } from '@formily/react'
import { Form, FormItem, Input, Select } from '@formily/antd'

const form = createForm({
  effects() {
    // 字段值改变时触发
    onFieldValueChange('select', (field) => {
      console.log('effects', Math.random());
      form.setFieldState('input', (state) => {
        // 对于初始联动,如果字段找不到,setFieldState会将更新推入更新队列,直到字段出现再执行操作
        state.display = field.value
      })
    })
  },
})

const SchemaField = createSchemaField({
  components: {
    FormItem,
    Input,
    Select,
  },
})

export default () => (
  <Form form={form}>
    <SchemaField>
      <SchemaField.String
        name="select"
        title="控制者"
        default="visible"
        enum={[
          { label: '显示', value: 'visible' },
          { label: '隐藏', value: 'none' },
          { label: '隐藏-保留值', value: 'hidden' },
        ]}
        x-component="Select"
        x-decorator="FormItem"
      />
      <SchemaField.String
        name="input"
        title="受控者"
        x-component="Input"
        x-decorator="FormItem"
      />
    </SchemaField>
    <FormConsumer>
      {() => (
        <code>
          <pre>{JSON.stringify(form.values, null, 2)}</pre>
        </code>
      )}
    </FormConsumer>
  </Form>
)
使用 addEffects 的方式添加 effect
const form = this.props.form;
form.addEffects('handleReaction', () => {
  onFieldValueChange('require_1', (field) => {
    // do something
  });
});

异步数据校验

官方示例:https://formilyjs.org/zh-CN/guide/advanced/validate#%E5%BC%82%E6%AD%A5%E6%A0%A1%E9%AA%8C

按照校验时机分为:onInputonBluronFocus

import React from 'react'
import { createForm } from '@formily/core'
import { createSchemaField } from '@formily/react'
import { Form, FormItem, Input } from '@formily/antd'

const form = createForm()

const SchemaField = createSchemaField({
  components: {
    Input,
    FormItem,
  },
})

const schema = {
  type: 'object',
  properties: {
    async_validate: {
      title: '异步校验',
      required: true,
      'x-validator': `{{(value) => {
        return new Promise((resolve) => {
          setTimeout(() => {
            if (!value) {
              resolve('')
            }
            if (value === '123') {
              resolve('')
            } else {
              resolve('错误❎')
            }
          }, 1000)
        })
      }}}`,
      'x-component': 'Input',
      'x-decorator': 'FormItem',
    },
    async_validate_2: {
      title: '异步校验(onBlur触发)',
      required: true,
      'x-validator': {
        triggerType: 'onBlur',
        validator: `{{(value) => {
        return new Promise((resolve) => {
          setTimeout(() => {
            if (!value) {
              resolve('')
            }
            if (value === '123') {
              resolve('')
            } else {
              resolve('错误❎')
            }
          }, 1000)
        })
      }}}`,
      },
      'x-component': 'Input',
      'x-decorator': 'FormItem',
    },
  },
}

export default () => (
  <Form form={form} labelCol={6} wrapperCol={10}>
    <SchemaField schema={schema} />
  </Form>
)
使用 debounce 对数据请求接口进行包裹

目的:主要减少 onInput 时请求接口进行数据校验的频率。

import React from 'react'
import { createForm } from '@formily/core'
import { createSchemaField } from '@formily/react'
import { Form, FormItem, Input } from '@formily/antd'
import { debounce } from 'lodash'

const form = createForm()

const SchemaField = createSchemaField({
  components: {
    Input,
    FormItem,
  },
})

const debounceValidator = debounce(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      if (!value) {
        resolve('')
      }
      if (value === '123') {
        resolve('')
      } else {
        resolve('错误❎')
      }
    }, 100)
  })
}, 300)

const schema = {
  type: 'object',
  properties: {
    async_validate: {
      title: '异步校验',
      required: true,
      'x-validator': debounceValidator,
      'x-component': 'Input',
      'x-decorator': 'FormItem',
    },
  },
}

export default () => (
  <Form form={form} labelCol={6} wrapperCol={10}>
    <SchemaField schema={schema} />
  </Form>
)
模拟异步请求

请求内容就是被层层包裹,中间进行数据逻辑处理。

试图优化,但是不起效果,以下这段代码有一个延迟返回校验结果的 bug

import React from "react";
import { createForm } from "@formily/core";
import { createSchemaField } from "@formily/react";
import { Form, FormItem, Input } from "@formily/antd";
import { debounce } from "lodash";

const form = createForm();

const SchemaField = createSchemaField({
  components: {
    Input,
    FormItem
  }
});

const mockRequest = (value) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (!value) {
        resolve("");
      }
      if (value === "123") {
        resolve("");
      } else {
        reject("错误❎");
      }
    }, 100);
  });
};

const debounceValidator = debounce((value) => {
  // 如果不进行 return 则不起效果
  return mockRequest(value)
    .then(() => {
    	return "";
      // return Promise.resolve("");
    })
    .catch((err) => {
      console.log(err);
    	return err;
      // return Promise.resolve(err);
    });
}, 300);

const schema = {
  type: "object",
  properties: {
    async_validate: {
      title: "异步校验",
      required: true,
      "x-validator": debounceValidator,
      "x-component": "Input",
      "x-decorator": "FormItem"
    }
  }
};

export default () => (
  <Form form={form} labelCol={6} wrapperCol={10}>
    <SchemaField schema={schema} />
  </Form>
);
最终方案

能够实时显示接口请求的异步校验结果

参考链接:https://antd.formilyjs.org/zh-CN/components/select#markup-schema-%E5%BC%82%E6%AD%A5%E6%90%9C%E7%B4%A2%E6%A1%88%E4%BE%8B

import React from "react";
import { createForm } from "@formily/core";
import { createSchemaField } from "@formily/react";
import { Form, FormItem, Input } from "@formily/antd";
import { debounce } from "lodash";

const form = createForm();

const SchemaField = createSchemaField({
  components: {
    Input,
    FormItem
  }
});

const requestMock = (value) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (!value) {
        resolve("");
      }
      if (value === "123") {
        resolve("");
      } else {
        reject("错误❎");
      }
    }, 100);
  });
};

const debounceValidator = debounce((value, callback) => {
  requestMock(value)
    .then(() => {
      console.log("success");
      callback("");
    })
    .catch((err) => {
      console.log(err);
      callback(err);
    });
}, 300);

const schema = {
  type: "object",
  properties: {
    async_validate: {
      title: "异步校验",
      required: true,
      "x-validator": (value) => {
        return new Promise((resolve) => {
          debounceValidator(value, resolve);
        });
      },
      "x-component": "Input",
      "x-decorator": "FormItem"
    }
  }
};

export default () => (
  <Form form={form} labelCol={6} wrapperCol={10}>
    <SchemaField schema={schema} />
  </Form>
);
formily 校验的核心代码

源码链接:https://github1s.com/alibaba/formily/blob/HEAD/packages/validator/src/parser.ts

表单 EFFECT HOOK 使用

踩坑 issue:表单切换显隐(组件卸载并重新挂载)后,所有 effects 失效

最后的解决方案是在需要使用 formily hook 的组件中手动 addEffects 添加 formily hook。

踩坑的主要原因是,公司自研的组件库 tabs 组件切换 tab 时是通过组件的卸载来进行切换的,卸载了的 formily 校验就会完全失效永远走的是 success。突然想到跟我最开始的时候触发的一个坑一样的,卸载掉的组件触发校验就是永远都是 success。

参考链接:

Form Effect Hooks:https://core.formilyjs.org/zh-CN/api/entry/form-effect-hooks
Field Effect Hooks:https://core.formilyjs.org/zh-CN/api/entry/field-effect-hooks

常用的 effect hooks

onFieldValueChange:用于监听某个字段值变化的副作用钩子。这个钩子会在 form 设置初始化值和 form.setValues 时触发。

onFieldInputValueChange:用于监听某个字段 onInput 触发的副作用钩子。仅仅想在用户触发了 input 事件后响应值的变化,也可以监听到选项框切换选项,check box 触发勾选等情况。

onFieldReact:用于实现字段响应式逻辑的副作用钩子,它的核心原理就是字段初始化的时候会执行回调函数,同时自动追踪依赖,依赖数据发生变化时回调函数会重复执行。内部会自动收集依赖。用户操作手动改变赋值时不会触发,使用 api setValue 时会触发。

不知道会不会有升级 formily 的坑。
⚠️:这个 hooks 会追踪依赖,依赖改变就会触发 onFieldReact hooks

onFormValidateStart:用于监听表单校验开始的副作用钩子。

onFormReact: 用于实现表单响应式逻辑的副作用钩子,它的核心原理就是表单初始化的时候会执行回调函数,同时自动追踪依赖,依赖数据发生变化时回调函数会重复执行。内部会自动收集依赖,内部依赖值改变就会触发 hooks。

如果 onFormReact 内部监听的数据没有 field 组件去承接,会在页面隐藏或者组件销毁时崩溃。
原因:onFormReact 中做了额外的 onFieldInit 收集 dispose 函数,并在销毁时进行触发,如果没有 field 组件去承接则就没有 dispose 函数,dispose 函数运行没有做判空处理。

踩坑

初始使用的时候,对定义不是很清晰,所以踩了很多不必要的坑。

  • 一个 form 实例对应一个 FormProvider 组件

    • 如果使用多个 FormProvider 组件对应一个 form 实例,则会触发意想不到的 bug。
    • 需要注意 form 显隐控制导致的 validate 失效问题。
  • FormProvider 组件中可以使用多个 SchemaField 组件

  • field 属性值部分需要注意的地方

    使用 visible 属性设置隐藏(none)的时候,字段隐藏则会为 undefined,在获取 form.values 时会过滤掉。

    hidden 属性设置隐藏(hidden)的时候,字段隐藏,但是在获取 form.values 时仍然保留字段值。

    active

    触发 onFocus 为 true,触发 onBlur 为 false

    visited

    触发过 onFocus 则永远为 true

    inputValue

    触发 onInput 收集到的值

    inputValues

    触发 onInput 收集到的多参值

    hidden

    为 true 时, display 为 hidden;为 false 时 display 为 visible

    visible

    为 true 时, display 为 visible;为 false 时 display 为 none

    参考链接:https://core.formilyjs.org/zh-CN/api/models/field#%E8%AF%A6%E7%BB%86%E8%A7%A3%E9%87%8A