阅读 831

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

经过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实现,写了很多很多重复而复杂的面条代码,包括,表单的数据收集,字段校验等等,代码可维护性极低,最终迫使我开始真正深入表单领域,探索最佳的表单解决方案。

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

问题

1. 性能问题

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

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属性。

就是说,用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的问题:

  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


关注下面的标签,发现更多相似文章
评论