Formily 2.0 深度实践

5,332 阅读8分钟

Formily 作为阿里巴巴旗下开源的一套非常火热的表单解决方案,目前已有 7.8k Star,针对表单这一领域场景,以非常完整、高效、先进的方式解决了开发表单过程中能遇到的几乎全部问题。

面向企业级表单的专业解决方案,专业!

Formily 2.0 正式发布至今已有 7 个月,作为 Contributor 之一,我将以一个企业级复杂度的表单——商品发布,作为应用场景做一个实践总结。

响应模型

初学者都说 Formily 的学习成本高、理解不了,如果说理解 Formily 最难理解的部分我想应该是 表单数据模型响应机制 的两大问题,而这也恰好是 Formily 的精髓所在。

历史的经验总是对人类有帮助的,几十年前,人类创造出了 MVVM 设计模式。这样的设计模式核心是将视图模型抽象出来,然后在 DSL 模板层消费,DSL 借助某种依赖收集机制,然后在视图模型中统一调度,保证每次输入都是精确渲染的,这就是工业级的 GUI 形态!

刚好,github 社区为这样的 MVVM 模型抽象出了一个叫 Mobx 的状态管理解决方案,Mobx 最核心的能力就是它的依赖追踪机制和响应式模型的抽象能力。

MVVM 设计模式

Formily 贯彻实现了真正的 MVVM 设计模式,在 Vue 框架中,大家都说 MVVM 但绝大多数开发者也只是理解了响应机制。什么是Model?谁会在写组件的时候强调,我这个组件的Model是什么?大多数开发者说的只是 props、data、state 这些。

Formily 跳脱于任何框架,先回归数据模型本身,非常细致、全面的梳理了表单这一场景中的所有 模型、字段、事件、生命周期。如果细细查看模型的每个字段含义,就能感受到其所说的 “企业级表单” 的深厚经验。总之,关于表单,你能想到的、没想到的,这个模型里面都有了;如果还有人说表单很简单,可以带他去看看

Formily 的表单、字段模型定义

当有了模型之后,还有一个问题需要处理:事件;与其说事件不如说是状态响应,就是说我们在对这个表单模型的某个字段修改之后如何让页面中的元素进行同时变化,最容易想到的是在每个模型字段变更的地方利用 EventEmitter进行事件冒泡,然后在组件的各种生命周期中进行订阅,然后做出响应。

而这也是 Formily 1.x 中的做法,这样确实很大程度的解决了模型和视图的关联,但也同样带来了非常多的事件冗余和性能问题。

在 React 场景下实现一个表单需求,因为要收集表单数据,实现一些联动需求,大多数都是通过 setState 来实现字段数据收集,这样实现非常简单,心智成本非常低,但是却又引入了性能问题,因为每次输入都会导致所有字段全量渲染,虽然在 DOM 更新层面是有 diff,但是 diff 也是有计算成本的,浪费了很多计算资源,如果用时间复杂度来看的话,初次渲染表单是 O(n),字段输入时也是 O(n),这样明显是不合理的。

要说 2.0 中最大的变化,莫过于引入了 @formily/reactive 作为表单模型的状态响应。利用 Mobx 的设计思想,可以将模型中的每个字段变为最小颗粒度的观察单元,这样模型中的每个字段就可以在 任意地方被订阅变化;同时,巧妙的 **依赖追踪机制 **更可以让开发者在无感知的情况下,订阅最少的字段。

举个简单的例子: 有一个name的字段,在 Formily 中它便是一个 Field 的字段模型,该模型中会存在 value \ errors等字段,然后通过视图组件将对应的数据信息给渲染出来。

按照React的state设计思路来看,其中任意一个字段修改都应该让组件重新执行渲染,但是假如我们的组件中只使用到了 value,那么errors 变化的时候组件渲染就是多余的。

而实际情况是一个完整的表单中将会有更加复杂的字段定义,更多的表单字段和联动,那么按照这样的渲染模式来说,性能将必然是一个极大的问题。

而神奇的 @formily/reactive 则巧妙的通过 getter获取组件运行过程中使用过哪些字段,从而完成动态的订阅和订阅释放,从而达到最小单元的订阅响应,这才使得整个表单在渲染过程中能够更加 **“精准” **的渲染。

想了解更多MVVM思想应用可查看:Mobx@formily/reactive

有了以上这样一个及其灵活、高效、精准的 “响应模型” 后,便可以在此基础上按照 React \ Vue 的方式灵活订阅消费了。并且,能够真正做到,哪个字段修改,哪些 “需要响应” 的地方才响应。

数组性能

商品发布中,有一个非常复杂的场景:笛卡尔SKU表格

大致意思就是通过商品属性设置,动态计算出所有的SKU,用户只需要填写对应的价格即可。

image.png

1655303314299-f3594bd8-a06f-4e35-939f-b444e22284ad.gif

就是这样的一个设计,产生出了无数问题,其中性能问题更是最头疼的问题。

一开始我们是基于 Formily 1.x 进行开发,正如我之前讲到的,基于事件流,整个表格在渲染过程中有非常多的事件订阅如(onFieldValueChange$);另外不同字段之间还存在校验联动,比如Special Price 价格不得大于 Price等。当有商品属性修改时,整个表格组件的渲染情况是这样的:

image.png

其中,那些FormItem相关项(需要输入)的渲染可了不得,它们的渲染会触发对表单模型的事件通知,这就会导致一个原本普通的render有非常多的副作用,在运行过程中的事件流下面这样的:

image.png

所以我们第一版中,大概编辑到50多个SKU就已经是上限了,再往上就会有非常久的响应时长。并且随着表格列的逐步增多,产品迭代逐渐复杂,这个性能问题就变得越来越突显。

在 Formily 2.0 正式发布后,我们也开始从新的模式获得到了灵感,开始着手 “抢救” 这个性能大坑😷。

最小订阅

升级 Formily 2.0 后,因为其最小订阅特性,使得我们从事件流中脱离出来,减少了非常多的字段和状态判断。 借助这样的最小变更订阅,使得表格在结构不变的情况下(比如修改某个商品属性的名称),每个 Field 不再执行重复的渲染和事件冒泡。

image.png

键值映射

面对笛卡尔智能组表的这个场景,我们一开始没有多想,Value 直接就按照如下结构进行了表单设计:

{
   skus: [
    { id: 'xxxxx1', price: 10 },
    { id: 'xxxxx2', price: 10.2 }
  ]
}

这个接口乍一看上去似乎没什么问题,也很直观,但实际上却存在非常大的问题。因为一旦数组的顺序发生变化,就会使得与其关联的所有 Field 都执行重新渲染,甚至事件冒泡。

在笛卡尔SKU的场景中,如果我们新增一个商品属性,就会在原有表格的中间插入新增的SKU。就会使得后面的SKU顺序都发生了变化,原来是 sku.5.price 现在就变成了 sku.6.price,这也导致表格中的这些单元格重新渲染,另外也会同时触发非常多的 onValueChange 事件。

image.png

解:

正如 Formily 作者在《解密Formily2.0》中提到的:数组转置算法

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

实际上,自动组表SKU数据 这本来就不是同一回事,如果把他们拆分开来看的话就会非常清晰。

简单理解,顺序、数量、组合关系这些表格长什么样是由另一个字段的值(销售属性)来决定的,仅此而已,没有 Value 一样可以完成组表这个动作,而这个过程很快,不是性能瓶颈。

之所以性能差,是因为在渲染单元格时引入了 Formily 作为字段状态管理,这个过程又因为组表过程中的顺序变化,导致了大量 Value 的变化,从而引起了大量订阅事件被触发,引起大量无效渲染。

但其实从数据本质来看,新增商品属性,表格多几行,其实对于数据本身是没有任何影响;每个SKU的价格是多少、库存是多少,这些应该是一个独立的 键值数据结构

{
   skuValue: {
      xxxxx1: { id: 'xxxxx1', price: 10 },
      xxxxx2: { id: 'xxxxx2', price: 10.2 }
   }
}

这样的好处是,在定义SKU值 Form中的路径时,不在关心表格的顺序如何,数据路径将变成: sku.0.price-> skuValue.[id].price

只要 SKU id 不变则数据字段不变,随便表格结构如何变化,都不会再影响 Value 变化;不论顺序如何,都能精准的绑定对应的 SKU 数据。当有新增的销售属性时,也只会渲染和变更对应位置的组件。

image.png

表格Diff

另外还有非常非常影响性能的问题,这个锅来自 Fusion ,也就是我们用的组件库。

image.png

我们都知道 React 会有 diff 算法来判断虚拟DOM树需要如何同步变更到真实DOM,而在这个过程中 key的定义就被作为组件diff的重要标识。

在执行变更DOM的过程中,如果 React diff 判断结果是INSERT_MARKUP(插入)或者 REMOVE_NODE(删除)则会执行组件的 mount \ unmount,这对于 Formily 便意味着很多很多的模型状态变化 (mount\visible等) ,又会引起非常多的订阅响应。

比如:现在表格中有 4 个SKU,原始数据中我会给每个SKU提供一个id作为这个dataSourceprimaryKey;在表格渲染过程中,Fusion 在行级组件(Row) 提供的 Key 是当前SKU的 id,但是在单元格(Cell)渲染时提供的却是${rowIndex}-${colIndex}这就意味着当前单元格,在以下任意一个关键值发生变化的时候都会执行销毁和重新挂载

  • 当前行数据的ID (主键)
  • 当前行顺序
  • 当前列顺序

实际上,对于表格来说,如果主键(primaryKey)是确定的,那么 "当前行顺序" 的影响因子就应该被主键所取代。

也就是说,SKU 的数据只要在 dataSource 中,就不应该执行该行单元格的销毁,不论其顺序如何变化。

目前该问题已经提PR 给 Fusion#3953

路径模型

FormPath 也是 Formily 2.0 中一个非常好用和有特色的能力,相比于 Formily 1.x 中的 cool-path ,它提供了更加清晰、易用、强大的路径管理能力。

可以说 Formily 中的路径处理是我见过最强的树形路径处理工具库,可能作者是觉得 Formily 已经有太多包了,所以把这个强大的工具库也放在了 @formily/core 里面(也可能懒),但这不妨碍它真的很强大。

只要稍微复杂一点的表单数据,一定会和各种树形路径打交道,很多时候拼接路径、查找路径、解构路径都是需要开发者自行处理的,这些东西说复杂也不复杂,说简单也不简单。

很多开发者可能会觉得,不就是几个路径处理么,无非是多几个点少几个点的事情,我自己处理下不就得了。但这种事情写一次两次还行,写多了,就变成屎了。

何不稍微花点时间学习下它的语法,来把这个测试覆盖率 99% 的路径工具库用起来呢。另外,我们之前讲过,因为是 Reactive 的模型基础下,如果自己写这类路径处理方法也有可能引起不必要的副作用。

下面粘几个非常不错的例子,一起来感受下吧:

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

总结

目前我们业务中已经使用 Formily 2.0 完成了全新的重构升级,依赖显著减少(rxjs、stylecomponents),重构过程中删除了非常多的兼容代码,当然性能也 巨幅提升

在商品发布场景下,200多 个SKU (表单项 1200+) 在Formily 1.0 (上个版本) 中基本是 不可用状态,但我们为了对比测试,还是做了一组数据记录如下:

Formily 1.0 重构前Formily 2.0 重构后优化幅度
表单初始化时长196529ms5843ms缩短 97.2%
值变化,SKU表结构改变40983ms774ms缩短 98.1%
值变化,SKU表结构不改变69465ms1520ms缩短 97.8%
* 以上测试数据均产出于 开发模式,250个SKU,表单项1500+