干货高能预警,此文章信息量巨大,大部分内容为对现状问题的思考和现有技术的论证。 感兴趣的朋友可以先收藏,然后慢慢研读。此文凝结了我在中台领域所有的思考和探索,相信读完此文,能够让你对中台领域的常见业务场景和解决方法有着全新的认知。
此文转载请注明出处。
在2019年5月11日的那个周末,我在FDCon 2019大会上进行一次有关中台领域的分享,分享的标题是《业务实现标准化在中台领域的探索》,并在现场发布了RCRE这个库,并介绍了如何使用RCRE来解决中台业务开发所面临的各种问题。
会后看了一些同学的吐槽,可能是我分享方式的问题,使得当时并没有详细的阐述RCRE产生的背景和原因,以及当时所实际面临的痛点,而是仅仅去介绍如何使用RCRE了,难免也被冠以出去打广告的嫌疑。
RCRE的诞生并不是一蹴而就,而是我在这个领域多年摸爬滚打的精华。它每一行代码都凝结着我从深坑中跳出来之后的思考,是下文介绍了所有问题和场景的解决方案。
初次公开分享难免会经验不足,对在场观众的需求把控不清晰。现场演示代码,可能并不能充分体现出这些API产生的背景和原因。所以为了满足当时大家的需求,所以这篇文章,不讲代码,只讲思考和论证,来介绍当时我在中台领域所面临的问题,以及我针对这些问题的看法和思考和最后我为什么要在RCRE中设计这样的功能来解决这些问题。
更完美的状态管理方案
过去的几年,中台领域出现了很多非常优质的UI组件库,比如Ant.Design, Element-UI等,这些组件库解决了过去前端工程师所面临的还原设计稿成本高的问题,通过采用统一的设计风格的UI组件,就能让前端工程师无需再专注于切图和写CSS,而是更专注于页面逻辑的实现。
在页面逻辑的实现层面,UI组件的状态管理也有了很大的发展。随着Flux的提出,再到Redux,Mobx等,使用一个状态管理库来管理一个应用的状态已经成为了前端主流,甚至在最新的React中,还会有UseReducer这样源自Redux的API出现。
而对于状态管理,社区也衍生出两种完全不同的思路。
状态管理的两极分化
一种是以Redux为主导的不可变数据流的方案,通过让整个应用共享一个全局的Store,并且强调每一次数据更新都要保证State完全不可变,以及完全避免使用对象引用赋值的方式来更新状态这样的方式来保证对页面的数据操作,让整个应用具备可追溯,可回滚,可调试的特性。这样的特性在面对代码如山一样的大型复杂应用,有着非同一般的优势,能够快速来定位和解决问题。
不过Redux这样的模式也存在一定的弊端,首当其中的就是它要求开发者要完全按照官方所描述的那样,写大量的Action,Reducer这种的样板代码,会让代码行数大量膨胀,使得开发一个小功能变得非常繁琐。使用单一的State来管理就需要开发者自己去完成State的结构设计,同时不可变数据状态管理仅仅是Redux所强调的一种思想和要求而已,由于并没有提供有效避免对象引用赋值的解决方案,就需要开发者时刻遵守这种模式,以免对不可变造成破坏。
因此Redux这种设计模式固然有效,但是过于繁琐和强调模式也是它所存在的弊端。
而另外一种则是与Redux完全相反的思路,比如Mobx。它鼓励开发者通过对象引用赋值来更改状态。Mobx通过给对象添加Proxy的方式,获得了每个用户每个React组件所依赖的属性,这样就拿到了对象和组件之间的属性映射关系,这样Mobx就能依据这些依赖关系,自动实现组件的更新。使用Mobx之后,State的很多细节都交给Mobx进行管理,也就不会有Redux那种State设计的工作了,同时也就不存在像Redux那样,编写大量的样板代码,而是直接修改状态数据就能达到预期的效果。
Mobx这种的思想和Vue的机制非常类似,同时也都存在同样的一个弊端——由于没有状态的副本,无法实现状态的回滚。数据之间的关系捉摸不清,更新的实现完全被隐藏在Mobx内部,对开发者不可见,当状态复杂之后,就会造成调试困难,Bug难以复现的问题。
Mobx这种设计模式能在早期能极大提升开发效率,但是在项目后期就会给维护和调试造成一定的困难,造成效率的降低。
可见,在状态管理不可变数据和可变数据都各有各的优缺点,貌似是鱼和熊掌不可兼得。那么问题来了,是否存在一种新的技术方案,能够结合Redux和Mobx的优点呢?
有关Redux和Mobx之间对比的详细的内容,可以继续看这篇文章:https://www.educba.com/mobx-vs-redux/
简单而又可靠的状态管理
在大型复杂应用开发这种场景下,Redux可靠但是不简单,Mobx简单而又不可靠,因此就需要找到一种简单而又可靠的状态管理方法。
Redux的可靠在于它能够让状态可回溯,可监控,使用单一的状态能降低模块太多所带来的复杂度。Mobx的简单在于它使用方便,对写代码没有太多要求,也不需要很多的代码就能实现功能。
对于大型复杂应用来说,状态可回溯,可监控这些特性是重中之重,有了它才能让整个应用不会因为太复杂而失控。因此优化的方向就被转化为:能否借鉴Mobx这种简单易用的思想,来降低Redux的使用成本。
在使用单向不可变数据流这种背景下,降低Redux的使用成本需要往以下三个方面发力:
- combineReducer的使用会让开发更繁琐,因此需要避免每次开发都需要进行State结构设计
- 每一次数据操作都要写Action,Reducer也会让开发更繁琐,因此需要避免编写大量的Action,Reducer
- 不是所有人写的Reducer都能保证State修改不可变,因此需要一种替代方案来修改State
针对以上三个方面,我认为可以采取以下方法来进行解决:
- 利用将组件之间的结构关系映射到State,就能在一开始就推断出State的结构,进而自动帮助开发者完成combineReducer这样的操作。
- 将多个Action进行合并,为开发者直接提供通用Action的方式,多个Action之间利用参数来进行区分,以解决Action过多的问题。
- 为开发者封装状态操作的API,在内部实现不可变的数据操作,避免开发者直接接触到State。
有了上面三个基本的思想,接下来就是要思考如何才能够和现有的Redux架构进行整合。
像Mobx一样去使用Redux
首先,第二个和第三个方法可以被整合成一个API——一个通用,保证状态不可变的状态修改API。这样就和Mobx直接改了数据状态就更新的操作很相像了——调用这个API就把修改状态搞定了。
而对于第一点,熟悉react-redux的同学都知道,Redux中的State,是通过编写mapStateToProps函数来将状态映射到组件的Props上的。而mapStateToProps函数的参数却是整个Redux的State,想要将它映射到组件中,还需要完成从State取值的操作。而当我们在一开始设计状态的时候,依然需要去想个名字来完成整个状态的结构设计,前后一对比,仔细想想后会发现,这一前一后都是需要一个Key才能完成,为何不用同一个Key呢?这样一个Key既可以完成Redux的State中,每一个Reducer的划分,也可以完成mapStateToProps的时候,属性的读取。
所以我们只需要将这个的一个Key放到一个组件的属性上,通过组件的挂载来完成过去需要combineReducer才能完成的状态划分,然后再mapStateToProps的时候,同样依据这个属性,完成State到Props的映射。而且通过这样的方式,整个State的结构都完全可以在组件上进行控制了,也就不需要再去使用combineReducer这样的API了。
通过上述的讲述的方法,我们就可以将它们封装起来,做成一个React组件,让这个组件来帮助我们管理状态,并且通过这个组件的API来修改状态。这也就是RCRE中,Container组件背后的思想。
Mobx的简单不光光在于开发者不需要思考如何去更新和管理状态,它还有一个很大的优势在于,你可以再任何一个地方都可以直接去修改状态。相比目前Redux中,一些值和函数都采用props进行传递这种繁琐的方式,Mobx这样的功能会让人感觉方便不少。
因此,即使现在有了Container这种可以帮助我们自动管理状态的组件之外,我们还需要一种类似于Mobx这样,可以绕过props也能传递数据和方法的设计。
React在16版本推出了新的Context API,这也是所官方推荐的一种跨props传递数据的解决方案。因此我们可以利用这个API,来实现在Container组件内部的任何一个地方,都可以自由读取状态和修改状态。也就是RCRE中,ES组件背后的思想。
总结一下,解决Redux使用成本高的问题的核心就在于,找出那些可以被重复利用,差异性不是特别大的地方,再加以封装,就能得到非常不错的效果。
总结来看的话,整个模型就可以使用下面的图来进行概括。
解决组件联动所带来的复杂性
写过中台类型系统的人都知道,凡是涉及到组件联动的需求,项目排期一定很长。因为一旦页面中的组件有了关系,那么就得花费大量时间来处理每一次联动背后,每个组件的更新,组件的数据状态创建与销毁,稍有不注意,就有可能写出联动之后组件没有正确更新,或者是组件销毁数据没有销毁的Bug。
当每天维护的就是这样一个包含数不清的联动关系的大型系统,每一个Bug所带来的损失都不可估量的时候,这背后的难度也就可想而知了。
组件联动的本质
组件联动本身并不复杂,我们可以把它简单描述为:当一个组件更新之后修改全局的状态,其他组件需要根据状态来做出相应的反应。同时组件修改状态并不一定是同步操作,它还有可能是异步的操作,比如调用一个接口。组件修改状态它仅仅是一个单向的操作,是很容易被理解的,而大家都觉得开发带有组件联动的功能很复杂的原因是在于,当这个组件完成了状态更新之后,究竟有哪些组件会因此而联动,将是一件很复杂的事情。
单向数据流思想的价值所在
在过去MVC架构的应用中,这样的场景是非常难以处理的。因为组件与组件之间的通信是通过发布订阅这种模式进行的,当组件之间关系复杂之后,就会形成一种网状的依赖结构,在这种结构下,暂且不说能不能理清它们之间的关系,光是可能出现的环形依赖所造成的死循环,就已经让开发者抓狂。
React的单向数据流思想,我认为就是应对这种问题最好的方法。因为在单向数据流的架构下,组件之间的关系从过去的网状结构,转变成了树状结构。在树状结构模型下,组件与组件之间只存在,父子关系与兄弟关系这两种情况,而且还没有环形依赖。这就大大简化了关系复杂所产生的一系列问题,让整个组件结构一直都能保持稳定。
每个组件都要管好自己
接下来就是要思考,当一个组件更新的时候,该如何去更新其他组件了。
当场景很复杂的时候,我们是很难搞清楚一个组件的更新究竟要触发哪些组件,那么最好的办法就是让每个组件自己主动对当前的情况做出反应。
这也就是不难理解,React为每个组件都提供了生命周期函数这样的功能了。当组件开始联动的时候,我们不需要分析出一个组件究竟需要影响哪些组件,而是让每一个组件都管好自己就好了,就像父母和老师经常就对孩子说,管好你自己,你已经是个大人了。
通过一个组件的触发,来带动组件父级的更新,父级再进而带动其所有组件的更新,然后每个子组件更新的时候去检查数据并作出相应的反应,就能以可持续的方式来实现组件联动。
通过结合生命周期和组件状态来提升效率
当组件被其他组件所影响的时候,组件大致分为三种不同的状态:
- 组件挂载
- 组件更新
- 组件销毁
一个完备的业务组件,想要去支持被其他组件联动触发的话,除了组件的基础渲染结构,还是需要在以上三个方面添加针对这个组件的一些实现。不过当系统中有很多很多的组件的时候,反复为每个组件都实现上诉三个方面的功能,就显得有些重复性劳动。
因此我们就需要想个办法不去单独为每个组件都编写这些逻辑,而是寻找到一种更为通用的方法。首先,我们需要先对这三个方面的功能进行更为细致的分析。
组件的挂载的时候,除了要初始化一些私有的数据和状态之外,可能和其他组件产生影响的就是这个组件的默认值了,当组件初始化的时候,就要立刻将组件初始化写入到状态中,来完成一些特定业务需求所需要的初始默认值。
当组件被更新的时候,如果整个组件渲染的数据完全是来自于props,是个完全的受控组件的话,正常情况下是不需要做任何处理的。
当组件被销毁的时,如果业务有需求,是需要自动将这个组件所带有的状态也一并在状态中删除。
通过以上分析,可以看出,在生命周期内所实现了和状态有关的操作,都是对某个指定的Key执行新增或者删除相关的操作。所以要想提升效率,就只需要将这个Key也作为组件的一个属性,然后就可以在底层实现通用的挂载逻辑和销毁逻辑,实现简单的配置就完成了生命周期和组件状态的整合。
这些思考,都可以在RCRE中的ES组件中找到对应的实现:
- 执行状态操作的Key: name属性
- 组件初始化的默认值: defaultValue属性
- 控制组件是否需要销毁时自动清除数据: clearWhenDestory属性
接口调用的通用模式
接口调用在常规的中台应用中很常见,任何涉及增删改查的应用都是需要依赖一些后端接口。
在一些简单的场景,你可能只需要在某个回调函数内调用fetch就能拿到接口的数据。
不过对于较为复杂的场景和中大型应用,接口的调用就更需要规范化。因此才会有利用Action来调用接口的方案出现。不过当场景越来越复杂,比如一个Action调用多个接口这种情况,redux-thunk这种简单的方案就会显得力不从心,因此社区又出现了redux-saga这种可以支持多接口并行调用等更高级的库出现。不过redux-saga的学习成本并不低,甚至关于什么是saga,还专门有一篇论文来解释,耗费这么多精力来学习各种库和概念,等真正要在业务中实际应用的时候,还是一头雾水。没有任何开发经验的同学,依然很难处理好如何调用接口这个问题。
因此关于异步获取数据,我认为需要用一种更为简单傻瓜的设计,提供一种能够覆盖多种业务场景的统一方法,来帮助开发者快速理解并完成它们需要的功能。
和接口相关的常见业务场景
针对这样的问题,从业务角度来进行思考是一个非常不错的方向,在这个方向努力,就能实现快速解决业务中那些常见场景下的功能需求。
首先,需要来分析一下,在中台系统中,和异步获取数据相关的一些常见功能:
- 由各种参数和条件触发的查询
- 页面一开始初始化所需的数据
- 组件联动时需要的数据
- 并行调用无依赖的接口
- 串行调用相互依赖的接口
以上的三个方面,几乎就囊括了常规那些中台业务需求中除了表单验证之外需要接口的场景了。接下来,就是要从这些功能中,找出它们的共同点,这样才能做出更为通用的设计,来应对不同需求变更所带来的不确定性。
接口参数和接口触发的关系
对于不一样的业务功能,接口参数和组件的触发的时机是可变的,它取决于当前业务所需要的字段和每个UI组件所触发的回调函数。不变的是每一次接口的请求,都将伴随着组件的更新,毕竟拿到接口数据之后,必然要更新组件才能将接口数据传递给其他组件。
因此对于第一类功能,不管页面中的组件是如何变化,只要这个组件能够触发接口,那么它必然会影响到接口请求的参数,否则没有参数变更的请求是不会满足与当前的业务需求的。因此关键点就在于参数的变更和请求接口之间的关系:
参数变化,触发接口
参数不变,不触发接口
恰好的是,任何状态的更新都将触发容器组件的更新,进而更新整个应用的组件。因此我们可以利用这样的一个特性 —— 在容器组件上挂载钩子来自动触发接口,并且在请求之前,读取最新的状态来动态的去计算接口的参数,进而判断出是否需要触发接口。
因此我们就可以很巧妙的设计出这样的一套触发流程:
各种不同的操作更新了状态 --> 容器组件更新 --> 重新计算接口参数 --> 判定并触发接口
接口初始化多样性所带来的问题
对于第二类功能,在最简单的情况下,页面初始化的时候,它所依赖的接口是无条件触发的。但是现实并不是如此,因为某些接口的初始化是存在条件的,它可能是依赖某个组件的数据,也有可能是依赖某个接口。
不过在日常业务开发中,只有最简单的场景下,接口的调用是放置在componentDidMount这类生命周期内部,其他带有条件的接口初始化调用,是无法放置在componentDidMount内部的,而是分散在其他地方。坏的情况就是被放置在某个组件的回调函数内,等接口调用完再执行下一次操作,好的情况就是会封装一个Redux middleware, 通过全局拦截的方法来调用。
仔细想想的话,就会发现这样的做法会有很多弊端,第一点是接口的调用不够集中,它是分散的,这样就会给大型应用的代码管理造成很大的障碍。第二点是接口的调用都需要一个特定的前置条件,这样的前置条件可能是取决于代码调用的位置,也有可能是来自于一大堆if else的判断,这些都对如何管理和组织接口造成了很大的难题。
不过如果我们将视野放宽,从关注如何去调用一个接口,放大到组件的状态和接口之间的关系,就会发现此类问题,都能使用上面所推导出的触发流程来解决。
通过将能够触发接口请求的数据都存入到State中,并且在每个接口上添加一些触发的附加条件,就能复用上面那个触发流程模型:
普通组件挂载 --> 组件初始化数据 --> 状态更新 --> 容器组件更新 --> 接口判定是否满足请求条件 --> 重新计算接口参数 --> 判定并触发接口
这样的话,我们就可以使用同一种机制和模型,来完成第一类和第二类场景下有关接口的需求。
复杂的组件联动所造成的开发成本剧增
组件联动是中台领域中一个比较复杂的场景了,因为它涉及一个组件的数据变更对其他组件的状态影响。
当页面中一个组件的数据发生了变更,如果有一些组件和这个组件存在联动的话,那么所有涉及的组件都将所有反应,反应的行为通常包括新组件的挂载,现有组件的更新,以及组件的销毁等。组件之间的联动关系并不是固定的,而是完全取决于当前的业务逻辑。如果在如此复杂的组件关系中,还需要去调用新的接口,例如需要请求接口来为新出现的下拉选项组件提供数据,那么在何处调用这个接口,就又是一个值得推敲的问题了。
组件联动之后去调用接口,并不是仅仅在新组件的componentDidMount中写入接口调用那么简单,因为这个接口调用,不一定是在当前组件挂载完毕之后就满足请求的条件,有可能新的接口调用,是需要两个以上的接口都完成挂载并初始化数据之后才能发起请求。这样的话,接口的调用就只能被移植到状态更新之后,然后再单独编写一些判定才能解决。
从此可见,组件的联动和特定的接口触发条件会急剧增大完成需求的难度。如果我们将上面所介绍的机制拿来和现有的场景进行对比后发现,组件的联动带来的接口触发,也只不过是个纸老虎。
组件的联动,必然会涉及状态。不管是一对一的联动,还是一对多的联动,都离不开背后对组件状态的修改。状态能够时刻反映出当前组件的情况。
因为组件的联动只不过是多个组件状态的变更,所以我们依然可以采用上面所介绍的模型来解决这样的一类问题:
A组件被触发 --> 状态更新 --> B组件和C组件做出反应 --> 状态更新 --> 容器组件更新 --> 接口判定是否满足请求条件 --> 重新计算接口参数 --> 判定并触发接口
这样的话,我们就可以使用同一种机制和模型,完成一二三类场景下有关接口的需求。
如何处理接口之间的关系
当应用复杂起来之后,不光组件之间存在很多的关系,接口与接口之间也是。而每一个接口的请求,都是需要消耗一定的网络时间。但是接口与接口是否存在关联,是完全取决于当前的业务需求和数据现状。当接口触发的条件并不是来自于其他接口返回的数据,我们可以认为接口与接口之间不存在关联。
如果不使用任何async await或者是redux-saga这样的工具的话,在一个函数内调用多个接口很容易出现callback hell的情况,也给接口的管理造成一定的负担。
但是,如果我们仔细研究的话,会发现每个接口在最后,都会将返回数据或者一部分写入到状态中。那么如果我们给每一个接口进行命名,让接口返回之后,将返回数据写入到这个名字为Key的值中。那么就可以直接在状态中通过判定这个名字是否存在来判定接口已经成功返回,这样就和判断其他组件的值是否在状态中没有任何区别。
有了以上的基础,那么判定接口是否返回就和判定组件一样简单,因此就可以将它囊括到接口判定是否满足条件中去。
总结
要想将调用接口这么复杂的事情做到傻瓜化,就需要找出不同场景下,这些操作的共同点,找出共同点就能设计一个通用的模型来解决一系列的问题,实现应对多种不同场景下的接口需求。这也是RCRE中Container组件的DataProvider功能的背后的思想。
流程式任务管理
在中台系统中,还有一类特殊的业务功能是很难被一种模型所概括的——由用户行为所触发了线性交互逻辑。
这种类型的业务功能有一些比较明显的特点:
- 它并不复杂,通常是一连串操作的组合
- 它由用户行为所触发,也可能会涉及一些连续交互的功能。
- 完全由业务逻辑所主导,并没有太多的共同点
通常情况下,这类逻辑就大量分散在系统的各个组件内部,看上去像是某些事件的回调函数。但是随着需求的不断迭代,就会让组件变得非常膨胀,以至于会影响整个组件的可维护性。
由于每个功能都是完全按照需求所定制化开发的,在一些常见业务功能都被高度封装的情况下,多个功能之间的衔接,依然需要工程师人工编写代码来进行完成。
这样就会造成一个问题——功能的复用程度并不是特别高,因为有相当一部分的代码都是胶水代码,是无法被复用的。所以想要提升整体的代码复用性,就需要去思考,如何才能减少胶水代码的开发。
分析交互逻辑的内部细节
倘若仔细去分析之后就会发现,组合通用逻辑的胶水代码,不管是执行同步的操作还是异步的操作,它都是以线性的方式去执行,相比组件与组件之间的关系来说,交互逻辑这类代码的结构都比较简单,它们都是在上一个操作完成之后才能去执行下一个操作,当中间遇到了一些执行错误或者异常时,都是退出这个操作就结束了。
task1 --> task2 --> task3 --> task4
所以,这个问题就可以被转变为如何找到一种能够去结构化同步或者异步操作的机制。
多个异步操作可以使用Promise进行串行调用,同步的操作也可以被包装成立刻返回的异步操作。所以可以使用Promise来将异步和同步之间的差异进行打平。
串行调用在程序的世界中是非常常见的操作,例如reduce函数,就是一个非常好的例子。如果能够将每一个操作的调用,放置在一个数组中,那么就可以使用一次调用,来进行批处理操作。
批处理的数据来源
对于每个交互逻辑来说,它都需要读取一些参数来完成它的工作。比如发起请求需要参数,弹出确认框需要提示信息,数据验证需要输入数据。这些操作的数据来源有可能是来自于用户触发事件时的事件对象,也有可能是来自于当前整个应用中状态的数据,也有可能是来自于上一个操作的返回值。
所以如果要做这样的一套批处理机制,让每一个操作都能很顺畅的运行的话,那么封装所有来源的数据就是一件很有必要的事情了。
因此就需要在调用每一个操作所封装的函数之前,把当前所有的数据信息都收齐起来,组装成一个对象传入到函数中,来满足不同的业务需求所需要的数据。
每一个操作在执行的过程中,都有可能读取以下来源的数据:
- 上一个操作的返回值
- 事件触发的时候,传递的值
- 全局应用的状态
当然,批处理还需要具备错误能力——当任何一个操作返回的异常,整个操作就会直接被终止。
配置聚合和控制中心
任何零散的事物要想有组织的进行工作,就必须要有控制中心。
在过去,处理交互逻辑是非常的分散的,即使现在有了类似于reduce的批处理操作,如果它依然是散步在一些不为人知的角落,这依然无法解决分散所导致的混乱问题。所以我们还需要将批处理的配置聚合在一起,并放置在最显眼固定的位置,让每一个人都知道想要找到这段逻辑是如何工作的,就需要看这里就够了。
因此就需要思考,这样的一个包含所有操作的信息的控制中心,应该放置在哪里比较好。
页面中的组件,都是以树状的结构进行组织的,那么不管这个页面中组件的数量有多大,这些组件一定都会有一个最顶层的父级组件。所以这个站在金字塔最顶层的组件,就是放置控制中心的最佳选择,怎么看起来感觉和现实世界中的情况差不多(笑。
而在React应用中,直接和状态通信的容器组件,就是聚合配置信息的组件了。也就是为什么在RCRE中,Task功能是作为Container组件的一个属性的存在。
而流程式任务管理,正是RCRE的任务组功能背后的思想,通过这样的一套机制,就能过去分散的交互逻辑,有迹可循,易于调整。
更便捷的表单验证
表单一直都是中台领域中开发成本高的代表。它含有数不清的交互场景,也是业务需求最频繁改动的重灾区。
实现单个表单验证并不是很难的一件事情。表单验证的目的就是要去验证用户输入的组件数据,通过验证数据的合法性来给予用户一些反馈。因此表单验证就只有2个功能,第一是组件数据的改变触发验证,第二是将验证结果反馈给用户。
页面中的数据是多变的,实现一个全面的数据验证功能,光在组件的onChange事件内添加钩子来触发验证是远远不够的,因为组件的数据不光来自于自己,还有可能会来自于其他组件。除此之外,针对页面输入框这种特殊的组件,触发表单验证还有onBlur事件这样特殊的交互。
因此实现数据的验证功能就需要围绕三个方面来开展,第一是onChange事件的触发,第二是组件所读取的数据发生改变时触发,第三是onBlur事件这种特殊场景。
而对于页面中的结果反馈,因为它涉及到组件的渲染,所有是需要通过一个统一的状态来进行控制,这样才能通过组件渲染到页面上,进而给予用户提示。
所以总结来看,实现一个组件的验证功能不光光是一个简单的数据校验逻辑,而是要去完成以下的工作:
- 对数据的校验逻辑
- onChange事件钩子
- onBlur事件的钩子
- 组件更新时对数据变更的判断
- 存储表单验证状态的State
- 展现错误信息的组件
以上就是完成一个组件验证所需要的工作了,但是这并不是最烦人的地方,最让开发者头疼的,是以上这些工作,每个需要被验证的组件都要完成,那么需要去写的代码可就多了去了。
利用状态来驱动表单验证
仔细观察这些触发表单的场景之后会发现,上诉2,3,4点的是业务中最常见的应用场景,同时这三点的背后,也和状态的更新完全保持一致。因为无论是onChange事件还是onBlur事件,还是对数据变更的判断,都是先有组件的状态变更,再有的验证,因此充分利用这个特性来节省工作量,就是解决2,3,4这三类问题的突破点。
表单验证和组件状态变更是同步变更的,那么只需要在组件变更的不同生命周期和回调函数内,添加触发表单验证逻辑的钩子,就能很好的让表单也跟着组件的状态一起变化。
表单验证的常见业务场景
通过上的分析和方法,表单验证可以被状态自动触发,所以我们可以把通过状态来触发表单验证所有的场景都列举出来:
- 组件触发onBlur事件来触发验证
- 组件触发onChange事件来触发验证
- 通过一个接口来验证数据
- 组件被其他组件所联动来触发验证
- 特殊验证场景,比如特定的验证逻辑
- 组件被删除也要同步清空组件的验证状态
同时,除了和状态之间的关系,表单还有一些它所特有的场景:
- 通过点击提交按钮,在发送请求之前触发所有组件的验证
- 跳过被禁用按钮的验证功能
- 多组件之间的验证相互互斥
同时表单的禁用特性,还会和组件联动有关:通过一个组件,来控制另外一个组件的禁用属性,进而操作验证状态。
提供表单特有场景下的支持
根据以上的分析,表单有三个特有的场景需要被支持。对于第一个场景,点用户点击了提交按钮的时候,最外层的Form组件会触发onSubmit事件,因此可以为开发者提供一个封装好的回调函数给开发者使用。在这个回调函数内部,需要去依次去触发每个组件的验证功能,来进行全局的校验,来确保提交的时候,每一项都验证通过。
在表单中,被禁用的组件是不需要验证功能的,因为用户无法更改组件的输入,那么验证也就没有了意义,因此还需要专门监控组件的disabled属性以便当组件被设置为禁用的时候,立刻充值组件的验证状态。
对于组件验证互斥这种特殊的验证逻辑,我们可以将它看作是一种将组件状态和验证状态进行整合的功能。因为要想实现验证互斥,就必须要去读取其他组件的验证状态,并将自身取反,因此就只需要给开发提供一个可以自定义扩展验证的功能就足以,具体的专门逻辑实现交给开发者处理。不过这里需要注意的是,在提供自定义验证的同时,还要给开发者提供读取全局状态的能力,因为实现这种功能不仅要读取自身的数据,而是要读取来自其他组件的数据,这是一个需要注意的地方。
在RCRE中,<RCREFormItem />组件都已经完全具备此类功能,能够自动帮助开发者完成那些由各种状态变更而触发的表单验证场景。
表单自身的私有状态
由于表单也需要来存储当前的验证信息和错误信息,因此表单也需要和组件的一样,需要持有一些状态。
因此想要节省开发表单时,验证和错误信息的开发工作量,就需要为开发者提供一个通用的状态存储功能。同时表单的状态并不是类似于组件的状态那种,会有联动的功能,每个组件的验证都是相互独立,只为当前组件所负责。
因此就可以直接使用React State这种轻量级的状态管理功能来完成组件验证状态的持有,通过将其封装成一个React组件,就能方面开发者进行使用,这也就是RCRE中RCREForm />组件背后的思想。
除了一个存储整个表单状态的组件,每个组件的验证状态还需要实时同步到<RCREForm />这样的统一存储区域。因此就需要像上文所介绍的<Container />和<ES />组件通讯的机制类似,采用React Context API来实现组件验证状态和<RCREForm />之间的通讯,以完成表单验证状态的同步,这就是RCRE中<RCREFormItem />组件背后的思想。
有了这两个机制,开发者就不需要手动去编写实现来维护表单的验证状态了,所以对于上述第五点和第六点所带来的重复性工作也就迎刃而解。
写在最后
这篇文章所有的内容,就是RCRE这个库背后所有的设计思路和思想了,想必你看到这里也能够理解为什么会有RCRE这样的库诞生了。如果有兴趣想继续了解这个项目,可以点击下面这个链接:
https://github.com/andycall/RCRE
如果有任何问题,欢迎在下方留言,我尽可能将内容做到更好。