动态表单设计_面向复杂场景的高性能表单解决方案(背景篇)

e1b7ad989766bc798558c86a36f0d30e.png

UForm 是由阿里供应链平台前端团队研发打造表单解决方案,基于 React,注重高性能,高效率,可扩展。

Github Page:https://alibaba.github.io/uform

源起

还记得 4 年前,刚入职天猫的时候,接到了一个中后台前端需求,是天猫超市的阶梯满减优惠券创建页面,那时 React 正开始普及,年轻气盛的我毅然决然的选择了使用 React 来开发,就一个单纯的 CRUD 录入页面,使用 redux 架构硬是把需求给交付了,但是,当时我总共花了 15 天才开发完成,当然,不排除当时第一次接触 redux,学习曲线较为陡峭,更重要的是这个页面的业务逻辑非常复杂,最复杂的部分主要是:

  • 阶梯规则,List 形式

  • 每一层阶梯内的同级字段之间有动态联动,比如:字段 A 变化,会控制字段 B 的显示隐藏

  • 每一层阶梯内的同级字段之间有联动校验,比如:字段 B 的值必须大于等于字段 A 的值

  • 层级与层级之间的字段有联动校验,比如第二阶梯的字段 A 的值要大于第一阶梯字段 B 的值

当时实现这样的需求,没有用到任何第三方表单解决方案,纯用 redux 实现,写了很多很多重复而复杂的面条代码,包括,表单的数据收集,字段校验等等,代码可维护性极低,最终迫使我开始真正深入表单领域,探索最佳的表单解决方案。

慢慢的,接触了集团内部和业界很多优秀的表单解决方案,它们的核心职能都是帮助你自动收集数据,同时搭配了一套完善的校验模型,让你真正做到只关心业务逻辑,但是,这些方案都会多多少少存在一些问题。

问题  1. 性能问题

因为都是基于 React 的传统单向数据流管理状态的思路来实现的表单解决方案,那么也会受单向数据流副作用影响,具体的副作用就是,一个 UI 组件的数据更新,会影响其他 UI 组件的重新渲染,其实其他 UI 组件并不需要重新渲染,也许你会说,React 有 shouldComponentUpdate API 来控制组件的渲染,所以,你需要对每个组件的 props 做 diff 判断,是选用浅比较还是深比较还得仔细斟酌,同时,如果是粗暴的使用 shouldComponentUpdate API 的话,还很有可能出现以下问题:

cosnt App = ()=>{

假如对 ComponentA 做了 shouldComponentUpdate 控制,只要 ComponentA 的属性没有发生任何变化,通过 setState 触发 App 组件重新渲染就不会向下触发 ComponentB 组件重新渲染,更不会使得 ComponentB 接收到最新的 value 属性。

就是说,用 shouldComponentUpdate API 控制渲染对于存在组件嵌套的场景是很有可能出现子组件没有正常接收新属性的情况的。

还有,在 React 最新的 React Hooks 体系中是没法处理 shouldComponentUpdate 的,只有一个 React.memo API,但是它只是对属性做浅比较,这样来看,就好像是 React 官方自己把进一步性能优化的路给堵死了似的,其实主要原因是因为 React 官方推崇 Immutable 数据,所有数据操作需要严格走 Immutable 的方式做数据操作,但是对于通用组件而言,为了保证组件鲁棒性,一般都不会假定用户传 Immutable 的属性,最终,你到底要不要坚持单向数据流管理一切的数据呢?性能问题却已经摆在了面前。

所以,很多表单解决方案就开始放任 React 全局 rerender,就像只要满足官方推荐的单向数据流方式就是一种政治正确一样。

 2. 代码可维护性问题

说到代码可维护性,我们需要判断,什么样的代码才是可维护的。可以大致总结一下,一般具有可维护性的代码都有以下特征:

  • 代码风格是与团队代码风格一致的,这个可以通过 eslint 做约束

  • 代码是模块化的,不应该一个文件包含所有业务逻辑,必须做物理拆分

  • 逻辑是分层的,Service 是一层,View 是一层,核心业务逻辑是一层,可以参考 MV*,每一层不应该掺杂其他层的职能

  • 视图是组件化的,将通用视图交互逻辑层层抽象封装,进一步简化核心视图复杂度

下面我们再来看看传统的表单解决方案,比如 Ant Desgin 的 Form 解决方案,下面是它的使用规范:

  • 要求使用到表单的业务组件统一使用 Form.create() 包装器来对其做包装

  • 通过 props 拿到 Form 实例方法,比如 getFieldDecorator,通过 getFieldDecorator 对表单字段做再次包装,用于处理数据收集,数据校验

  • 联动逻辑分散在各个具体的表单组件的 onChange Handler 中,通过 this.props.form.setFieldsValue 来处理字段间联动

我们再想象一下,如果一个表单页面有很多联动,我们将不得不在一个业务组件内写很多的 onChange Handler,最后导致业务组件变得非常臃肿,对于初学者而言,写大量的 onChange Handler 是很有可能直接在 jsx 中写匿名函数的,那么,这样也会导致 jsx 层变得非常脏乱差,所以,事件处理逻辑是需要与 jsx 层做严格隔离的,否则代码的可维护性就堪忧了,当然,对于简单的场景而言,使用 Antd Form 是没任何问题的,不过,Antd Form 仍然是采用单向数据流的方式来管理状态,也就是说,任何字段变动都会导致组件全量渲染,同样,Fusion Next Form 也存在同样的问题。

 3. 表单研发效率问题

说到研发效率,一定就是尽可能的让用户少写重复代码,如果你用 Antd Form 或者 Fusion Next Form,你肯定会发现你的组件内部到处都是 FormItem 组件,到处都是 onChange Handler 到处都是{...formItemLayout},这些重复而又低效的代码其实是不应该存在的。

 4. 后端数据驱动问题

有一些场景,我们的表单页面是非常动态化的,某一些字段是否存在,前端完全感知不到,是由后端建表,由不同职业属性的用户手工录入的字段信息,比如电商购物车的表单页面,交易下单页面,系统需要千人千面的能力,这样就需要前端拥有动态化渲染表单的能力了,不管是 Antd Form 还是 Fusion Next Form 都没有原生就支持这样的动态化渲染的能力,你只能在上层在封装一层动态化渲染机制,这样就得基于某个 JSON 协议来驱动渲染,我见过很多很多类似的动态化渲染表单的解决方案,它们所定义的 JSON 协议都是非常定制化的,或者说不够标准的,有些根本就没有考虑全面完备就开始使用,最终导致前后端业务逻辑都变得非常复杂。所以,表单的动态渲染协议最好是标准而且完备的,否则后面的坑是很难填平的。

探索

从上面的几个问题我们可以看出来,在 React 场景中想要更好的写出表单页面是真的很困难,难道,React 真的不适合写表单页面?

在大量搜索并研究各种表单解决方案之后,本人总算找到了一个能根本上解决性能问题的表单解决方案,就是 final-form, 这个组件是原来 redux-form 的作者重新开发的新型表单解决方案,该方案的思路非常明确,就是每个字段自己管理状态,自己做渲染更新,分布式状态管理,完全与 redux-form 的单向数据流理念背道而驰,但是,收益一下子就上来了,表单的整体渲染次数大大降低,React 的 CPU 渲染压力也大大降低,所以,final-form 就像它的名字一样,终结了表单的性能问题。

同时,对于代码可维护性而言,final-form 也有自己的亮点,就是它将表单字段的联动逻辑做了抽离,在一个独立的 calculate 里处理,这样就不会使得业务组件变得非常臃肿。而且,作者对 final-form 的可扩展设计也是非常清晰的,还有,final-form 是一个开箱即用的解决方案,它就是一个壳,通过 render props 的形式可以组合使用各种组件库,总之,final-form 解决方案解决了表单领域的大部分问题。

那么,还有哪些问题 final-form 是没法解决的呢?本人通过深度研究源码,用例,同时也结合了个人体会,大致可以总结一下 final-form 的问题:

  • 联动不能一处编写,单纯 calculator 不能处理状态的联动,比如字段 A 的值变化会控制字段 B 的 disabled 状态,必须结合 jsx 层的 Field subscription 才能做状态联动,用户需要不停的切换写法,开发体验较差,比如:https://codesandbox.io/s/jj94wojl95

  • 嵌套数据结构需要手动拼接字段路径,比如 https://codesandbox.io/s/8z5jm6x80

  • 组件内外通讯机制过于 Hack,比如在外部调用 Submit 函数 https://codesandbox.io/s/1y7noyrlmq

  • 组件外部不能精确控制表单内部的某个字段的状态更新,除非使用全局 rerender 的单向数据流机制。

  • 不支持动态化表单渲染,还是需要在上层建立一个动态渲染引擎

探索 & 创新

因为 final-form 已经解决了我们的大部分问题,所以可以在核心理念层借鉴 final-form,比如字段状态分布式管理,基于 pub/sub 的方式做字段间通讯,但是对于 final-form 所存在的问题,我们可以大致梳理出几个抓手:

  • 副作用独立管理,主要是对表单字段状态管理逻辑,独立带来的收益是 View 层的可维护性提升,同时统一收敛到一处维护,对用户而言更加友好

  • 嵌套数据结构路径自动拼接

  • 更加优雅的组件内外通讯方式,外部也能精确控制字段的更新

  • 基于标准 JSON Schema 数据结构做扩展,构建动态表单渲染引擎

最终,我们可以推导出解决方案的雏形:JSON Schema + 字段分布式管理 + 面向复杂通用组件的通讯管理方案

JSON Schema 描述表单数据结构

为什么采用 JSON Schema?我们主要有几方面的考虑:

  • 标准化协议,不管是对前端,还是对后端都是易于理解的通用协议

  • JSON Schema 更侧重于数据的描述,而非 UI 的描述,因为表单,它就是数据的输入,我们希望,用户关心的, 更多是数据,而非 UI

  • JSON Schema 可以用在各种数据驱动场景,比如可视化搭建引擎中的组件配置器等

什么是 JSchema?

JSchema 相当于是在 jsx 中的 json schema 描述,因为考虑到纯 json schema 形式对机器友好,但对人类不够友好,所以,为了方便用户更高效的描述表单的结构,我们在 jsx 层构建了一个 JSchema 的描述语言,其实很简单:


是不是发现,使用 JSchema 描述表单,比单纯用 JSON Schema 描述代码少了很多,而且也很清晰,所以,我们将在 jsx 层使用 JSchema,同时组件是既支持 JSchema 也支持纯 JSON Schema 形式描述表单的。

 JSON Schema 属性扩展

因为 JSON Schema 原本是用于描述数据的,如果直接用在前端里,将会丢失很多与 UI 相关的元数据,那么这些元数据应该怎样来描述呢?Mozilla 的解决方案是专门抽象了一个叫做 UI Schema 的协议专门来描述表单的 UI 结构,可以看看 https://github.com/mozilla-services/react-jsonschema-form。看似是将 UI 与数据分离,很清晰,但是,如果我们以组件化的思路来看待这个问题的话,一个表单字段的数据描述应该是一个表单字段的组件描述的子集,两者合为一体则更符合人类思维,怎么合,为了不污染 json-schema 原本协议的升级迭代,我们可以对数据描述增加 x-* 属性,这样就能兼顾数据描述与 UI 描述,同时在代码层面上,用户也不需要分两处去做配置,排查问题也会比较方便。

 字段状态分布式管理

想要理解什么是字段状态分布式管理,首先得理解什么是单向数据流,还记得 React 刚开始普及的时候,人人都在讨论单向数据流,就跟现在的 React Hooks 的概念一样火,当时我也是花了很长时间才理解什么才是单向数据流。

其实,单向数据流总结一句话就是:数据同步靠根组件重绘来驱动,子组件重绘受根组件控制

就像前面所说的,单向数据流模式是有性能问题的,所以,我们可以考虑使用状态分布式管理,再总结一句话,状态分布式管理就是:数据同步靠根组件广播需要更新的子组件重绘,根组件只负责消息分发

其实,前者跟后者还是有一定的相同之处的,比如根组件都是消息的分发中心,只不过分发的形式不一样,一个是靠组件树重绘来分发消息,一个是通过 pub/sub 来广播消息,让子组件自己重绘,数据流,还是一个中心化的管理数据流,只是分发的形式不一样,就这样的差别,却可以让整个 React 应用性能提升数倍。

面向复杂通用组件的通讯管理方案

对于复杂通用组件的通讯管理方案,使用单向数据流机制做管理性能问题是很严重的,所以只能再想想还有没有其他方案,其实也不是没有方案了,ref 就是一个很常见的通讯方式,但是,它的问题也很明显,比如容易被 HOC 给拦截,虽然有了 forwardRef API,但还是写的很别扭,而且还增加了组件层级,提升了组件复杂度。

但是,参考 ref 的设计思路,其实还是可以借鉴的,ref,就像它的名字一样,是作为一个引用而存在,但是,它只是代表了组件的引用,并没有代表组件的 API,所以很多人使用 ref 就会遇到被 HOC 拦截的问题,而且,使用 ref 还会存在私有 API 有可能被使用的风险,所以,对于大多数场景,其实我们只是需要一个可以脱离于单向数据流场景的 API 管理机制,这样一想,其实就很简单了,我们完全不需要用 ref,自己几行代码就能实现:

class MyComponent extends React.Component {

这就是最原始的类似 ref 的 API,在使用组件的时候,我们只需要

const actions = {}

就这样的方案,完全不会被 HOC 给拦截,也不会出现私有 API 会被使用的风险,但是,这个方案是用于外部—>内部的数据流通讯,那么,内部—>外部的数据流通讯又该是怎样的呢?我曾想过就基于原本的 onXXX 属性模式,在组件 props 上暴露出各种响应事件 API,但是,这样一来,就又会出现我前面提到过的逻辑过于分散导致代码可维护性降低的问题,参考 redux 设计模式,它的核心亮点就是:将 actions 收敛扁平化,将业务逻辑收敛聚合到 reducer 上,所以,我们也需要一个收敛聚合业务逻辑的容器来承载,这样既能提升架构的清晰度,也能提升代码可维护性。

最后,通过大量的探索实践,我们发现,rxjs 是很适合事件逻辑的收敛聚合的。所以,我们可以大致的实现这样一个原型

class MyComponent extends React.Component {

所以,我们最终使用的时候,只需要

const actions = {}

就这样,我们实现了组件的 API 与事件收敛的能力,当然,对于一个大型应用,我们可能会有很多组件,同样也可以以类似的模式进行管理状态:

const actions = {}

我们完全可以共享同一个 actions 引用与一个 effects 处理器,更进一步,我们可以把 actions 与 effects 以独立 js 文件做管理,这样一来,effects 就像 redux 的 reducer 一样了,但是,它比 redux 能力更加强大,因为结合 rxjs,它天然具有解决各种时序型异步问题的能力。相反 redux 则得借助 redux-saga 之类的方案来处理。

好了,前面的都是原型,我们可以将这部分逻辑做进一步抽象,最终,便成了 react-eva 。

沉淀

就这样,我们的表单解决方案的三大要素可以改为:

JSON Schema(JSchema) + 字段分布式管理 + React EVA

所以,UForm 诞生了,它就是严格按照以上思路设计出来的,欢迎大家尝鲜!有问题尽管吐槽吧!

 相关链接
  • https://github.com/alibaba/uform

  • https://github.com/final-form/react-final-form

  • https://github.com/mozilla-services/react-jsonschema-form

 活动推荐

“1 分钟发现问题,5 分钟定位问题,10 分钟解决问题”,ArchSummit 深圳 2019 全球架构师峰会上,阿里巴巴技术专家彭伟春,将系统化介绍小程序端到端全链路监控的原理和实战,以及如何尽可能低成本一套代码监控各大小程序。更多内容,请点击阅读原文了解。

62bb8d485c934afe30c789a6303b4670.png

6776491baeea9a7a3e989446c7eebbd3.gif

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值