新轮子SparkUI
为了推进SPA改造,我们成立了一个专门的前端小团队,与业务模块开发团队紧密合作,经历数十个迭代,开发并完善了一套基于React的前端框架,内部名称为“SparkUI”(这一名称与Apache Spark或Java Spark无关)。
SparkUI是一套完整且灵活的前端开发解决方案。该方案基于React,由Modula应用状态管理框架、一系列可重用的前端组件、以及构建SPA所需的各类支持库组成。该方案重视可重用性、灵活性、可测试性以及开发效率,解决了前端社区常见的一些针对商业前端应用开发的痛点,如复杂状态、Side Effect,组件拆分等,更在工程实践、文档化、本身代码质量等方面达到较高标准,为前后端分离架构下的商业前端应用开发提供了坚实的基础。目前SparkUI已成功应用在FreeWheel的前端项目中。
下图是SparkUI框架的简要架构。
其中上游的React、Redux、ImmutableJS等框架为SparkUI的直接依赖,下游的Business Components业务组件、Business Modules业务模块则为基于SparkUI框架开发业务代码的产出;衔接上下游的,则是SparkUI的核心组成部分。
可重用组件Library Components
SparkUI截止至截稿日已积累了40个子Package,其中很大一部分为可重用的UI组件,我们称之为Library Components,例如Spark-loading、Spark-calendar、Spark-raw-grid等。凡是业务模块提出的对前端组件的需求,只要与业务并不直接相关的,我们都会设计并迭代开发相应的可重用组件。
我们在设计可重用组件时,遵循的一些要点包括:
1.无状态组件(Stateless Component)优于状态化组件(Stateful Component)。例如下面的<LinkGroup>组件,用户调用该组件时需要将是否展开的状态传入expanded属性,并在onClick中传入处理函数以修改该状态。
<LinkGroup label="More" expanded={ model.get('linkGroupExpanded') } onClick={ model.sendLinkGroupToggle } > {/* <Link>s */} </LinkGroup>
我们在当初设计API的时候完全可以利用组件内部的State维护其展开状态,点击修改状态的逻辑也藏在组件内部,用户就无需传入这两个属性。这样确实能减少用户的代码量,但也大大限制了该组件的应用场景。试想,如果LinkGroup的子组件是在点击时才去服务器端获取的,用户该如果使用这一组件?
2.组合组件(Composing Components)优于具有DSL(Domain Specific Language)属性的单一组件。在下面的例子中,<Tooltip>被设计为一个独立的组件,封装了鼠标悬停则显示tooltip的交互逻辑,其Content属性除了支持简单的字符串,更是支持传入任意Element;任何其他组件需要加入tooltip支持时,只要将其作为<Tooltip>的子组件即可:
<Tooltip content={ <p> I am a <strong>tooltip</strong></p> }> <div>My Content</div> </Tooltip>
反观另一种设计(我们并没有这样做),任一组件需要加入tooltip时,需要修改该组件以加入tooltip以及相关的一系列属性,这样并做不利于组件复用:
<Label tooltip="I am a tooltip" tooltipFontWeight="bold">My Content</Label>
以上只是简单的例子,其实我们在这一点上交过很高额的学费。我们最初版的Grid是这样的:
<Grid columns={[ { name : 'number', display: 'Number', size : 'fixed-medium', align : 'left', widget : false, tip : { 1: { direction: 'right', title : 'This is level 1 tip', content : () => { return 'This is level 1 tip'; } },
2: { direction: 'right', title : 'This is level 2 tip----level 2', content : () => { return 'This is level 2 tip---- level 2'; } } } }, { name : 'month', display : 'Month', size : 'fixed-large', sortable: false, widget : false }, { name : 'url', display : 'URL', size : 'fixed-medium', align : 'center', sortable: false, renderer: { fallback: 'url' } } ]} data={ this.data() } />
其中的Column属性我们称之为DSL属性。这样的DSL描述一个Grid对于用户而言还是比较友好的,但对于组件内部逻辑实现而言,其可维护性相当差,尤其是在项目初期,不断有用户提过来新的需求,比如“想改变特定单元格点击行为”, “特定行可以展开并加载子数据”等等,导致我们在维护这套DSL语法时会经常陷入前后矛盾,举步维艰的状况。
针对这样的困难,我们最终淘汰DSL,开发了Spark-raw-grid包,将Grid拆分为从大到小十几个组件,由用户根据需要将他们组装在一起。限于篇幅,不在这里介绍细节。
3.高阶组件(HOC, Higher-Order Components)优于混合属性(Mixins)。在设计Spark-modula-form的时候,我们使用了Mixin属性:
<Checkbox label="Subscribe to news" { ...subscribe. getCheckboxProps() } />
用户在加入表单输入组件时,需要显式的加入预制Mixin属性以享受表单接口的便利。然而在用户需要定制类似onChange这样属性时,Mixin属性会产生冲突。这时HOC方式会提供更高的灵活性。我们在SparkUI中广泛使用了recompose.pure()这样的HOC,也开发了一些自有的HOC。限于篇幅,省略细节。
应用状态管理框架Spark-modula
上一节提到Flux所提供的单向数据流不能完全满足我们的业务需求。我们在对比了Flux和Redux后,决定自主开发一只新轮子。当时面对的挑战包括但不限于:
• 在Ruby on Rails应用中,开发团队曾设计并开发了大量的Model,这不仅是因为要遵守RoR的MVC实践,更是因为业务的复杂程度客观要求有完整的建模,并基于模型推进前端的开发。我们的新轮子需要以类似的方式来消化业务的复杂性;
• 在业务的前端需求中,常常有一个页面内包含2个甚至多个Grid,这些Grid之间会互相影响。比如一个典型场景:“Grid A在自动加载后,如果只包含一条记录,则自动选中这条记录,并按该记录ID读取Grid B”。这样的交互在React社区被称为副作用即Side Effects。我们的新轮子需要用相对简单的方式支持Side Effects的处理。
这只应用状态管理的新轮子我们起名为Modula,并入Spark-modula包。Modula框架基于Redux但并不限于Redux,与部分Redux生态(如Redux-devtools)兼容,且已完整封装并隐藏了底层的Redux。下图简要介绍了Modula与React、Redux的关系:
Modula应用状态管理框架
例如,在Redux里,应用状态是完全平展开的结构且不存在任何的层级关系,因为缺乏一个对象化的组织,所以要在状态众多的情况下,在Redux的Store上找到某个状态就只能依靠纯记忆。而Modula引入了对象树(Model Tree)后,所有的状态都可以被对象化,即通过预先定义好的结构来组织状态。尽管是比较复杂的组件,在页面上的展示可能也只是一个表单或Table。
如果给这个Table设定一个较为复杂的状态——加一个搜索条,搜索条本身有简单搜索和复杂搜索的区分,上面还有复杂的工具栏、动作条,其本身或许还需要支持翻页等。如此多的状态之下,用Redux的方式可能会有好几百个状态在一个Store里,于是管理起来就会非常困难;但Modula就可以组织得更好,下面是Modula主要的设计理念:
1.Application State = Initial State + Deltas,其中Delta是由Actions触发的(借鉴Flux, Elm);
2.Application State可以由一棵Model Tree来描述,这棵树的每个节点都是一个可以描述有效业务实体的Model(借鉴Redux, Elm);
3.由一个给定的Application State到另一个State的Transition可以由Model Tree提供的Reactions所描述,一次成功的Action到Reaction的匹配会将Model Tree演变为下一个状态(原创);
4.Side Effect是上述State Transitions的结果,它包含了一个更新的Model实例,以及零至多个Callback Functions(借鉴Elm)。
对于Modula中Side Effect问题的处理,Modula模块中的Receiver可以返回Side Effect,一个Side Effect可以是Sender或Bubble Event的引用,也可以是一段匿名函数(箭头函数); List-A读取完成时会根据List-A中包含的ID,自动触发读取List-B。
经过快速迭代,Modula框架已正式替代早期的Flux,应用于业务模块开发。Modula包括Model模型、Constants常量、Container容器、Test Utility测试工具四个组成部分,其中Model包含Props/Hierarchy、Context、Sender/Receiver、Delegates、Bubble Event、Lifecycle Methods、Services、Local Props等概念/API。以下是一个典型的Model例子:
可以看出上半部分相当于Model的Schema, Props/Hierarchy/Context基于Immutable数据结构实现了数据模型;而下半部分相当于Model的行为,Sender/Receiver + Modula Container实现了单向数据流。
前端路由框架Spark-router
此外,为了能够支持构建典型的SPA,我们基于Modula开发了一个Spark-Router组件。相比于React-Router(其状态并不存储在Redux的Store上), Spark-Router中路由的状态管理能够与应用中其他部分的状态管理采用同样的机制。
此前,应用状态都分散在React-Router的状态与Modula Model(底层对应Redux Store)里,两者经常遇到同步问题,我们的解决方案是将路由相关的状态也合并进Modula中。Spark-Router基于Model配置路由,Component可根据Model切换相应界面,这样就不必再在路由的状态和应用中其他部分的状态之间添加同步设计,也让程序变得更简单。
SparkUI开源计划
到截稿日为止,SparkUI在FreeWheel的生产环境里使用已经超过一年了。我们无论是在开发SparkUI还是开发其他前端基础设施的过程中,都利用了大量开源社区的成果,我们也希望我们的工作和成果能回馈到开源社区,为更多的开发者提供新的选择。
我们开发SparkUI的初衷,是为了应对FreeWheel前端应用较高的复杂性。FreeWheel核心应用之一是广告资源的管理系统,这个系统所针对的客户非常专业,对应的、有着非常复杂的工作流是要通过UI来实现的。这导致该应用在前端的状态很多、很复杂。SparkUI框架的特点,就是擅长于用来构建具有复杂前端状态的应用。这也是核心商业系统普遍具有的一个特点,我们认为该框架在类似应用中会有较高的使用价值。
SparkUI目前还只是在FreeWheel公司内部使用,而其开源事宜已提上日程。FreeWheel和其母公司Comcast都对此非常鼓励,目前已经开始对开源SparkUI走相关法务流程。FreeWheel首席架构师张晗表示:“SparkUI可能并不会全部开源,组件库这类属于产品特定需求的部分会被拿掉,像Modula这样的通用模型部分会属于开源的范畴。如果你的应用需要复杂的前端功能,特别是需要对具有相互关系的状态进行较多维护时,就可以考虑使用我们的Modula。”