可能是你见过最专业的表单方案—解密Formily2.0

Formily2.0 官网: v2.formilyjs.org/

源码地址: github.com/alibaba/for…

迭代计划:2021年底发布正式版,beta/rc版本持续迭代半年,目前已发测试版

本次项目主要由笔者发起,在这里着重感谢 阿里数字供应链事业部 对Formily项目的重视与支持,同时感谢 宋思辰 给Formily2.0贡献了超高质量的 @formily/vue,还感谢笔者师弟 潇澤 给Formily2.0贡献了超级好用的智能网格布局组件FormGrid

介绍

如果你是还没用过Formily的朋友,可以移步 Formily介绍 了解Formily是如何一步步解决表单问题的,如果是用过Formily的朋友,一定了解Formily过去的定位,也就是 面向复杂场景的表单解决方案,但是Formily2.0的定位发生了很大变化,一句话表达就是 面向企业级表单的专业解决方案,何为专业?专业就是拥有:

  • 业内领先的思想
  • 丰富的使用场景
  • 极致的细节优化
  • 完善的文档周边

现在Formily2.0可以拍着胸脯自信满满的对你说:“我足够专业!”,哪里体现专业呢?还得从解密开始。

说到解密,自然是需要对Formily2.0一步步剖析,才能真正了解它的核心价值,在这里,我们主要基于以下几个问题为切入点:

  • 为什么要升级?
  • 解决了哪些问题?
  • 都有哪些亮点?
  • 后续的规划是什么?

为什么要升级?

Formily2.0升级的原因,主要是对比1.x,有以下几点问题:

  • 性能还是不够好
  • 依赖关系太复杂,体积太大,稳定性不够
  • 包结构设计不够优雅,直观
  • 内部设计不够完备,灵活,导致答疑成本巨高
  • 因为答疑量太大,发量也越来越少

以上列举的所有问题,Formily2.0已经全部解决,可惜又顺道带来了Break Change与重新学习的成本,那么,这都是如何解决的呢?

都是如何解决的?

性能问题

问题拆解:

  • 大数据场景的ArrayTable的初始化渲染卡顿问题
  • 超多字段的初始化渲染卡顿问题

以上这两个问题的本质问题其实是因为Formily在字段初次渲染的时候会阻塞式计算字段状态,比如某些联动,不只是需要字段A值变化时控制字段B的显示隐藏,首次渲染的时候也需要控制字段B的显示隐藏,否则就很难满足业务需求了。

那假如我们把初次渲染的阻塞式计算改成在字段挂载完成之后再计算会怎样呢?就会出现体验很差的闪动效果,一开始字段是显示的,然后又隐藏,这种体验肯定是不可接受的,所以初次渲染的阻塞式计算是必须有的,只是这里的计算量太大导致的性能问题。

那么如何解决计算量的问题呢?

咱们先分析计算量大主要大在哪里:

  • 因为要支持外部控制,所以支持了受控渲染,但内核是不受控状态,要从受控转到不受控,需要监听props变化,这里会有大量脏检查
  • 初始化联动的setFieldState会遍历所有字段去找到目标字段,如果联动规则很多,那么初始寻找字段的查找次数也会非常多,也就是存在大量的重复寻找字段的遍历次数
  • ArrayTable数据量太大,导致字段数量也很大

第一点很好解决,我们只需要放弃受控模式就行了,那用什么替换呢?参考Vue,我们采用Reactive模式,基于类似Mobx的解决方案来响应外部变化,这样一个是解决了性能问题,同时还会减少props脏检查的副作用问题(比如props传一个匿名函数,这个函数到底是否应该让组件重新渲染其实是由用户决定的)

第二点不太好在setFieldState中优化了,因为寻找字段就是得遍历寻找,只能想其他办法?支持被动联动模式,借助类似Mobx的解决方案(@formily/reactive)设计一个专门解决被动联动的响应器模型,也就是说只有渲染到某个字段的时候,才会触发响应联动,这就解决了寻找字段次数过多的问题

第三点非常难解决,因为字段数量摆在那里,基数大了,比如一个横向10列,纵向100行的数据,轻轻松松就有1000个字段了,即便用了Reactive,也是会创建很多Observable对象,所以最终只能妥协,把ArrayTable变成分页展示,这样即便是10万行数据也能轻松渲染了。具体示例移步ArrayTable

总结

Formily2.0的性能优化之旅目前而言基本上可以说是已经快到极限了,当然还有优化空间,就得看灵感了。

依赖关系问题

问题拆解:

styled-components依赖问题

  • 导致整体包体积增加
  • 无法覆盖样式,大量的随机类名
  • 大版本各种Break Change,影响Formily稳定性,很容易出现重复打包,同时一旦出现重复打包,样式就会出问题
  • 无法定制主题,因为无法复用本身组件库的样式变量(less/scss)

所以Formily2.0果断移除styled-components依赖,follow组件库自身的样式体系,比如antd,改用less,fusion则改用scss

immerjs依赖问题

  • 大版本Break Change,影响Formily稳定性,很容易出现重复打包
  • 以Immutable为核心思想的模式,始终不太适合Formily这样的Observable Form的目标

为什么Immutable思想不太适合Formily呢?因为Formily不想再做脏检查,1.x基于immerjs迭代了很多版,一开始的版本脏检查次数非常多,一直优化到至今,还是有一些脏检查不可避免,因为它根本没有像Mobx一样的依赖追踪机制。

所以Formily2.0果断放弃了immer依赖,内部基于@formily/reactive实现响应式领域模型。

rxjs依赖问题

  • 导致整体包体积增加
  • Rxjs存在各种大版本Break Change,一个是会影响Formily稳定性,一个是很容易出现重复打包
  • 实际使用过程中用到的API并不多

所以在Formily2.0中直接移除了rxjs,那会不会导致用户写逻辑变得更加复杂呢?

因为rxjs对用户的要求是将所有状态变成Observable事件流模式,这样才能发挥出它的最大价值。

可带来的就是心智压力实在太大,而且实际场景,我们的状态几乎都是以对象实体来建模的,而非以事件流建模。

所以两种心智模型不停切换对用户而言,学习成本太高,而且收益也不大。

相反,Reactive这种模式,虽然同样是Observable的,但是它是借助了劫持代理将数据自动转换成Observable的,也就是说它将大量的Observable计算消化到了内部,对外暴露的更多的是用户容易理解的实际业务逻辑,所以这种模型是对业务建模是非常友好的,这也侧面证明了在Formily2.0中引入Reactive不仅能解决性能问题,还能帮助用户更加方便的做领域建模。

cool-path依赖问题

  • 独立维护不方便阅读源码,文档查阅体验也不好
  • 独立维护不方便表单场景针对性的调试

所以在Formily2.0中直接将CoolPath移到了Formily主仓库,并命名为@formily/path

总结

整个依赖关系治理了之后,Formily2.0的整体体积减少了很多,同时可控性与稳定性也将会大大提升。

包设计问题

问题拆解:

  • package没有一个模块导出标准,把所有API全部在@formily/antd这个包里导出了,内部的包API命名还有存在冲突的情况,同时@formily/antd和@formily/antd-components定位始终没讲清楚,如果以React组件为分类标准,其实应该全部在一起,如果以核心包+扩展组件包的分类标准,又应该拆开
  • @formily/antd支持了esm模块标准,其他底层库却不支持,这样对使用snowpack/vite用户很不友好
  • 没有单独抽离json schema协议部分作为统一层,导致想要扩展Vue部分很难扩展
  • @formily/react-shared-components的定位很鸡肋,其实它只是为了在antd/fusion之间组件复用,但是如果想扩展其他组件生态,就很难复用了

第一点:统一收敛组件包到@formily/antd上,它的定位就是一个单纯的组件库,核心API从@formily/react与@formily/core中导出,不再揉在一个包中,这样有2个好处:

  • 组件包的定位更加清晰了,写文档也会比较好写,用户也方便定位文档
  • 完全解决了不同包之间命名冲突的问题,因为不会将所有API统一从一个包里导出了

第二点:所有package构建的时候同时打出esm模块,umd模块,囊括业内所有模块标准

第三点:单独抽离@formily/json-schema包,方便其他UI桥接库使用

第四点:移除@formily/react-shared-components,一定的冗余可以降低体系复杂度

总结

一定要保证每一个包的定位和职责是确定的,否则不管是在写文档还是答疑,都很难讲清楚。

答疑成本问题

问题拆解:

Schema协议的不完善导致答疑量剧增

  • 为什么type为object,有些时候是VirtualField,有些时候却是普通字段?其实内部做了一个映射,但是非常隐晦
  • x-props是FormItem的属性还是具体字段组件的属性?因为有历史包袱,导致x-props既能传组件属性又能传FormItem属性,但就出现一个问题,FormItem属性和组件属性存在冲突,比如addonAfter,那到底应该传给谁,这种模棱两可的问题,对用户的理解成本却是巨大的。
  • 为什么setFieldState修改组件数据源需要设置state.props.enum,很难理解
  • x-linkages无法适配更复杂的联动需求,比如计算器需求

第一点,Formily2.0 定义了一种新的Schema Type,叫做Void,也就是只要某个字段的type为void的时候,就会自动变成虚字段,这样显示声明虚字段的方式非常清晰,完全不像1.x一样,还得去猜,在前端是否有注册一个VirtualField

第二点,Formily2.0定义了x-decorator/x-decorator-props来描述包装器与包装器属性,用户可以更方便的注册包装器,从根本上解决了x-props语义不明确的问题与属性名冲突的问题

第三点:Formily2.0在模型层上直接维护了数据源状态,就叫做dataSource,我们总算不需要理解state.props.enum,

第四点:前面讲到Formily2.0内核是基于类似于Mobx的@formily/reactive来设计的了,所以我们在协议层也定义了一响应器的概念,叫做x-reactions,它既支持主动联动模式,也支持被动(依赖追踪)联动模式。协议描述联动的能力直接强大一个数量级。

模型设计的不完善导致答疑量剧增

  • 字段卸载的时候自动删值这个默认行为让用户非常非常难理解,这个行为给用户带来了很多不可预料的隐藏问题
  • 默认值与值合并策略不完善,老是出现一些难以预料的问题
  • 对于数组状态转置问题还是没有根本解决,比如嵌套的ArrayList场景,在上移下移的过程中,1.x是无法做到子级字段状态自动销毁和自动转置的。同时1.x的数组转置还会污染原有数组数据,会给数组元素注入symbol,这让用户很疑惑
  • 主动联动写法想要解决计算器需求非常麻烦
  • Effects内部无法维护局部状态,使得有些场景实现起来异常复杂且麻烦

第一点:废弃字段卸载自动删值的默认行为,如果要删值,始终以用户行为为准,只有用户手动控制字段隐藏(display === “none”),或者手动设置值为空才会删值

第二点:始终以用户行为为准,如果某个字段没有被用户操作过,合并以赋值的先后顺序为准

第三点:定义特殊字段模型ArrayField,状态转置逻辑挪到ArrayField的方法中,这样可以做到不污染原有数组数据,同时转置算法做了很大程度的优化,保证了转置过程的绝对正确性,具体转置算法后面会专门讲。

第三点和第四点:引入了@formily/reactive 之后一切迎刃而解,对于第四点,后面会专门讲。

自定义组件扩展机制设计的很不优雅导致答疑量剧增

  • 全局注册组件机制很容易因为重复打包而导致组件找不到的情况
  • 自定义组件想要消费表单模型和当前字段模型状态使用useForm和useField是无法消费的,因为它们是用来创建模型的,而非使用模型
  • 为什么要实现一个组件的阅读态是依赖props.disabled?那就是想实现禁用态怎么实现?

第一点:放弃全局注册组件机制,改为工厂式注册(createSchemaField),第一可以获得更强的类型提示,第二,可控性更强,即便是重复打包也不会出问题

第二点:useForm/useField职责为使用上下文的表单模型和字段模型,创建表单模型或字段模型不走React Hook,这样使得API更加清晰易理解了

第三点:字段模型层新增readPretty模式,代表阅读态,禁用态确定为disabled,不再表达阅读态含义,实现阅读态,可以直接消费字段模型的readPretty属性,这样修改之后,自定义组件将会变得更加灵活,能力也更加完备

文档体系的不完善导致答疑量剧增

  • 不支持搜索,用户很难快速定位文档位置。
  • 没有给出每个包的文档,全部揉在一起,不方便用户自顶向下思考Formily,从而导致用户很难快速定位文档位置。
  • 文档缺失严重,很多API都得猜它怎么用

这个问题首先就是使用社区优秀文档工具dumi,基本上文档相关的问题它都能很好解决,然后就是需要拆开各个包的独立文档,以每个包都是一个独立产品的思路去运营,这样既能方便使用者查找文档,也能方便开发者维护文档。

总结

解决答疑成本的问题,核心还是要以用户为准,只要用户觉得难受的,必然就是设计不够优雅,不够灵活完备的。

发量问题

为了解决发量问题,前提是把前面的所有问题都解决,然后再进一步解决发量问题,我这边的解决方法是:

  • 将过去几十元的洗发水升级为几百元的高档男士洗发水 — 施华蔻薄荷活力洗发水
  • 每周去盲人推拿,按摩头皮
  • 开始每天吃保法止
  • 花几万元植发

都有哪些亮点?

目前主要讲Formily2.0相比于1.x的升级亮点,如果想要了解Formily的整体能力,可以移步 Formily介绍

独立的响应式解决方案

@formily/reactive,前面也多次提到了它,这里正式介绍一下,它的核心思想是参考的Mobx,但是它解决了Mobx在复杂领域模型上没有解决的一些问题:

  • mobx 不支持 action 内部进行依赖收集
  • mobx 的 observable 函数不支持过滤 react node,moment,immutable 之类的特殊对象
  • mobx 的 observable 函数会自动将函数变成 action
  • mobx 的 batch 模式不支持局部batch
  • mobx-react-lite 的 observer 不支持 React 并发渲染
  • 深度observe监听对象变化无法拿到值路径

这些问题如果是深度Mobx用户应该是能体会到的,总之,造了@formily/reactive这个轮子是专门为了解决像Formily这样复杂领域模型而存在的,完全可以替代Mobx,同时它的体积比Mobx要小很多很多,而且因为有Formily背书,完备性,性能,稳定性都有保证,当然,这也证明了Formily遇佛杀佛,遇鬼杀鬼的态度,只要是能让Formily变得更好的手段,造轮子又怎样?

更优雅的开发方式–TS智能提示

因为Formily2.0已经是所有代码都是Typescript编写,也就是说你在使用任意一个API的时候都能获得优秀的智能提示体验,下面两个很常见的使用案例。

纯JSX开发模式

不管是component属性还是decorator属性,第二个参数的智能提示永远是跟随第一个参数传入的组件来提示,这样用户就不需要再去猜这个组件到底有哪些属性API。

Schema Markup开发模式

值得关注的是,Schema Markup的类型提示是借助了Typescript4.2的Template Literal Types 来实现的。

支持Vue2/Vue3

在这里很高兴的告诉大家,Formily总算支持Vue了,而且还是同时兼容Vue2与Vue3的,这里再次感谢 宋思辰 同学。

具体文档地址 @formily/vue

同时,Vue相关的Formily扩展组件生态也在进一步建设中,同样是借助的社区力量,社区的力量真的很给力!

Effects局部状态

什么是Effects局部状态,我们可以先看看 Select异步搜索案例

从该案例中,我们会发现 useAsyncDataSource 这个Effect Hook函数内,可以直接用@formily/reactive来定义状态,然后又能在onFieldReact中消费,也就是说,我们的Effect Hook完全做到了自包含,也就是说很多复杂的表单逻辑,我们可以基于Effect Hook以非常高内聚的方式去抽象,写法非常类似Vue3的setup,当然,这并不是故意模仿,算是在引入Reactive概念之后的一种不谋而合吧,也侧面证明了effects这种模式的可行性与完备性非常高。

Effects上下文

在 effects 函数中如果我们抽象了很多细粒度的Effect Hook,想要在 hooks 里读到顶层上下文数据就需要层层传递,这样明显是很低效的事情,所以 Formily2.0 提供了 createEffectContext 帮助用户快速获取上下文数据,这就类似于React Context一样,具体文档参考createEffectContext

智能网格布局

为什么需要智能网格布局?为什么不用组件库默认的网格布局?

以Ant Design的Grid为例,它主要有几个问题:

  • 写法实在太麻烦,每次都需要嵌套Row/Col,代码量巨大
  • 无法快速实现等分布局
  • 无法控制每一列的最大宽度或者最小宽度做弹性伸缩

所以Formily2.0中提供了FormGrid智能网格布局组件,它是基于css grid来实现的,当然,它内部还有一些js计算,它的核心亮点有:

  • 只需要引入一个FormGrid组件即可快速实现网格布局,只针对FormGrid组件下的html元素生效
  • 默认等分,对于需要跨列的网格,只需要包一层GridColumn组件即可
  • 跨列场景拥有自动纠正能力,因为整个布局系统是完全响应式弹性伸缩的
  • 支持设置每一列的最大最小宽度,这个能力在表单场景非常有用,因为需要防止超窄屏或超宽屏场景下表单控件变得极窄或者极宽的问题

文档地址

响应式并发渲染

内存回收问题

因为React要支持concurrent模式,Function Component对Mobx这类Reactive方案非常不友好,比如StrictMode下,组件每次更新都会重复触发函数执行2次,useMemo也会触发2次,但是useEffect/useLayoutEffect却又只会执行一次,而Reactive方案则强依赖useMemo,因为要创建一个持久化的Reactive Tracker对象与当前组件做绑定,按照前面这种非常混乱的执行逻辑,则会导致Reactive Tracker无法正确的回收内存。

参考Mobx的方案,@formily/reactive 也是采用了FinalizationRegistry API来实现自动回收内存,具体逻辑就是在Function Component中创建一个对象引用,然后传给FinalizationRegistry ,监听该引用被销毁的时机,如果该引用被销毁则回收第一次useMemo创建的Tracker。

需要注意的是,在第二次执行Function Component的时候会触发useEffect,需要在useEffect中释放第二次渲染FinalizationRegistry 创建的监听函数(防止Tracker被错误销毁),只有在组件unmount的时候才可以真正销毁第二次useMemo创建的Tracker。

源码地址

父子组件并发响应问题

主要是解决以下报错问题

Warning: Cannot update a component (`ParentComponent`) while rendering a different component (`ChildComponent`). To locate the bad setState() call inside `ObjectField`, follow the stack trace as described in
复制代码

这个问题非常难解决,就连Mobx也没解决,那怎么才会触发这个问题呢?

如果父子组件共同依赖同一个Reactive数据,且子组件在第一次渲染的过程中对修改这份Reactive数据,那就会触发该报错问题。

那@formily/reactive-react是怎么解决的呢?

笔者整整花了1个月才想到的解决方案,解决思路核心就是:全局维护一个更新队列,与一个渲染计数器,被observer包装的组件在每次渲染的时候都会触发计数器加1,useLayoutEffect执行的时候触发减一,在组件被触发更新的时候需要检查计数值,如果为0,说明全局没有其他组件正在渲染,如果大于0,则说明其他组件正在渲染,将更新入队,在useLayoutEffect执行的时候同样会检查计数值,如果为0 ,则说明整体处于稳定状态,批量执行更新队列(一边执行,一边删除队列元素)。

注意,这里直接用useLayoutEffect其实是有个陷阱的,因为useLayoutEffect的执行次数是不稳定的,前面讲内存回收问题的时候有提到,那怎么解决这个问题呢?这里笔者用了一个非常取巧的方法,在计数器加1的同时创建一个定时器,用来执行计数器减1和执行更新队列,在useLayoutEffect里清除定时器,因为useLayoutEffect是同步执行的,如果正常情况下,useLayoutEffect是会在定时器执行之前执行,清除定时器就不会触发计数器重算,但是如果useLayoutEffect没有被正确触发,比如StrictMode,还有组件调用了无效setState场景,那就会走定时器这个兜底逻辑。

源码地址

数组转置算法

在1.x中的数组转置算法,可以说是没有算法,非常不完备,且不安全,同时还很难排查问题,主要是因为:

  • 它通过污染数组原始数据的方式给每个元素打标,然后基于每次更新的标记来判断数组是否发生了移动,删除,新增等操作
  • 基于了非常不靠谱的定时器来做节点删除,完全是当时的无奈之举

在Formily2.0中,第一个问题通过直接定义ArrayField操作方法的方式做到了完全不需要污染数组原始数据就能清楚知道用户的实际操作。

第二个问题,重点来了:

首先,Formily2.0放弃了过去的FormGraph数据结构,直接将所有字段模型存在了表单模型的fields属性上,就是一个单纯的对象结构:

type FieldAddress = string
type FieldsStore = Record<FieldAddress,Field>
复制代码

因为我们知道了数组的具体操作类型,那我们的状态转置,不就是将字段A的地址变成字段B的地址么?就是这么简单,所以我们每次操作的时候做一次全量遍历,寻找到要替换的字段模型,替换掉地址即可,模型完全不需要任何改动,当然这里面还有很多细节,但是整体思路就是这样的,总之这样的思路从根本上解决了Formily一直在数组状态转置上的痛点。

源码地址

还有哪些细节优化?

内核默认接入FormilyDevtools

在1.x中与chrome插件通讯的地方一直在@formily/react这一层做的通讯,如果用户想支持vue,那就得单独实现一次通讯,显然这是不够优雅的,所以Formily2.0将插件通讯逻辑内置到了内核中,这样不管接入哪种框架,都能获得优秀的chrome调试体验。

更加精细的校验时机控制

在1.x中,校验时机参数triggerType是挂在Field的属性上的,也就是它只能批量控制某个字段的校验触发时机,但是大多数场景,我们其实都是对单个校验规则设置触发时机,有些校验规则,还是希望走默认的(onChange触发),所以,Formily2.0实现了这个特性。

具体文档参考FieldValidator

校验结果支持成功状态

在1.x中,校验结果支持了warning类型,也就是不阻塞提交,但是FormItem会有warning样式,但有些场景,我们可能对成功状态也需要走校验规则逻辑,所以,Formily2.0支持了success类型,FormItem同样会有success样式。

具体文档参考FieldValidator

FormPath支持相对路径查询

在1.x中,相对路径只能用在x-linkages中,这次2.0升级,我们直接给FormPath支持了相对路径计算,也就是说,我们不需要在用繁琐的transform了,而且,每个字段模型都有一个query方法,可以快速查询相对字段,比如:

field.query('.aa').value() //直接读取同级别aa字段值
field.query('..aa').value() //读取父级aa字段值
field.query('..[+].aa') //读取跨级相邻aa字段值
复制代码

具体文档参考 FormPath

总结

前面讲了那么多,其实Formily2.0最大的变化核心还是来源于引入了Reacitve响应式编程模式,让一切问题都变得迎刃而解了,当然,亮点和细节优化也不止提到的这些,还有很多需要你去挖掘和体会的哦!看到这里,你是否想要尝试一把了呢?

Q/A

问:为什么测试版要持续半年?

答:目前已经发的测试版已经是可用版本,单测覆盖度已经超过90%,这么长的迭代时间主要是用于收集用户反馈,如果哪里设计不好的,可用及时纠正,防止正式版给用户埋坑。

问:后续的规划?

答:下一步主要是2.0表单设计器,目前已经在开发阶段了,预计年底会放出测试版

问:正式版发布之后1.x还会继续维护吗?

答:会继续维护,只是不会再新增feature,只做bugfix,预计会持续维护到2022年底

问:Formily2.0的浏览器兼容性如何

答:不兼容IE

问:@formily/antd这个包支持antd3.0么?

答:不支持

问:有了 Vue 了,为什么还需要提供@formily/vue?

答:Vue 是一个 UI 框架,它解决的问题是更大范围的 UI 问题,虽然它的 reactive 能力在表单场景上表现出众,至少比原生 React 写表单要方便,但是如果在更复杂的表单场景上,我们还是需要做很多抽象和封装,所以@formily/vue 就是为了帮您做这些抽象封装的事情,真正让您高效便捷的开发出超复杂表单应用。

问:Formily方案与FormRender是什么关系?

答:Formily是集团官方统一表单解决方案,FormRender是飞猪团队根据自身业务场景沉淀出来的解决方案,不同的产品思路,存在即合理。

招聘

如果你对中后台前端技术非常感兴趣,还想继续深挖并攻破其中的技术难点,那欢迎来阿里数字供应链事业部,这里有足够大的平台让你施展你的前端魔法,笔者也十分期盼与你共事,这是笔者的微信:janry_kk

钉钉技术交流群

关注钉钉群,后续可能会不定时直播,分享Formily的各种心得技术哦~(群号:)

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享