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 分为了内核层,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 的架构图:
可以从架构图中可以得出渲染时 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 管理表单字段数据状态
// 组件库组件: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
表单结构本身就是一个树结构,每个字段都有自己的绝对路径和相对路径。其中 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 函数重复执行,用一张图表示:
可以看到从订阅到派发订阅,其实是一个封闭的循环状态机,每次 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 //高阶绑定
}
参考链接
formliy 官网:https://formilyjs.org/zh-CN/guide
formily github:https://github.com/alibaba/formily
@formily/reactive:https://reactive.formilyjs.org/
@formily/core: https://core.formilyjs.org/
@formily/react: https://react.formilyjs.org/
formily 2.0 更新概要:https://github.com/alibaba/formily/discussions/1087