复杂表单应用解耦,淘宝机票订单实践
作者: 发布于:

背景

在 Web 应用中,复杂表单这类 Web 应用富交互元素多,业务逻辑复杂,犬牙交错,且需求变化频繁。及容易成为晦涩和幽暗之地,也经常是各种代码坏味道的来源。针对这种典型的复杂应用,本文以淘宝机票订单为例提出一种架构模式梳理和消化表单带来的复杂性。

模块和组件划分

解决复杂表单的的第一步,划分模块。

概念上,为了复用和解耦方便,应将模块按照功能的内聚程度进行划分。强相关,频繁沟通和交互的功能应该归为一个模块。模块间尽量不存在依赖关系。也就是常说的“高内聚,低耦合”。
如下图所示,淘宝机票订单页面主要有被分为 7 个主要模块。

模块划分完毕,下一步确认组成模块的组件。
关于模块和组件的区分。一般按照以下三个纬度考量。

  • 是否有业务逻辑参与。
  • 是否包含 HTML。
  • 是否具备一定独立性。

“模块”,定义为一个包含 HTML、CSS(图片被认为是 CSS 的一部分)、JavaScript 的代码集。模块的应用方式多为通过 Web 模板技术(如:Velocity、FreeMarker、PHP)。因为包含了 HTML,使得模块必须通过服务端合并加载并且最终推送到用户浏览器。此外,“模块”还是具备一定独立业务和交互的集合,最好可以被其他页面引用。良好的独立性也可以帮助协同开发,在实际开发中可多人可以并行开发多个独立模块,提高效率。

“组件”,定义为一个仅包含”css”和”javascript”的代码集。正因为不包含 HTML,所以组件可通过 JavaScript 异步加载。因为这种可异步加载的特性,组件在复用方面的容易性远超模块。组件没有业务逻辑或者仅有少部分公共业务逻辑。业务逻辑越多,组件的可复用性就越低。

模块、组件间通讯

组件/模块划分的目的是将彼此间相对独立的功能分离,前面通过模块和组件的划分解决了分离问题。实际中,模块之间存在协作关系。模块间应以一种轻量的方式协作。一般的为了更好的分离和解耦,可以考虑用广播的方式在模块间沟通,考虑使用事件的方式在组件间通讯。

如下图所示,淘宝机票订单页面的数据流向。

不同模块在后期均有可能扩展小功能。例如不定期的活动优惠等。事件广播可以让不同模块/组件间新增功能影响面缩小。在淘宝机票订单中应用中,使用广播组件通讯主要用来完成以下意图。

1、知会。
知会的特性在于异步通讯。广播发起方只需要放出事件,无需等待其他关注者完成处理。称为异步广播。例如表单模块的内容变更需要知会到显示订单金额的模块,显示订单金额的模块接受事件后需要更改金额。
基于这种方式的通讯,各模块之需要做好自己的事情,外部关注的事件广播出去即可。异步广播还有一个好处是系统坚固性比较强,广播发送者不会因为事件监听者的使用不当而异常。

2、请求数据
例如,模块 6(负责提交)需要在被点击后从模块 2(乘机人表单),模块 4(联系地址)、模块 7(金额计算)。获取具体数据提交。请求数据的场景特性在于,广播发起者需要等待事件处理者完成处理后再继续下一步行为。称为同步广播。

基于此机制。提交模块只需要负责综合校验,浮层,网络请求及异常处理。而具体请求的内容由其他模块决定。对后续模块的扩充起到了很好的左右。

复杂组件拆分

模块和组件划分完毕后,可能会发现某些组件非常复杂,几乎占据了整个 Web 应用一半以上的代码。这部分组件由纯 JS 实现,并且使用 JS 模块加载器加载。
同一个组件大量代码纠结在一起,最终还是会导致架构腐化。因此,复杂组件需要进一步拆分。在淘宝机票订单中,乘机人信息组件是一个复杂组件。如下图所示:

拆分这类输入型的复杂组件,一般来说有两种思路方式。

1. 纵切,组件树型式。
将组件进一步划分为更细力度的输入组件,将每个输入域作为一个单独组件。最终形成一个组件树。

这样的组织方式结构严谨层级清晰,最大的优点是很容易支持字段扩展。
但考虑如下场景,为了尽量友好的提示用户,需要在输入域外的某处增加提示帮助。

这种场景下组件树的组织方式每次在面对变化时就会略显手忙脚乱。难道把每个地方出现的 tip 都座位独立组件看待吗?
字段级的适普性降低了适应细节调整的能力,付出的代价在于界面体验。

2. 横切,AOP 式。

将所有输入域抽象的看待为同一个组件。按照组件的富应用特性分层看待。在本例中,乘机人组件被按照从简单到复杂分为 3 个切面。

  • 切面 1:基础展现层只负责最基础的可完成输入的表单控件,及基础 DOM 管理。
  • 切面 2:富展现层负责修饰 base 层的基础 HTML 控件,形成富输入控件。
  • 切面 3:校验层负责对 base 层的输入数据进行业务级校验。

未来,如果新增 tip 或者其他业务逻辑,增加一个新切面即可,完全或者很少需要修改老代码文件。


淘宝机票订单采用了 AOP 这种方式,从最终代码量上来看,可以看出复杂度被比较均衡的分布到不同文件中去。

同样,这种方式也有局限,如果需要扩展字段,那将是一个灾难,你有可能需要到每一个切面里面去做修改。

有句老话说的好,没有最优方案,只有最适合的解决方案,任何解决方案,都需要放到具体场景中去评判。事实上,对这个问题的进一步研究,可以发现以下规律。

对于一个组件、模块,同时追求简单设计、适普性(字段级扩充)、界面体验是不可能的。如果场景需要适应字段灵活扩展,那就采用纵切的模式。如果使用场景需字段确定,需要更多细节控制力度,那就横切,AOP 式。如果两者都要兼顾,就需要引入复杂设计,综合运用横切和纵切。但是这样形成的最终设计会很复杂,开发和可维护性上会有代价付出。

对于淘宝机票这类互联网应用,使用了横切的方式来拆分组件,因为在这个场景中,字段的数量是相对固定的,而围绕固定数量字段的优化需求是层出不穷的。然而在企业内网应用或者网站后台 Web 应用中,字段的变化会比较频繁。建议主要采用纵切的思路划分。

表单校验

有表单的地方就有校验。项目初期,校验的功能总是不起眼。等待项目后期时候经常会发现校验已经占据了巨大工作量并且成为海量 bug 的源头。因此校验是一种典型的容易被轻视单又蕴含巨大工作量的事情,需要特别对待,专门设计。

一般来说,这根据校验根据其复杂度可以分为以下两类:

  1. 格式校验
    格式校验一般是校验用户输入的格式是否满足要求,比如是否数字、电话号码、邮箱等等。此类校验的特点是校验域单一,一般只对一个 input 或者某个组件的 value 进行检查。格式类校验应与与用户展现非常接近,一种非常好的做法是将此类校验信息直接描述在 HTML 标签属性中。HTML5 中 input 的 pattern 属性就是一种基于这种思想的解决方案。
  1. 逻辑校验
    逻辑校验是满足格式校验后,继续进行的与业务相关的校验,例如是否存在相同用户名,输入的生日是否和身份证号不符等等。此类校验的一般涉及多个输入域,要综合处用户的输入内容一起校验。此类校验逻辑复杂,不适合写在 HTML 中。

目前有很多流行的 form 校验框架解决校验问题,如何引入合适的校验框架,先从理解校验这件事的过程开始。
典型的一个校验过程如下,用户在某个 input 处完成输入,应用在某个时刻被触发校验,可以是失去焦点或者 keyup 或者其他。被触发的校验过程找到此处 input 所需要的校验规则(有时候这个规则被直接写在 HTML 中)判单正确与否,如果正确,可能有提示,如果错误,可能也有提示。
从以上场景的描述中,可以找到校验的几个关键环节。这里局部采用一下管理学上经典的 5W1H 问题分析方法来分析问题。

  • who:哪个输入控件的内容需要校验。这是框架是解决不了的。要对哪个输入域做校验应该是应用传递进入的。
  • when:何时被触发校验。比如说是 who 失去焦点时。变化太多,框架解决不了。只能被动触发。
  • what:做什么校验。有时候这个 what 被写在 HTML 中。基本上,所有格式校验都是固定的,这个问题应框架解决。但框架应预留接口做更加复杂的业务校验。
  • how:校验完毕后的动作。框架不能决定做什么,但是在校验结果出来后,框架应能知会到外部调用者。
    在设计框架或者选择已有框架时,首先要区分框架的边界,简单来说,就是做什么和不做什么。框架应实现相对固定的业务流程。同时对可变部分预留足够的灵活性。

一个通用的校验框架一定是不含界面部分的。界面是多变和难以穷举的,是用 tip 显示错误,还是在输入域附近显示,是否需要动画,是否需要修改输入域的视觉状态,这些可变化的部分应为框架外部内容,由更专业的 tip 组件或者 popup 来完成。框架只应该负责在校验完成时候知会相关组件完成显示错误提示等若干事情。

基于以上的分析,校验框架应该具备以下规格

  • 解决 what 问题。内置了各种格式校验规则,如电话号码、e-mail 等,并且能够灵活定义新的逻辑校验。
  • 解决 who 问题。说明如何根据输入的字符真正找到 who 对应的 value,并且能够对于这个 who 使用哪些校验规则
  • 解决 when 问题。提供一个触发校验的方法。
  • 解决 how 问题。产生校验结果后能够知会外部的功能框架。

在淘宝机票订单应用中,依据上述原则自行设计了一个 Validator 框架,接口定义如下,Validator 是校验框架对象。

  1. 在构造函数中提供表格化的校验逻辑定义型式。如下图所示,传递如下结构,定义每个字段对应的校验方式。在下图中,定义每行为一个 field,每个 field 有若干 rule,每个 rule 可以是框架内置的格式校验,也可以是自定义的逻辑校验,实际上是函数名。

  1. Validator 框架提供 validate() 方法,validate 方法有两个行为,如果不指定参数,将依次执行完所有 field 的校验,并且将最终结果返回。如果执行一个 field name,框架将只校验 field name 对应的输入域。
  1. 一旦执行 validate() 方法,无论校验结果如何,框架均向其观察者发送事件 onValidate,以便触发后续动作。
  1. 一些辅助参数,需要提供一个从 field name 找到输入域 value 的 function。

总结

在处理复杂表单时,首先通过合理模块、组件划分,将复杂度分散。然后利用详细和广播机制解决分散的模块和组件间通问题。接着,过于复杂的组件要考虑进一步拆分,具体拆分的方式有纵切和横切两种,根据具体使用场景决定。最后,不要小看了校验,需要特别对待,专门设计。

题图:https://unsplash.com/photos/_F3VBwffsOE By @Andre Benz