面向复杂场景的高性能表单解决方案

通过3年的洗礼,由阿里供应链平台前端团队研发打造的UForm终于发布!🎉🎉🎉
UForm 谐音 Your Form , 表明,这才是你想要的Form解决方案
高性能,高效率,可扩展,是UForm的三大核心特点
查看完整文档请戳 alibaba.github.io/uform

源起

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

  • 阶梯规则,List形式
  • 每一层阶梯内的同级字段之间有动态联动,好比:字段A变化,会控制字段B的显示隐藏
  • 每一层阶梯内的同级字段之间有联动校验,好比:字段B的值必须大于等于字段A的值
  • 层级与层级之间的字段有联动校验,好比第二阶梯的字段A的值要大于第一阶梯字段B的值

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

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

问题

1. 性能问题

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

cosnt App = ()=>{
  const [value,setState] = useState()
  return (
      <div>
          <ComponentA>
           <ComponentB value={value}/>
          </ComponentA>
          <button onClick={()=>setState('changed')}>改变value</button>
      </div>
  )
}复制代码

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

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

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

因此,不少表单解决方案就开始听任React全局rerender,就像只要知足官方推荐的单向数据流方式就是一种政治正确同样。设计模式

2. 代码可维护性问题

说到代码可维护性,咱们须要判断,什么样的代码才是可维护的。能够大体总结一下,通常具备可维护性的代码都有如下特征:性能优化

  • 代码风格是与团队代码风格一致的,这个能够经过eslint作约束
  • 代码是模块化的,不该该一个文件包含全部业务逻辑,必须作物理拆分
  • 逻辑是分层的,Service是一层,View是一层,核心业务逻辑是一层,能够参考MV*,每一层不该该掺杂其余层的职能
  • 视图是组件化的,将通用视图交互逻辑层层抽象封装,进一步简化核心视图复杂度

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

  • 要求使用到表单的业务组件统一使用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的问题:

  1. 联动不能一处编写,单纯calculator不能处理状态的联动,好比字段A的值变化会控制字段B的disabled状态,必须结合jsx层的Field subscription才能作状态联动,用户须要不停的切换写法,开发体验较差,好比:codesandbox.io/s/jj94wojl9…
  2. 嵌套数据结构须要手动拼接字段路径,好比 codesandbox.io/s/8z5jm6x80
  3. 组件内外通信机制过于Hack,好比在外部调用Submit函数 codesandbox.io/s/1y7noyrlm…
  4. 组件外部不能精确控制表单内部的某个字段的状态更新,除非使用全局rerender的单向数据流机制。
  5. 不支持动态化表单渲染,仍是须要在上层创建一个动态渲染引擎

探索&创新

由于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的描述语言,其实很简单:

<Field type="Object" name="aaa">
   <Field type="string" name="bbb"/>
   <Field type="array" name="ccc">
      <Field type="object">
          <Field type="string" name="ddd"/>
       </Field> 
   </Field>
</Field>
​
//========转换后===========
{
   "type":"object",
    "properties":{
        "aaa":{
            "type":"object",
            "properties":{
                "bbb":{
                    "type":"string"
                },
                "ccc":{
                    "type":"array",
                    "items":{
                        "type":"object",
                        "properties":{
                            "ddd":{
                                "type":"string"
                            }
                        }
                    }
                }
            }
        }
    }
    
}复制代码

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

JSON Schema属性扩展

由于JSON Schema本来是用于描述数据的,若是直接用在前端里,将会丢失不少与UI相关的元数据,那么这些元数据应该怎样来描述呢?Mozilla的解决方案是专门抽象了一个叫作UI Schema的协议专门来描述表单的UI结构,能够看看 github.com/mozilla-ser…。看似是将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 {
    constructor(props){
        super(props)
        this.state = {
            data:{}
        }
        if(props.actions){
            props.actions.getData = ()=>{
                return this.state.data
            }
            props.actions.setData = (data)=>{
                this.setState({data})
            }
        }
    }
}复制代码

这就是最原始的相似ref的API,在使用组件的时候,咱们只须要

const actions = {}
<div>
   <MyComponent actions={actions} />
    <Button onClick={()=>{
            actions.setData('hello world')
    }}>设置状态</Button>
</div>复制代码

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

最后,经过大量的探索实践,咱们发现,rxjs是很适合事件逻辑的收敛聚合的。因此,咱们能够大体的实现这样一个原型

class MyComponent extends React.Component {
    constructor(props){
        super(props)
        this.state = {
            data:{}
        }
        if(props.actions){
            props.actions.getData = ()=>{
                return this.state.data
            }
            props.actions.setData = (data)=>{
                this.setState({data})
            }
        }
        if(typeof props.effects === 'function'){
            this.subscribes = {}
            props.effects(this.selector)
        }
    }
    
    selector = (type)=>{
        if (!this.subscribes[type]) {
          subscribes[type] = new Subject() //rxjs的核心API Subject
        }
        return subscribes[type]
    }
    
    dispatch = (type,payload)=>{
        if(this.subscribes[type]){
            subscribes[type].next(payload)
        }
    }
    
    render(){
        return <div>
             {JSON.stringify(this.state.data)}
             <button onClick={()=>dispatch('onClick','clicked')}>点击事件触发</button>
        </div>
    }
}复制代码

因此,咱们最终使用的时候,只须要

const actions = {}
const effects = ($)=>{
    $('onClick').subscribe(()=>{
        actions.setData('data changed')
    })
}
<div>
   <MyComponent actions={actions} effects={effects} />
    <Button onClick={()=>{
            actions.setData('hello world')
    }}>设置状态</Button>
</div>复制代码

就这样,咱们实现了组件的API与事件收敛的能力,固然,对于一个大型应用,咱们可能会有不少组件,一样也能够以相似的模式进行管理状态:

const actions = {}
const effects = ($)=>{
    $('onClick').subscribe(()=>{
        actions.setData('data changed')
    })
}
<div>
    <MyComponentA actions={actions} effects={effects} />
    <MyComponentB actions={actions} effects={effects} />
    <MyComponentC actions={actions} effects={effects} />
    <Button onClick={()=>{
            actions.setData('hello world')
    }}>设置状态</Button>
</div>复制代码

咱们彻底能够共享同一个actions引用与一个effects处理器,更进一步,咱们能够把actions与effects以独立js文件作管理,这样一来,effects就像redux的reducer同样了,可是,它比redux能力更增强大,由于结合rxjs,它自然具备解决各类时序型异步问题的能力。相反redux则得借助redux-saga之类的方案来处理。

好了,前面的都是原型,咱们能够将这部分逻辑作进一步抽象,最终,便成了 react-eva

沉淀

就这样,咱们的表单解决方案的三大要素能够改成:

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

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

广告

阿里供应链平台前端,持续招人中… 欢迎简历投递 zhili.wzl@alibaba-inc.com

相关文章
相关标签/搜索