我所在的业务组,负责公司的多个类目(这些类目就如京东上不同的品类)的产品开发。这些不同类目的门店有不同的特定,要展示不同的信息,因此就要求录入的东西是不同。但是显然,我们不打算每一个类目都开发一遍,所以我们设计了一套可配置页面解决方案和一套动态解析数据解决方案。这篇博客讲述的是我们的可配置页面解决方案。不过这个解决方案限制在APP的h5页面上。
不过担心泄露公司机密(然而并没有),所以这里将会忽略一些业务相关的东西。而且举的例子也和我所在的公司无关。希望读者见谅(不服你来打我啊)。
在可配置页面里面,我认为要解决的是三个核心问题:
- 页面布局和样式
- 页面内容——也就是数据
- 页面的交互
下面将从这三个角度来探讨。
需求特征和一些概念
首先来看一张图:
这是我在淘宝上随便截的图,虽然它和我实际要解决的需求的图看起来差别很大。我给它划分成了不同的块,并且标号了。整个图,被称为页面。里面一个个标号的,被称为组件(Component)。实际上,页面也是一个组件,被称为顶级组件,也被称为根组件(root component)。这里的划分标号并不完整,但是可以看出来一些:
- 整个页面被认为是一个树形结构,由不同的组件构成。这种树形结构的划分是依赖于个人的理解的。从图里面,我划分出来的树形结构大概是1和2是根的子节点,3和4是2的子节点,5是4的子节点;
- 组件由组件构成,它们是递归构建的。直到最小的组件,这一类的组件逻辑上将无法再被拆分,这一类的组件被称为单元(unit)。注意的是,图中的3,4,5都不是单元,它们都可以进一步被划分,这里省略了而已;
前面我们已经得出了三个概念:
- 组件:页面的组成部分;
- 页面:组件的一种。在这次设计方案里面,一个页面对应的就是一份配置;
-
单元:逻辑上最细粒度的组件,将不可再被拆分;
它们的关系如图:
现在我们来分析一下这个页面的特征。首先的是,这个页面的布局是简单的,可以认为是一种网格的布局,以从上到下,从左到右的顺序逐个分布组件。整个页面首先可以被分成1和2两个组件,这两个组件从上到下放好。组件2里面又被分成了3和4两个,它们依旧是从上到下顺序排列下来。最后的5,可以被认为是依次按照一行两列一直排列下去。
其次,没有复杂的交互。充其量也就是点点点,而后可能下面展示的内容变了,又或者跳转到了新的页面。
实际上“筛选”那里并不符合简单交互,因为筛选点了之后会弹出一个框,然后再点点点一堆。应该算是比较复杂的交互了。
布局和样式
布局
首先我们要考虑的就是布局和样式的问题。前面需求特征里面已经说过了,布局是简单的,它可以被看成是一种网格:
我们将前面截图的内容去掉,只留下布局,那么就是图中这个样子。要定义这么一个东西,实际上很方便,我们只需要指定一个组件其子元素每一列放置多少个组件就可以了。因为配置里面隐含了总共多少个子元素,那么就可以计算得到行数。
还有一种比较复杂的配置解决方案,就是每一个组件,指定其宽度。假如说一行放置两个元素,如果是平分的话,那么就是每个50%;如果不是均分,那么可以指定每个组件的宽度,可以是60%和40%,也可以是其他的分配比例。这种配置方式会带来布局的灵活性。但是会带来配置的复杂性。因为每一个组件至少要多一个宽度的属性。
现在有很多前端的框架是支持栅格系统的,因此在布局方面,其实能够省事很多。不过,此处有一个地方尤其要慎重考虑:在指定宽度布局的时候,谨慎使用固定长度。这句话的意思是,可以使用百分比的方式来指定一个组件占据的宽度,但是千万不能直接指定它该有多宽,比如指定某个组件宽度是60px。这种方式容易变形,因为不同手机,其屏幕宽度是有区别的。
样式
我在设计的时候,秉持了一个原则:绝不配置任何有关样式的信息。我认为布局,如果可以避免的话,都应该避免,只是实在避免不了而已。
我反对配置样式的理由是:
- 配置样式会带来配置的急剧复杂化:一个组件的样式,由很多控制选项,而且不同组件之间还会相互影响;
- 配置样式导致样式修改调整都十分困难:这和第一点是一而二二而一的问题,配置的复杂化会影响修改配置的任何一个地方,组件的相互影响让你根本不敢修改;
但是,一个组件是不可能没有样式的。我的解决方案是,同一种类型的组件,都具有同样的样式。实际上,从配置上来说,基本不含有任何的样式信息,是前端自由决定一个组件究竟长什么样。
不过,还有一些问题要额外考虑。第一个是,如果真的有需求,比如一份配置,在两个APP上用,两个APP的色调不同(好吧,说的就是我司,点评APP和美团APP,一个是橘色的主色调,一个是蓝色的主色调),那么究竟该如何解决?这个问题可以引申为,对于一份配置,要渲染成多种风格,该怎么搞?
这个问题还比较好解决。在配置里面指定风格,或者说主题(Theme)。Spring实际上对此有很好的支持。也就是说,在不考虑配置组件的样式的情况下,可以考虑一份配置作为一个整体,允许有主题的概念。主题则决定了这份配置将如何会被渲染。实际上,这已经超出了页面配置的范围了。
还有一个比较棘手的问题:有两个组件,它们除了样式不同以外,都一样,那么怎么办?最为直观的例子就是,文案一个要加粗,一个不要:
我的解决方案是,我会定义一种新的组件。这种方法十分粗暴,但是其仅适用于这一类组件不多的情况。否则会带来组件类型膨胀的问题。
还可以考虑另外一个解决方案,这种方案是我事后完成设计之后才想出来的,未曾实践过,不知道效果。就是,虽然我们不配置每个组件的样式,但是我们配置每个组件的主题。比如说前面图中的三个文案,可以指定为三种主题(normal, bold, red)。这能够防止组件类型的膨胀,也能避免直接配置样式带来的问题。
我觉得,这种方法应该算是最好的了吧。不过我还是觉得,配置不要牵涉样式是最好的。
页面内容——数据
这部分要考虑的问题是:什么时候加载数据?是把数据糅合进去配置里面,或者说一份配置经过转变之后,也就是填充了数据之后,再交给前端,还是直接返回配置,让前端再次发请求,以获得数据?
对于第一种而言,很多工作被后端完成了,这在前端资源紧缺的情况下可以考虑。但是,更加好的实践,应该是后面一种,尤其是在涉及一些交互的时候——纯粹的展示,对于两者来说,区别并不大。
在下面交互这个部分,还会有对数据的进一步讨论。
页面交互
交互是整个配置页面最难解决的地方。对于页面的一次交互来说,有几个要素:
- 目标:其实就是交互所涉及的组件;
- 触发器:是如何触发这一次交互的。比如说可以是点击了某个东西,也可以是输入了某个东西;
- 数据:如,点击一次之后显示新的内容,那么这个新的内容就是数据。又或者在用户输入的时候实时校验输入的格式,那么用户输入就是必然要获取的数据
如果写过前端的读者,应该很容易就看出,这个和JS的事件模型的概念是比较接近的,我只是换了一种说法而已。实际上,这也的确是参考前端事件处理的一般机制来设计的。
处理页面交互,我其实没有很好的方法,最根本的问题在于:如果交互涉及业务逻辑,那么就不可能通过配置来完成。
所以我采用了一种十分简单粗暴的方法。我预定义了几种动作,这里列举一部分:
- 第一种动作我称为提交。顾名思义,这个动作会将表单数据取出来一一进行校验之后,提交给后端,并简单提示结果;
- 第二种动作我称为即时输入校验。即只要是输入类的组件,比如输入框,下拉框这种东西,都会在每次值发生变化的时候执行一次前端校验,而后在不通过的时候提示结果;
- 第三种是级联。比如说,点击某个地方,然后页面展示出来的内容就会换掉。这并不需要往后面请求数据,实际上数据都取出来了,只是控制其中的部分展示与否;
这三种是我觉得比较常用的。后面又陆续添加了一些,但是大多数都围绕那么一个核心:向后端请求数据,展示某部分数据。
所以,实际上,要解决交互,最为重要的就是要解决数据访问的问题。
页面交互与数据访问
页面交互,常常要面对的一个问题是:如果交互涉及到了数据,那么我该怎么取到这些数据呢?举个例子最简单的例子,一些范围的输入,比如说价格筛选范围,要求第一个价格比第二个价格低。那么在校验的时候,就必须要同时知道两个输入的内容。
这个价格只是举例子而已,类似的还有日期输入。实际上良好的交互设计,或者系统实现,应该是无论用户怎么输入,90,或是09,或者9~9,系统都能给出正确的结果。
我们很容易在输入框上绑定一个监听输入的事件,也很容易获得绑定了的那个输入框的内容,但是另外一个输入框的呢?解决方案可能是,直接操作DOM树。这种方案是能够解决问题,只要你能够确保拥有一个合理的组件标识符生成策略,能给予那个输入框一个在页面内独一无二的标识符。
但是还有一种更加优雅的解决方案。这是参考当下的一些MVVM框架的实现。可以在全局维护一个数据model。上面的例子,要校验价格是否合理,只需要访问这个model就可以,它已经不需要关心这个数据究竟是怎么输入,从哪里输入的了。因此,拥有这个全局model之后,所有的数据访问以及变更都是通过这个model来进行的。
这个模型能够解决所有的跨组件通信的问题(它依然不能解决跨页面通信问题,不过这种需求几乎不可能出现在APP上)。但是它存在两个问题没有解决:
-
作用域隔离。因为我们只有一个全局model,所有的数据都往这里塞。我们可能会面临这样一个问题,这个页面有两个相同的字段,比如都叫totalPrice,但是它出现在不同的地方有不同的含义。就页面组织而言,两个字段可能对应两个属于不同模块的组件。全局model的困难就在于,比较难表达这种区别。一种合适的手段是采用合理的层级关系:
不过,我有一个更加粗暴,我也觉得是更加好的解决方案。那就是避免出现这种情况。确保整个全局model里面,每一项数据都是独一无二的。一般的应用而言,这点是很容易做到的;
- 另外一个问题,是安全问题。数据只是放在一个model里面,自然容易出现非法访问的问题。不过,在我看来,任何放在前端的数据都是不安全的数据,所以,这个问题其实解决不解决,在我眼里相差不多;
回到我前面提到的,交互较多的时候,由前端分别获取配置和数据的优越之处就在于,单独一次获取数据,能够较容易构建这样的一个model。而如果由后端将配置和数据聚合之后,前端就需要更加复杂的解析配置的工作,以构建这个model。
实际上,这里可以把model看做是一个中间件,只是借助于这个Model来实现交互中的数据传递。而后,通过监听这个Model中数据的变化来实现页面刷新。这个流程做过前端开发的读者应该很熟悉——的确,这就是一般的MVVM框架的工作方式。
注:图片引自http://www.ruanyifeng.com/blog/2015/02/mvcmvp_mvvm.html
所以,如果能够借助于一些MVVM的框架,即便不是双向绑定的框架,单向绑定的框架也能极大的简化工作量。
交互例子
我感觉这部分还是要借助一些例子来说明我是怎么设计整个交互的。现在假定我们要提交数据。前面我已经说过,所有的输入数据都可以在model里面找到。这个表单的配置里面已经告诉了我需要提交哪些数据(暂时忽略校验过程),并且告诉了我要提交到哪里:
总体流程
经过前面的分析,现在可以给出我们设计的前端渲染页面的整个过程:
下面我来分析一下当中某些过程的要点。
load module
这个过程是很重要的。我将Module定义为一些组件和预定义事件的集合。它是用来支持扩展性的一个东西。因为在开发的过程中,无法预计到会有多少种组件,也无法预计到会有何种交互。所以关键不在于设计好组件,而在于设计好组件接入方式。而我们自身设计好的组件,也不过是利用这种方式接入而已,切记!如果你能够通过良好定义接口接入自己预定义好的组件,那么别人才有可能通过这个接口接入自己定义的组件。
因此,在允许别人自定义组件的情况下,就需要有能力从配置中解析出来需要用到的module,并将其加载过来。整个过程完成后,那么解析配置所需要的全部材料都已经准备好了。
render template 和load data
渲染模板(render template)是一个可选的动作,因为后面可以在加载数据之后,render component的时候完成整个页面渲染。首先我要解释一下渲染模板。这个名词其实不准确。准确的说法是渲染一切不需要数据就能完成的组件。例如,常见的页面的页头,或者品牌的Logo都是直接可以渲染的,不需要经过加载数据这么一步。因此可以先完成这一部分的渲染。这样页面会立刻显示出来一部分,避免用户长时间面对空白页面。
在这个过程中,可以并行的是同时加载数据。例如前面截图淘宝的页面那样,去加载商品数据。完成了数据加载之后,最后进行的就是render component。如果依赖于一些MVVM框架的话,这个过程大概不需要花费什么功夫。
register event
这是一个我要着重强调的步骤,注册事件!所谓的注册事件,就是利用已经加载数据信息,在组件上绑定事件的过程。这个过程,要放在最后一步的原因是:某些动作是与数据相关的。比如说会员等级不同,会影响加载的组件。有些会员会出现额外的按钮,或者有些会员点击按钮是跳转,而另外一些会员点击按钮却是提示一个信息。
因此,只有在完成数据加载之后,整个页面才是完整的。
系统划分
系统可以如下划分:
- model模块:维护的则是全局model。它只应该暴露两个接口,一个是取数据的接口,一个是更改的接口;
- 事件模块:它要完成整个事件的执行。实际上,它依赖于下面的module模块;
- module模块:里面维护任何一个组件,任何一个事件的基本信息。页面的渲染和事件的执行都依赖于其中的信息;
- 通信模块:它将负责和服务端的数据交互;
- 控制模块:这是一个如果有必要可以进一步划分的模块。它将负责配置的解析,确定是否需要加载额外的模块;在model发生变化的时候刷新页面;页面变化的时候将数据写入等。它依赖于其余各个模块的通力合作;
- 预定义module和第三方module:这就是事实上承担工作的部分。我们提供了预定义的一些组件,比如复选框,下拉框等。但是也允许第三方接入自己的module。其实,这些预定义的module也是用同一种方式接入的;
额外考虑的问题
原子组件
这是一个讨论拆分粒度的问题。举个例子来说:
这个从最细粒度上来说,大概可以拆成三个部分:
- 返回的那个箭头
- 输入框
- 照相机图标
然而实际上,考虑到实际的应用场景,这三个经常被合在一起使用,那么完全可以将它作为一个最基本的不可再拆分的原子组件了。这样做能够省去很多的配置。就复用性而言,也还是能够接受了。在大多数的页头,都能用上。
我这里可以额外讨论一下粒度的问题。组件粒度(或者是模块粒度,业务粒度)和可用性、复用性是息息相关的。一般而言,它们的关系都是粒度越细,可用性越差,复用性越强;反之则是可用性越强,复用性越差。还有一个相关的是复杂度。粒度越细,那么组件内部复杂性就会低,而组件之间合作的话,复杂度就会上升。
如前例子。如果拆成三个,那么输入框是可以做成通用的输入框组件。但是可用性会变低,因为为了配置这个页头,不得不指明这三个组件,以及他们的分布和一些提示文案,这也是复杂度显著上升的表现。
在拆分组件的时候,要注意权衡复用性、可用性和粒度。
数据校验
如果配置的页面,是允许用户输入的,那么数据校验就是一个很重要的点了。很不幸的是,我要解决的问题,其实就包含了大量的用户输入。
数据校验要解决的一个问题是,保证后端服务器校验逻辑和前端校验逻辑是一致的。这是一个看起来容易,但是里面有比较多坑的问题。
首先是,因为页面是配置的,所以校验方式也必然是配置的。前端的校验逻辑是从配置里面读取的,而要保证后端校验逻辑和前端校验逻辑是一致的,那么岂不是后端也要去解析这份配置了?解决方案的确差不多,但不是后端读取前端配置,而是两者都从一个公共的地方读取这种校验逻辑。
注意的是,我设计的系统里面,除了这个可配置的前端页面部分以外,还有一个动态解析数据后端部分与之配合。而这两者都是一种面向元数据的模式。因此,两者从同一个地方读取校验逻辑是合理的。
我可以再说一下我们定义的几种校验:
- 正则表达式校验:这是最常用,也是最实用的校验方式。几乎任何输入都可以用这个方式校验,可以用于校验手机号码,网址等;
- 个数校验:校验复选框,图片上传数量等;
- 范围和长度校验:这两种校验都可以算是正则表达式的一个简化,因为它们本来是可以用正则校验完成的。比如价格范围,名字长度等;
还有一种在前端实现起来困难重重的校验方式,我们称为级联校验。就是,要确保多个输入数据之间满足一定的关系。比如说你选中了某个下拉框,比如说衣服,下面它就要求你必须输入衣服的尺寸,颜色,价格范围。而且,如果下面的尺寸、颜色或者价格输入了,也要确保下拉框选中了衣服。这种校验,我们采取的方案是——不校验,而交给后端来校验。因为前端要配置这种校验关系十分困难,但是交给后端就好多了。因为,无论前端是否校验数据,后端接收到数据都必须要校验。
配置的表达
前面读者可以看到,我们是使用json格式作为配置的表达形式的。JSON格式有很多优点:
- 天然的树形结构,能够完美契合我们对布局的设计;
- 前端读取到配置后,直接就能够使用;
- 足够简单
还有一种可以供选择的格式是xml格式。只是这种格式对前端来说并不太友好。当然,后端配置的时候可以用XML配置,或者别的什么格式来配置,而后再经过转化,变成一种适合前端使用的格式。
配置存储
配置存储是一个挺麻烦的问题。单纯的配置,是可以用文件来存储的。但是如果要配置的数量很多的话,那么用文件来存放,会导致文件特别多,管理也麻烦。
一种方式是直接把配置作为一个整体存到数据库的某个表的某个字段里面。这是一种挺不错的解决方案,既不会太复杂,也能够避开使用的文件的问题。
还有一种存储方式是,直接将配置拆分成组件来进行存储。也就是说一个组件是一行,而后维护组件之间的父子关系。这种可以带来组件的复用,在维护配置的一致性上比较有意义。如果页面之间有很多组件是需要共用的,那么这种存储方式会比较合适。我们设计的系统第二版就是使用这种方式。存储组件的树形结构使用的是路径方式,如/component1/component2/...
大多数情况下,在Path这一个字段上建立一个索引差不多就可以了。因为组件本身是很少被修改的。
限制
- 不支持复杂布局。这种配置的页面,不支持复杂的布局,它只能使用在布局极为有规律的地方。因为复杂的布局意味着复杂的配置,而复杂的配置——为什么不直接开发呢?
- 不支持复杂交互。
其实在不限制配置文件复杂度的情况下, 这些限制都可以被克服。这是因为,我们可以将页面的配置看成是页面的另外一种形式化的描述。我们常见的对页面的描述就是HTML标签和CSS样式,这里不过是改为了JSON格式。前端开发的东西,不过将这种JSON配置转化为HTML和CSS形式的描述:
注:我甚至想过一个更加复杂的解决方案,就是我们定义一套新的语言用于配置,而后再编写一个编译期,将这个配置语言编译成HTML和CSS,还有JS。也就是我们认为配置一份源代码,而HTML和CSS是编译期输出的结果(可以认为是编译过程中的中间结果)。不过这个方案不具有什么可行性,太过于困难,而且收益也有点低。所以,将配置看成是另外一种形式的页面的描述,是可配置页面最为核心的概念了。
最后
你说的这个系统那么多缺点,要来何用?
我一再声明的一个观点是:我并不想开发一个一劳永逸的系统。这个系统也并不打算提供各种面面俱到的能力,我对它的期望是能够解决八成问题就可以了。我一直笃信的是,如果依赖于系统解决剩下两成问题所需要花费的精力,与解决这八成所花费的精力,有过之而无不及。
最直观的是,考到80分很容易,80-90就有点难了,90-95更难。而100,那就基本上是需要上天眷顾了。
要不要开发一个可拖拽的系统用于配置页面?
当且仅当,有很多非专业人士需要配置界面的时候,才有必要开发这么一个东西。实践证明,这种东西的学习成本和恶心程度,绝对是你不想碰的。
PC上能使用吗?
答案是,能!实际上我第一次设计这种东西,是外调到另外一个组支援一个项目的,当时设计的是一个PC版的可配置页面解决方案。在PC上,要额外考虑的东西更加多,我举一些例子:
- 布局更加复杂:APP的布局是简单,因为屏幕宽度及其有限,可以说整个大小也有限,由此带来了很大的布局便利。很不幸的是,这些在PC上都不再具有了;
- 元素更加复杂:在PC上,有什么iframe, modal之类的东西。也就是会有各色各样的嵌入页面,弹窗之类的内容;导航也有千万种,什么下拉框,面包屑……这些设计的东西真是多不胜数。如果不限制前端可以使用的元素种类,那么这个可配置页面的解决方案,怕是要难产了;
- 交互复杂:如果说APP还可能受制于屏幕和交互方式,导致交互可以比较简单,那么PC上就毫无顾忌了。首先就是在PC上可以输入乱七八糟的东西——这也会带来前端输入校验的痛苦,而后PC弹窗的嵌套,页面的嵌套也是难点。最坑爹的是跨页面通信(我就遇到了,还能比这更坑的吗?)
讲了这么多难点,我能够提供的一点建议就是:牢牢记住页面就是一个顶级组件。因此页面的嵌套的可以看成是组件的嵌套,跨页面通信可以看成是组件间通信(当然要比一般的组件间通信困难很多,而且限制也更多)。此外就是,禁止产品或者设计人员自由发挥!!!
还有就是,设计要简单,不要复杂。如果因为少数的几个特性会导致解决方案复杂化,那么还是直接开发页面吧。
这样配置有什么好处?
第一个好处是节省前端资源。我司前端资源紧缺,这算是设计系统的一个很重要的初衷了;
第二个好处是节省测试资源。因为页面是配置的(实际上我们的数据解析也是配置的),所以后面接入新的业务的时候,基本上就是看看配置对不对,并不需要测试的介入;
第三个就是吹牛逼。是的,别人问你做了什么,可配置页面总比一个个页面开发过去要牛逼多了;
免责声明
其实我是一个后端开发,不过因为一些机缘巧合的东西,所以让我设计了这么可配置页面的解决方案,颇有一种赶鸭子上架的感觉。我只能凭借我寥寥几个月的前端开发经验,设计出来了那么一套东西。虽然我在实际使用中感觉很好用,但是我同事也觉得问题多多。因此我最后要说的就是,答案仅供参考。