react系列-formily踩坑实记2

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

碎碎念:
使用 formily 已经半年了,期间使用 formily 表单写了几个大需求,目前状态处于能够快速定位 formily 相关问题的阶段。但是没有完整的读过源码和梳理 formily 处理方式,所以还是不够深入,仅处于会用状态。
突然发现我好像没有从头到尾好好看过 formily 官方文档,都是在看示例,初期看文档可能没有太深的感受,现在再去看一遍觉得受益匪浅。

本文主要是针对使用 formily 开发过程中遇到的问题做一个踩坑复盘,顺便解答一下最开始上手使用 formily 的时候可能会产生的疑问:

疑问点:

  • 什么是 formily?formily 能解决哪些问题?
  • 使用 class component 还是使用 function component 写表单
  • 使用 JSON Schema 还是 JSX 的方式写表单
  • 使用 formily Field 管理表单字段数据状态

踩坑复盘:

  • @formliy/reactive-react 中的 observer
  • Field display 的 visible、hidden、none 区别
  • setValues 过后触发了预期外的 Field 响应
  • setValues 后表单并没有显示预期的数据
  • field validator 无法清空 setSelfErrors 设置的 error
  • 联动使用 Field reaction 还是 effect hooks

什么是 formily?formily 能解决哪些问题?

什么是 formily

一个抽象了表单领域模型的 MVVM 表单解决方案

Formily 解决的问题

  • 表单性能优化
  • 字段关联逻辑,联动逻辑实现高效
  • 表单状态管理
  • 表单数据管理
  • 动态渲染
  • 表单场景化复用

解决方案

  • 精确渲染(reactive 方案 - 面向DDD的响应式状态管理方案)

    将视图模型抽象出来,然后在 DSL 模板层消费,DSL 借助某种依赖收集机制,然后在视图模型中统一调度,保证每次输入都是精确渲染的,这就是工业级的 GUI 形态。

    实现了一个类 Mobx 的库 @formily/reactive,核心的能力为依赖追踪机制和响应式模型的抽象能力。

  • 领域模型(Field 模型)

  • 路径系统

  • 生命周期

  • 协议驱动

    JSON-schema 数据协议

    在 JSON Schema 中,引入 void,代表一个虚数据节点,表示该节点并不占用实际数据结构。
    推荐使用 schema 的重要原因之一:Schema 模式的核心优势,借助协议,可以做场景化抽象。纯 JSX 模式,受限于 JSX 的不可解析性。

  • 分层架构
    formily-分层架构

    这张图主要将 Formily 分为了内核层,UI 桥接层,扩展组件层,和配置应用层。

    内核层是 UI 无关的,它保证了用户管理的逻辑和状态是不耦合任何一个框架,这样有几个好处:

    逻辑与 UI 框架解耦,未来做框架级别的迁移,业务代码无需大范围重构
    学习成本统一,如果用户使用了@formily/react,以后业务迁移@formily/vue,用户不需要重新学习
    JSON Schema 独立存在,给 UI 桥接层消费,保证了协议驱动在不同 UI 框架下的绝对一致性,不需要重复实现协议解析逻辑。

    扩展组件层,提供一系列表单场景化组件,保证用户开箱即用。无需花大量时间做二次开发。

Formily 的核心库

我们常用到 formily 的三个核心库,其中各个库的作用为:

  • @formliy/reactive: 面向DDD的响应式状态管理方案。

  • @formily/react:@formily/react 的核心定位是将 ViewModel(@formily/core)与组件实现一个状态绑定关系,桥接了数据和 UI,把很多脏逻辑优雅的解耦,变得可维护。

  • @formily/core:formily 核心库,将领域模型从 UI 框架中抽离出来,与逻辑的耦合关系中释放出来,提升代码可维护性。

使用 class component 还是使用 function component 写表单

因为我们主要实践是使用 jsx 的方式写的,所以特别特别建议使用 function component 的方式去写 formily 表单。

主要理由有:

  • @formliy/reactive 中的 api observer 只支持 function component
  • @formily/react 中有很多方便的 hooks 和 api

在上面👆已经讨论过了 formily 核心库之间的关系

function component 的最佳实践(简单的数据结构,官方例子完全够用 😛):https://formilyjs.org/zh-CN/guide/scenes/edit-detail

使用 JSON Schema 还是 JSX 的方式写表单

可以先看一下 @formily/react 的架构图

formily-react-架构图

可以从架构图中可以得出渲染时 Markup Schema 会转换为 JSON Schema 再转换为 JSX 的方式进行渲染,所以 JSX 是最终的运行时状态。
纯 JSX 方式是性能最好的,因为没有表达式编译。(来自于 formily 作者的答疑解惑)

使用 JSON Schema 方式写表单的好处:

  • 易于平台移植,比如说可以从 React 迁移到 Vue 或其他平台。

  • 没有特殊限制,使用纯数据格式,在 JS / TS 文件中写也能友好支持。
    使用 JSX 方式写表单的好处:

  • 编码时有良好的编码提示,易于查找问题,便于维护。

  • 符合组件编码和代码组织习惯。

  • 易于组件拆分,样式和字段组合更加灵活,更便于复杂的样式和数据结构组织

对于复杂的数据结构,JSON Schema 需要结合 JSX(使用 RecursionField 组件) 的方式组织组件结构,JSX 在这方面就更加灵活。如果只是前端使用到表单结构更加推荐使用 JSX 的方式组织表单组件。

JSON Schema 结合 RecursionField 组件的方式 demo 参考地址:Schema 片段联动(自定义组件)
更多阅读:三种开发模式

使用 formily Field 管理表单字段数据状态

demo 参考链接:编辑详情
自定义组件参考:实现自定义组件
在 formily 中使用自定义组件

// 组件库组件:formily 连接 input 组件能力,使用 input 组件管理 Field 状态
const FormInput = connect(
  Input,
  mapProps((props, field) => {
    return {
      ...props,
      suffix: (
        <span>
          {field?.['loading'] || field?.['validating'] ? (
            <LoadingOutlined />
          ) : (
            props.suffix
          )}
        </span>
      ),
    }
  }),
  mapReadPretty(PreviewText.Input)
)

// 业务组件
const DateSelect = observer(() => {
  const parentField = useField();
  return (
    {/* 业务组件中使用组件库组件管理 Field 状态 */}
    <Field 
      name="date" 
      basePath={parentField.address} 
      component={[DatePicker]}
    />
  )
});

// 业务组件
const NameInput = () => {
  // "叶子"数据用 Field 组件管理
  return (
    <>
      <Field
        name="firstName"
        component={[FormInput]}
        required
      />
      <Field
        name="lastName"
        component={[FormInput]}
        required
      />
    </>
  )
}

// 业务组件
// 这种组件中的数据值脱离了 formily Field 的状态管理,需要手动进行状态管理
const ConditionSelect = ({
  value,
  onChange
}) => {
  // 手动设置 Field 数据值
  onItemChange = (itemValue, itemIndex) => {
    // 可以做其他的复杂的数据处理,这里只是一个简单的演示
    const newValule = [...value];
    newValue[itemIndex] = {
      ...newValue[itemIndex],
      value: itemValue,
    }
    onChange(newValue)
  }
  return (
    <div>
      <div>复杂的样式 & 数据逻辑</div>
      {value?.map((item, index) => {
          return (
            <Input key={item.id} value={item.value} onChange={(e) => onItemChange(e, index)} />
          )
        }) 
      }
    </div>
  )
}

const DemoForm = () => {
  return (
    // ...
    <FormProvider form={form}>
      {/* 这种方式为使用组件库组件连接 formily Field */}
      <Field
        name="username"
        title="用户名"
        required
        decorator={[FormItem]}
        component={[FormInput]}
      />
      <ObjectField
        name="name"
        title="姓名"
        decorator={[FormItem]}
        component={[NameInput]}
      />
      {/* 具体写法可以参考 sugar-design 的 RadioGroup */}
      <Field
        name="birthday"
        title="生日"
        required
        decorator={[FormItem]}
        component={[FormRadioGroup, {
          options: [
            {
              label: <DateSelect />,
              value: true,
            },
            {
              label: '保密',
              value: false,
            }
          ]
        }]}
      />
      <Field
        name="conditionSelect"
        title="条件选择"
        required
        decorator={[FormItem]}
        component={[ConditionSelect]}
      />
    </FormProvider>
    // ...
  );
}

以上片段只是组织表单方式的一种,还有其他很多种方式大家可以参考官方文档

“叶子”字段值不使用 Field 管理状态的好处:

  • 简单,“叶子”字段值可以在数据 onChange 时做很多复杂的工作

  • 用不明白 formily 的状态管理时,可以使用自定义业务组件去控制数据状态

  • 自定义业务组件可以实现很复杂的样式和复杂的数据结构
    “叶子”字段值使用 Field 管理状态的好处:

  • 可以使用 form.query 获取到字段的 Field 实例

  • 可以使用 field effect hooks 进行字段监听

formily Field 数据颗粒度

如果是复杂的数据结构且需要监听到”叶子”字段的数据变动,就需要使用 formily Field 管理数据。

这种情况需要灵活运用 VoidField、ArrayField、ObjectField 组件对 Field 组件的包裹,也需要注意 field path 的设置,Field 组件也能包裹 Field 组件但是需要指定 field 的 basePath,不然 formily 无法找到被包裹的 Field 的正确路径。
其中,引入 void,代表一个虚数据节点,表示该节点并不占用实际数据结构。

如果是复杂的数据结构且不监听内部”叶子”字段,就可以不使用 Field 管理“叶子”字段值。

在线 demo:https://codesandbox.io/s/field-manage-data-9zmu5f?file=/App.tsx

Field display 的 visible、hidden、none 区别

显隐规则

显隐属性的写规则

显隐属性的读规则

继承逻辑:

如果父节点主动设置了 display 属性,子节点没有主动设置 display 属性,那么子节点会继承父节点的 display。

主动设置 display :

  • 给字段配置了初始化属性 display/visible/hidden
  • 如果初始化时没有配置,但是在后期又给字段设置了 display/visible/hidden
    如果希望子节点从不继承变为继承,可以把 display 设置为 null。
// 设置 父节点 ArrayField display 为 hidden
form.query("test4").take()?.setDisplay("hidden");
// 子节点默认的继承父节点 display
console.log(form.query("test4.0")?.get('display')); // 'hidden'

// 设置 第一个子节点 Field display 为 none (不继承父节点的 display)
form.query("test4.0").take()?.setDisplay("none");
console.log(form.query("test4.0")?.get('display')); // 'none'

// 子节点从不继承变为继承父节点的 display
form.query("test4.0").take()?.setDisplay(null); // 和 setDisplay('visible') 效果一样
console.log(form.query("test4.0")?.get('display')); // 'hidden'

// 设置 父节点 ArrayField display 为 none
form.query("test4").take()?.setDisplay("none");
// 查询 父节点 display
console.log(form.query("test4").get("display")); // 'none'
// 查询 父节点 value
console.log(form.query("test4").value()); // undefined
// 查询 第一个子节点 Field display
console.log(form.query("test4.0").get("display")); // 'undefined'

设置 field display 的方式有:

  • field.setDisplay(‘none/visible/hidden’)
  • field.display = ‘none/visible/hidden’
  • field.setState((state) => state.diaplay = ‘none/visible/hidden’)
  • field.visible = true/false
  • field.setState((state) => state.visible = true/false)
  • field.hidden = true/false
  • field.setState((state) => state.hidden = true/false)

    其中设置 visible 为 false 时 display 为 none :
    图片

设置 hidden 为 true 时, display 为 hidden:
图片

获取 field display 的方法有:

  • field.display
  • field.getState().display
  • form.query(‘xxxx’).get(‘display’)

⚠️ 注意:

  • field display 状态为 none 时,form.values 中就不会包含该字段。
  • 父级字段 display 状态为 none,子元素字段会被销毁,无法查到子元素字段,但是父级字段仍然可以被 (Field / Form).query 查到,此时父级字段 value 为 undefined。
  • field display 状态为 hidden 时,是可以修改数据状态的。
  • 父级字段 display 状态为 hidden,子元素字段仍会保留,可以查到子元素字段。
  • field display 状态不为 visible 时,form.validate 是不会校验隐藏中的字段。
  • field 一旦挂载到 form 上如果不是通过设置 field display 状态控制显隐,form.submit 或 form.validate 时会校验该字段。
    <FormProvider>
      // 这种情况就不是通过 field display 状态控制显隐,field 一旦挂载到 form 上就会在 form.submit 或 form.validate 时触发 field 校验
      {show && (
        <Field
          name="test"
          required
          component={[Input]}
        />
      )}
      // 这种情况下,field 隐藏时 form 进行校验时不会触发 field validate
      <Field
        name="hiddenTest"
        required
        component={[Input]}
        reactions={[
          (field) => {
            field.hidden = String(field.value).includes("345");
          }
        ]}
      />
      // ...
    </FormProvider>

更多可以阅读源码:https://github.com/alibaba/formily/blob/formily_next/packages/core/src/shared/internals.ts#L949

setValues 过后触发了预期外的 Field 响应

有很多时候我们需要使用到 setValues,但是可能会因为忽略 setValues 的合并策略而导致意想不到的问题:明明只想 setValues test 的值,但是 setValues 后怎么触发了 test2 的 reaction 响应?

<FormProvider form={form}>
  <Field
    name="test"
    title="test"
    required
    decorator={[FormItem]}
    component={[Input]}
  />
  <Field
    name="test2"
    title="test2"
    required
    decorator={[FormItem]}
    component={[Input]}
    reactions={[
      (field) => {
        console.log("test2 reactions");
        const hidden = String(field.value).includes("345");
        field.hidden = hidden;
      }
    ]}
  />
  <Button
    type="primary"
    onClick={() => {
      // 这种方式会导致 test2 的 reactions 响应
      form.setValues({
        test: 123
      });
    }}
  >
    setValue
  </Button>
</FormProvider>

以上仅 demo 讲解,设置单个字段值可以使用 form.setValuesIn()

setValues 的合并策略:

interface setValues {
  (values: object, strategy: IFormMergeStrategy = 'merge'): void
}
type IFormMergeStrategy = 'overwrite' | 'merge' | 'deepMerge' | 'shallowMerge'

从上面的代码中可以看出,setValues 的默认合并策略是 merge,所以大部分时候我们使用 setValues 时,不用特意设置合并策略。
然而在某些特殊场景下,merge 合并策略并不适用:

  • form 重新 setValues 想要覆盖 form 旧的 value,然而旧的 value 的数据结构和新的 value 数据结构不完全一致,此时的合并策略应该是 overwrite
  • 需要批量设置某些浅层字段值但是不想触发其他字段的响应,合并策略可以使用 shallowMerge
  • 复杂的数据结构中需要修改某个层级较深的节点值需要使用 setValuesIn 的方式进行赋值

    不同的合并策略对应了不同的数据处理方式:

源码:https://github.com/alibaba/formily/blob/HEAD/packages/core/src/models/Form.ts#L382-L393

setValues = (values: any, strategy: IFormMergeStrategy = 'merge') => {
  if (!isPlainObj(values)) return
  if (strategy === 'merge' || strategy === 'deepMerge') {
    this.values = merge(this.values, values, {
      arrayMerge: (target, source) => source,
    })
  } else if (strategy === 'shallowMerge') {
    this.values = Object.assign(this.values, values)
  } else {
    this.values = values as any
  }
}

从源码可以看出,merge 和 deepMerge 都是用的同一种方式即深度合并赋值。

merge 策略源码:https://github.com/alibaba/formily/blob/HEAD/packages/shared/src/merge.ts#L132-L152

你可能忽略的路径系统

formily 中有非常重要的路径系统

  • 可以用来从字段集中查找任意一个字段,同时支持按照规则批量查找
  • 可以用来表达字段间关系的模型,借助路径系统,我们可以实现查找某个字段父节点,能查找父节点,也就能实现树级别的数据继承能力,同样,我们也能查找某个字段的相邻节点
  • 可以用来实现字段数据的读写,带解构的数据读写
  • 路径系统拥有很多 api,可以自由拼接路径

    路径模型

参考链接:formily 路径规则
路径系统 api:https://core.formilyjs.org/zh-CN/api/entry/form-path

formily路径模型

表单结构本身就是一个树结构,每个字段都有自己的绝对路径和相对路径。其中 field address 永远是代表节点的绝对路径,path 是会跳过 VoidField 的节点路径,但是如果是 VoidField 的 Path,是会保留它自身的路径位置。

利用路径模型和规则可以精确的找到或操作 formily 字段值,实现更加精细化地操作,增加了 formily 表单操作的灵活性。

路径系统可以解决的问题:

Q:****数据结构和组件结构存在差异

通常在数据结构比较复杂的表单中,容易遇到这样的问题:两个字段是兄弟关系,但是在组件结构中是嵌套关系,例如:

当 hasBirthday 的值不是【保密】时需要设置 birthday 数据,hasBirthday 和 birthday 数据又是平级关系

// 数据格式:{ hasBirthday: boolean; birthday: Date | null; }

const DateSelect = observer(() => {
  const parentField = useField();
  return (
    <Field 
      name="birthday" 
      // 指向 parent 路径
      basePath={parentField.address} 
      component={[DatePicker]}
    />
  )
});

const DemoForm = () => {
  return (
    // ...
    <FormProvider form={form}>
      {/* 具体写法可以参考 sugar-design 的 RadioGroup */}
      <Field
        name="hasBirthday"
        title="生日"
        required
        decorator={[FormItem]}
        component={[FormRadioGroup, {
          options: [
            {
              label: <DateSelect />,
              value: true,
            },
            {
              label: '保密',
              value: false,
            }
          ]
        }]}
      />
    </FormProvider>
    // ...
  );
}

在这种情况下 Field basePath 是非常好用的。当不够明确数据结构时,可以使用 basePath 明确指出 parent 字段路径。

Q:setValues 后表单并没有显示预期的数据

当我们遇到 Field 组件没有渲染出 Field value 时,不妨查看一下 Field 组件的绝对路径指向是否正确。

使用 FormStep 组件时需要注意 basePath 的使用。

field validator 无法清空 setSelfErrors 设置的 error

有的时候我们使用 setSelfErrors 手动设置了字段的 errors,但是在字段触发 field validator 时并没有清空我们手动设置的 errors,还得再次手动清空 setSelfErrors([])。

简单的 demo:

<FormProvider form={form}>
  <Field
    name="test2"
    title="test2"
    required
    decorator={[FormItem]}
    component={[Input]}
    reactions={[
      (field) => {
        if (String(field.value).includes("345")) {
          // 这种 👇 方式设置 errors 需要手动清空
          (field.query("test3").take() as FieldType)?.setSelfErrors([
            "test2 set test3 selfErrors"
          ]);
          // field validator 可以清空这种 👇 方式设置的 error
          (field.query("test3").take() as FieldType)?.setFeedback({
            type: "error",
            triggerType: "onInput",
            messages: ["test2 set test3 selfErrors"],
            code: "ValidateError"
          });
        }
      }
    ]}
  />
  <Field
    name="test3"
    title="test3"
    required
    decorator={[FormItem]}
    component={[Input]}
    validator={[
      (value) => {
        // 清空不了 setSelfErrors 设置的 error messages
        return String(value).includes("345") ? "value includes 354" : "";
      }
    ]}
  />
  <Button
    type="primary"
    onClick={() => {
      form.submit();
    }}
  >
    submit
  </Button>
</FormProvider>

需要先看一下 setSelfErrors 相关的源码:

源码链接:https://github.com/alibaba/formily/blob/formily_next/packages/core/src/models/Field.ts#L396-L402

set selfErrors(messages: FeedbackMessage) {
  this.setFeedback({
    type: 'error',
    code: 'EffectError',
    messages,
  })
}

get selfErrors() {
  return queryFeedbackMessages(this, {
    type: 'error',
  })
}

setFeedback = (feedback?: IFieldFeedback) => {
  // 如果存在原来的 feedback 则更新 feedback,否则就添加进去
  // 如果新的 feedback messages 的 length 无效则删除这个 feedback
  updateFeedback(this, feedback)
}

Field validator 结果转换为 feedbacks 的核心逻辑:

源码链接:https://github.com/alibaba/formily/blob/formily_next/packages/core/src/shared/internals.ts#L303-L324

export const validateToFeedbacks = async (
  field: Field,
  triggerType: ValidatorTriggerType = 'onInput'
) => {
  const results = await validate(field.value, field.validator, {
    triggerType,
    validateFirst: field.props.validateFirst || field.form.props.validateFirst,
    context: { field, form: field.form },
  })

  batch(() => {
    each(results, (messages, type) => {
      field.setFeedback({
        triggerType,
        type,
        code: pascalCase(`validate-${type}`),
        messages: messages,
      } as any)
    })
  })
  return results
}

patternType 不为 editable 或 visible 状态的不会进行 Field 校验
从源码中得出结论:

校验规则

formily 这样设计的原因是:

为了防止用户修改校验结果污染本身校验器的校验结果,做严格分离,容易恢复现场。

更多阅读:formily 校验规则

使用 setSelfErrors 还是使用 setFeedback

  • 如果 Field 需要展示 error message,并且定义了 Field validator,建议使用 setFeedback 的方式
  • 如果 Field 不需要展示 error message,但是定义了 Field validator,建议使用 setFeedback 的方式
  • 如果需要清空 ValidateError message 可以手动调用 field.validate() 动态清空
  • 如果 Field 不展示 error message,没有定义 Field validator,需要手动维护 Field error 状态时,可以使用 setSelfErrors 的方式,这种方式会更方便(不用写 code、type 等参数)
  • 如果想要和 Field 本身校验结果隔离,就需要使用 setSelfErrors 的方式

⚠️ 注意

  • field.selfErrors 获取的 error message 包含了 setSelfErrors 和 setFeedback 方式设置的 type 为 error 的 message
  • setFeedback api 是更新 code、type、triggerType 完全一致的 feedback 数据

Field validator 最佳实践

  • 将 Field 校验逻辑统一写到 Field validator 中会更好维护,在需要更新字段校验状态时,可以手动调用 field.validate 方法
  • ObjectField 和 ArrayField 的子元素数量改变才会自动触发 ObjectField/ArrayField validator

联动使用 Field reaction 还是 effect hooks

参考链接:实现联动逻辑
我们会遇到很多字段联动的表单需求,如 a 字段的修改需要联动 b 字段的修改,a 字段设置了特定的值后需要隐藏 c 字段等复杂的联动交互逻辑可能是表单中最复杂的部分了。一对一、一对多、链式、循环、异步、主动模式、被动模式等多种联动方式,稍不注意就会陷入了深坑当中。

demo示例:

const LeaveConfig: React.FC = observer(() => {
  useFormEffects(() => {
    // askForLeaveUnit.minUnit 修改会联动修改 minDuration 的值并进行校验
    onFieldValueChange('askForLeaveUnit.minUnit', (field) => {
      const minUnit = field.value;
      const minDurationField = field.query('minDuration').take() as FieldType;
      if (minDurationField) {
        const minDuration = minDurationField.value;
        if (minDuration < minUnit) {
          minDurationField.setValue(minUnit);
          return;
        }
        minDurationField.validate();
      }
    });
  });
  return (
    <>
      //...
      <Field
        name="minUnit"
        component={[
          FormSelect,
          {
            size: 'md',
            className: styles.formSmallSelect,
          },
        ]}
        reactions={[
          (field) => {
            // 监听 leaveUnit 的值修改动态设置 minUnit 的选项值和选中值
            const leaveUnit: LeaveUnitItem = field.query('.leaveUnit').value();
            if (!leaveUnit) {
              return;
            }
            const newOptions = getScopeOptions(leaveUnit);
            field.setComponentProps({
              options: newOptions,
            });
            field.setValue(
              Math.min(
                Math.max(field.value, newOptions[0].value),
                newOptions[newOptions.length - 1].value
              )
            );
          },
        ]}
      />
      //...
    </>
  );
}

主动模式

主动模式便于实现一对多联动。当前字段值修改影响其他字段。

主动模式联动核心基于:

常用的 fieldEffectHooks 有:

  • onFieldValidateStart:监听某个字段校验触发开始的副作用钩子

其中,value change effect hooks:

  • onFieldChange:用于监听某个字段的属性变化的副作用钩子,如监听字段的显隐变化
  • onFieldValueChange:用于监听某个字段值变化的副作用钩子
  • onFieldInitialValueChange:用于监听某个字段默认值变化的副作用钩子
  • onFieldInputValueChange:用于监听某个字段 onInput 触发的副作用钩子
    以上的 4 个 value change effect hooks 中最常用的为 onFieldValueChange 和 onFieldInputValueChange。

onFieldValueChange 和 onFieldInputValueChange 区别的常见场景:

  • form.setValues 或 form.setValuesIn 时会触发 onFieldValueChange 而不会触发 onFieldInputValueChange
  • field value onInput 会触发 onFieldValueChange 和 onFieldInputValueChange
    有的时候数据联动复杂,就需要考虑使用哪一个 effect hooks 是符合预期的,避免在获取后端数据 setValues 时出发了 effect hooks 导致原数据改变。

被动模式

被动模式便于实现多对一联动。其他字段修改影响当前字段。

被动模式的核心是基于:

  • onFieldReact 实现全局响应式逻辑
  • FieldReaction 实现局部响应式逻辑

    json schema 中的联动更多可以参考 SchemaReactions
    SchemaReactions 实现 Schema 协议中的结构化逻辑描述(内部是基于 FieldReaction 来实现的)
    一般情况下设置 field reaction 就能实现大部分功能。其中 field reaction 和 onFieldReact 内部都会自动收集依赖。最常见的问题有:

Q:为什么触发了 field 自身值改变调用了自身的 reaction?

* 最大的可能性是 field reaction 中使用了自身值,reaction 收集了自身值的依赖。

Q:reaction 中设置了需要监听某个 field value 的逻辑,但是并没有响应?

在创建 Field 实例时会建立 onFieldValueChange、onFieldInitialValueChange 等事件监听的连接,而 ObjectField 只会监听到对象属性的数量增减,ArrayField 只会监听到数组元素数量的改变

* 使用 ArrayField、ObjectField、VoidField 组件挂载字段值,但是这些组件在创建实例时没有设置子元素的 value change 监听,所以是无法监听到详细的字段值改变。
* 没有使用 Field 组件挂载字段值,无法收集到 Field 依赖,所以监听不到 value change。
* 监听的 Field 初始化时机有问题,reaction 初始化时,Field 还没有挂载,收集不到依赖。
* 监听的 Field path 错误,没有指向正确的 Field。

Q:组件显示隐藏后再显示 effect hooks 失效了

参考 formily issue:https://github.com/alibaba/formily/issues/1602

* formily 内部自动做了隐藏时 form dispose,为了避免内存泄漏的问题。
* createForm 中的 effects 可以使用 [addEffects api](https://core.formilyjs.org/zh-CN/api/models/form#addeffects) 或者 [useFormEffects hooks](https://react.formilyjs.org/zh-CN/api/hooks/use-form-effects) 方式替代。
* ⚠️ onFormReact 会在卸载的时候,收集的 Field 依赖项会分别执行 dispose 函数。

Q:被动模式容易进入死循环

* 多个值相互监听修改 value 可能会触发死循环,要注意循环判断。

主动模式 & 被动模式 demo:https://codesandbox.io/s/formily-reaction-demo-68zgri?file=/App.tsx

Reaction 机制

参考链接:https://reactive.formilyjs.org/zh-CN/guide/concept#reaction
先从 reaction 的机制来看,reaction 在响应式编程模型中,它就相当于是可订阅对象的订阅者,它接收一个 tracker 函数,这个函数在执行的时候,如果函数内部有对 observable 对象中的某个属性进行读操作(依赖收集),那当前 reaction 就会与该属性进行一个绑定(依赖追踪),知道该属性在其他地方发生了写操作,就会触发 tracker 函数重复执行,用一张图表示:

reaction机制

可以看到从订阅到派发订阅,其实是一个封闭的循环状态机,每次 tracker 函数执行的时候都会重新收集依赖,依赖变化时又会重新触发 tracker 执行。

所以,如果一旦我们不想再订阅 reaction 了,一定要手动 dispose,否则会内存泄漏。
field reaction 和 effect hooks 中 formily 底层做了 dispose 的处理,所以一般使用时可以不用手动 dispose。

最佳实践

🥲 含泪总结的最佳实践(可能会有偏差,欢迎补充和讨论

  • 表单字段的 reaction 太多了,可以全部抽到一个 reaction 的文件中,统一管理,记得注释。
  • effect hooks 最好写到字段值定义的地方,就近原则。
  • 一般情况下字段联动逻辑是主动模式和被动模式搭配使用,但是如果逻辑不清晰容易进入死循环,所以清晰的联动逻辑是很重要的。
  • form effect hooks 会有显隐问题,所以需要使用 useMemo 控制创建 form 实例。

有时 Form 上挂载的组件变动过大,如某些情况下需要挂载 FormStep 组件,另外的情况又不需要挂载 FormStep 组件,如果一直使用同一个 form 实例就会出现问题,所以需要控制 useMemo 创建 form 实例的时机。
(不用 useMemo 可不可以?不可以,重复渲染会导致重复创建 form 从而引发更多的问题)

// ⚠️ 注意:使用 formStep 实例要和 form 实例保持一致
const formStep = useMemo(() => {
  return FormStep.createFormStep(FormStepIndex.RULE_CONFIG);
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [holidayTypeDetail?.holidayTypeId]);

const ruleDetailForm = useMemo(() => {
  return createForm<RuleConfigForm>({
    initialValues: RULE_CONFIG_FORM_INITIAL_VALUES,
  });
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [holidayTypeDetail?.holidayTypeId]);

ruleDetailForm.addEffects('leaveRuleDetailEffects', () => {
  onFormValidateFailed(() => {
    if (formStep.current === FormStepIndex.RULE_CONFIG) {
      sendMessage({ id: ERROR_MESSAGE_ID, content: '基本规则配置有误', type: 'error' });
    }
  });
});

// ========================= render 部分 =================================
// ...
 <FormProvider form={ruleDetailForm}>
  {/* 假期类型为不限额或者加班调休时,没有发假规则 */}
  {!showSendRule ? (
    <RuleConfig />
  ) : (
    <VoidField
      name="holidayRule"
      component={[
        FormStep.JsxFormStep,
        {
          className: styles.navWrap,
          minWidth: 284,
          formStep: formStep, // 关联 formStepModel
          steps: [
            {
              title: '基本规则',
              name: FormStepName.RULE_CONFIG,
              content: <RuleConfig />,
            },
            {
              title: '发假规则',
              name: FormStepName.SEND_RULE_CONFIG,
              content: <SendRuleConfig />,
            },
          ],
        },
      ]}
    />
  )}
</FormProvider>
// ...

官网的最佳实践:https://reactive.formilyjs.org/zh-CN/guide/best-practice

在使用@formily/reactive 的时候,我们只需要注意以下几点即可:

  • 尽量少用 observable/observable.deep 进行深度包装,不是非不得已就多用 observable.ref/observable.shallow,这样性能会更好
  • 领域模型中多用 computed 计算属性,它可以智能缓存计算结果
  • 虽然批量操作不是必须的,但是尽量多用 batch 模式,这样可以减少 Reaction 执行次数
  • 使用 autorun/reaction 的时候,一定记得调用 dispose 释放函数(也就是调用函数所返回的二阶函数),否则会内存泄漏

@formily/reactive 中 action 与 batch 的区别

参考社区的 discussions:https://github.com/alibaba/formily/discussions/1928

batch:定义批量操作,内部可以收集依赖

// 只做batch,不阻止track
interface batch {
  <T>(callback?: () => T): T //原地batch
  scope<T>(callback?: () => T): T //原地局部batch
  bound<T extends (...args: any[]) => any>(callback: T, context?: any): T //高阶绑定
  endpoint(callback?: () => void): void //注册批量执行结束回调
}

action:定义一个批量动作。与 batch 的唯一差别就是 action 内部是无法收集依赖的

// batch+untrack
interface action {
  <T>(callback?: () => T): T //原地action
  scope<T>(callback?: () => T): T //原地局部action
  bound<T extends (...args: any[]) => any>(callback: T, context?: any): T //高阶绑定
}

参考链接

Formily 交流群

formily交流群