MVVM时代的Web控件 ——基于AngularJS实现
前不久,Yahoo宣布了一个消息,停止YUI框架的开发,令人很多感慨。YUI作为最知名的控件库之一,影响了几乎整整一代前端开发人员。
现在这个时代,除了最基本的模式,控件库已经被极大地多样化,差异化了,所以,不用尝试在一个控件中考虑太多,你再考虑也考虑不完需求,反而会把代码变得臃肿。
一些前端MV*模式和Web Components的流行,使得我们可以用更轻松快捷的方式组织界面,在这个过程中,需要重新考虑控件和普通业务界面的分界,控件的概念实际上已经淡化了。
是不是我们就不再需要Web控件库了?也不是。在很多场景下,还是会出现一些固定的UI模式,如果有较好的封装,会对业务开发提供很大的便利。现在流行的新框架很多,AngularJS,Polymer,React,每种都有自己的一套理念,目前各自的生态圈都是不如jQuery的,但我们如果硬把jQuery的控件拖过来,也会很别扭,那怎么办呢?
毛主席教导我们,自己动手,丰衣足食。全新的引擎,就应当有全新的外围,不能开着坦克还射箭,我们试试来自己搞一下。
用每种框架实现控件,都需要遵循它的理念,利用它的优势特性,然后用一些特殊优化来绕过它的弱点。
本文主要基于AngularJS框架,对构建一个Web应用中可能会面临的“控件”进行一些探讨,用下面几个典型的控件实现来大致说明它们的理念差异。
控件的分类方式
对于不同类型的控件,我们的处理方式是不同的,先作一下分类:
容器
所谓容器,意思是主要用于放置一些东西,最多也就做一下状态切换之类的工作,比较典型的是Panel,TabNavigator,Accordion。
在ExtJS这样的控件库里,会有Panel这种纯静态容器,这样的容器其实完全没有封装的必要,封装了可以省一点点编码量,但无足轻重,所以我们无视它。
另有一些界面容器,之前我们会把它做成控件,比如Accordion,TabNavigator,但其实它的内部实现并不复杂,无非是选中项切换,创建或者移除子项等等,而且,还要顺便提供一大堆的参数配置,用于实现界面。
比如,jQuery UI的Accordion实现:https://github.com/jquery/jquery-ui/blob/master/ui/accordion.js,内部封装了各种DOM操作,把HTML打成碎片混在JavaScript中,如果想要做一些UI层面的调整,非常困难。
在MVVM时代,如果借助数据绑定的力量,有可能把这个控件的实现改得跟原先完全不一样。
在AngularJS的主页上,有这么一个Demo:
<tabs>
<pane title="Localization">
</pane>
<pane title="Pluralization">
</pane>
</tabs>
这个Demo做了两个自定义的标签,用于简化实现TabNavigator的HTML代码。原先我们可能要这么写:
<ul class="nav nav-tabs" role="tablist">
<li class="active"><a href="#">Home</a></li>
<li><a href="#">Profile</a></li>
<li><a href="#">Messages</a></li>
</ul>
<div class="tab-content">
...content
</div>
它这么一搞,就比较语义化,代码的可读性增强了,但灵活性丢了很多,想要好用,至少还有一大堆配置项,比如我们想要在每个tab上加个关闭按钮,简直很麻烦。
把容器封装成指令,基本上都是得不偿失的事情,我们还是直接一些,来看一个简单的代码:
http://jsbin.com/homas/6
怎么样,是不是很简单?
因为像Accordion这类控件,内部的逻辑无非是对数组的增删改,使用AngularJS的双向绑定机制,可以极大简化低级的DOM操作,直接用清晰的逻辑把整个业务表达出来,然后给UI充分的自由度。
类似,TabNavigator这个实现选项卡的功能,也可以用一样的方式实现,甚至它的模型跟Accordion都是一样的。看我们的改写:
http://jsbin.com/homas/8
怎样,我们就直接用着Accordion的模型,搞了完全不同的另外一个东西出来,有了这样的方式,还要控件干什么?
数据列表
所谓列表型控件,侧重于数组型数据的表达和展示,比如,List,DataGrid,Tree,一般会有选中等操作。
简单列表
简单的列表其实跟上面的TabNavigator、Accordion的模型一样,就是把数组迭代而已,也基本没有继续成为控件的必要性。
简单的列表可能包含哪些形态呢?
- 纵向的列表,子项纵向排成单列
- 横向的列表,子项横向排成单行
- 下拉列表,子项通过某种操作触发展开
- 瓦片列表,子项流式布局
这些其实都是简单的数组迭代,唯一的差别在样式上。
这个示例给出了纵向列表和瓦片列表的大致代码,它们使用同样的数据源:
http://jsbin.com/yeciga/1
数据表格
比列表稍微复杂一些的是表格。表格是一种很常见的展示多列数据的方式,那么,表格有没有必要封装成控件呢?
这个要看业务场景有多复杂。常规的表格是可以不用封装成控件的,只要普通带样式的table,tr跟td绑定到数据源,选中样式再处理一下,就差不多可以了,单元格里面还可以包含各种复杂的其他组件,都很容易。即使是表头需要排序,这个模板也比较容易写。
那什么样的场景需要封装控件呢?比如说,行列都比较单一,纯展示文本数据,表格头可能要带拖动列宽等效果,总之,在表格数据源之外的方面作了比较多的工作,这种就可以搞成控件。
树形结构
比数组型数据更复杂的是树形数据的展示。
其实,树形结构是一个比较麻烦的东西,任何一个前端的MV*框架,都会希望你把数据模型尽量扁平化,避免过深的层次,而树恰好是反着来的,所以这就导致对树形数据的展现非常别扭。
有一些用AngularJS实现的树控件,使用了递归的数组绑定来实现,写起来确实是很简洁的,但效果不一定好,因为它的监控机制在这种场景下有较大的浪费,比如说,树节点的选中样式绑定到一个监控表达式,当很大数据量中一个节点被选中的时候,可能会要把所有的监控都跑一遍。当然这里的数据模型设计也是会有一些技巧的,比如,改在单个节点上存放选中状态,用于判定样式,会比依赖于控件级的selectedNode变量效率要高不少。
那么,对于这类控件,有什么好办法吗?
我觉得这个东西有两种思路:
- 如果预先知道数据量不大,用递归的方式也无妨。
- 如果数据量比较大,就不使用angular自身的绑定机制来做,而是在directive内部自行控制,做精确的DOM变更。React框架的Virtual DOM技术处理这种情况会有不少优势。
DOM辅助
这类控件比较典型的是ContextMenu,它的核心特征在于:自身的DOM一般直接从属于document对象,或者某个特定容器,不属于触发它的界面部件。AngularJS讲究的是分层、有序,尽量避免DOM操作,但这类控件的特点使得我们不太容易建立它的映射关系,因而不得不从DOM层面入手。
怎么办呢?
我们可以把两种截然不同的东西分离出来,比如说,右键菜单,它的菜单本体使用数据绑定来实现,而用DOM事件来控制它的弹出和关闭过程:
<ul class="dropdown-menu">
<li ng-repeat-start="menu in menuArr" ng-if="menu.action">
<a>{{menu.title}} {{aaa}}</a>
</li>
<li ng-repeat-end ng-if="!menu.action" class="divider"></li>
</ul>
$http.get("templates/menu/menu.html").then(function(result) {
var menu = angular.element(result.data);
$compile(menu)(angular.extend($rootScope.$new(), {
menuArr: scope.$eval(attrs["snContextmmenu"])
}));
element.on("contextmenu", function (evt) {
if ($document.find("body")[0].contains(menu[0])) {
menu.css("display", "block");
}
else {
$document.find("body").append(menu);
}
//这里根据事件设置一下菜单位置
});
$document.on("click", function (evt) {
menu.css("display", "none");
});
});
这类控件的处理方式基本上跟传统的是类似的,一般来说只有很特别的,状态跟事件相关的才需要这么做。像下拉式的菜单就不必通过这种方式,因为它的弹出层可以跟自身的DOM放在一起,绑定一个状态变量,当点击触发的时候,把这个变量改变掉就可以了。
公共服务
什么叫做公共服务型呢?是那种直接插入代码流程的UI展现,比较典型的是自定义的Alert,Dialog和Hint,这类代码的调用,是不涉及DOM操作,也不涉及绑定的,设计成公共服务会比较好。
比如说,当业务操作成功,要给出一个统一的提示,就让他用这样的API:
比如,要弹出自定义的确认取消对话框,就这样:
AlertService.confirm(param)
.then(confirmHandler, cancelHandler);
又比如,要弹出自定义的对话框,就这样:
DialogService.modal(param, data);
有些使用AngularJS的人会有认识上的误区,认为一切DOM操作都应当放在directive中,并不是这样,要看这个操作是干什么的,如果它起的是一个公共服务的作用,对业务来说不存在关联关系,那就应该设计成service。放在directive中的东西,应当是可以当做一个“组件”来使用的,
独立功能块
这类控件一般是独立功能的区块,跟外界的联系是松散的,跟业务界面没有明显的区别。主要通过事件来通讯,比较典型的是Calendar,Pager。
用一个分页控件pager来举例,它每次在当前选中页变更的时候对外发送一个事件,外界监听这个事件,并作出相应的操作。分页在很多管理系统中,真是一个很常见的东西。有些UI框架会把分页功能跟数据表格等控件捆绑,内置为它们的一个选项,这么做其实有不少缺点。
首先是增加了控件本身的逻辑复杂度,其次是不灵活了。
当分页控件独立出来之后,它如何跟外界交互呢?这其实跟普通的多块界面部件之间的通信并无差异,实际上,界面部件自身并不通信,因为他们都只是实例化之后的视图模板,真正可以用于通信的是随同它们一起实例化的视图模型,也就是AngularJS中的控制器,在控制器上通过$scope可以进行通信。
所有随界面模板实例化的$scope都挂在$rootScope为根的树上,然后通过事件进行通讯,从上往下是$broadcast,从下往上是$emit。当然,也可以自己造一个事件总线用于跨层级通讯。
那么,对于分页控件这样的东西来说,应当怎样去跟包含它的界面通信呢?
在有些基于AngularJS的控件库中,分页控件直接操作$parent的数据,在我看来,这不是一种好方式,原因稍后说。对于此类控件来说,使用事件与外界交互是最自然的方式,它使得界面组件之间的耦合性大幅降低。
表单增强
比较典型的是DatetimePicker,ColorPicker。
这类控件其实也可以算独立功能型,作这样的划分,主要是考虑到在大部分MVVM框架里,原生的input,select,textarea等都是有特殊增强的,可以直接跟数据模型绑定,它们跟外界唯一的交互就是数据模型。对于像DatetimePicker这样的控件,其实业务方并不关心它内部是怎么实现的,做了什么操作,只需要关注最后的选中值,从这一点来看,它跟普通的input并无区别。
这类控件是最适合封装成Angular指令(directive)的。
现在我们有些纠结了,从形态来说,表单增强类控件跟上面这种独立功能块的差别在哪里?为什么把分页划分到独立功能,而把DatetimePicker划分到表单增强呢?
分类的原则不是说它像不像表单元素,而是它是否应当能直接访问包含它的界面块的数据模型。
对于表单增强型的控件,设计思路一般是没有歧义的,大家都会让它直接绑定数据模型。那独立功能型的控件,为什么不能让它直接绑定数据模型呢?
这个差别主要来源于控件和数据模型的“距离”。表单增强型控件跟数据模型的距离非常近,因此它直接使用数据模型没有问题,但是界面增强型控件,很可能这个距离较远,比如说,至少要从父级视图模型中转一下。
设想我们要构建一个多widgets的门户,其中有一个widget是个日历,使用了Calendar控件,这个日历取值变更的时候,可能影响其他到其他widgets的行为。如果我们让它能访问父级数据,会导致系统结构变得混乱,所以只能限制它用事件。
动画
那么,碰到一些要使用动画的情况,该怎么办呢?
传统的方式,用JavaScript去根据浏览器的支持度,封装不同的实现,通常是三种:JavaScript动画,CSS Transition,CSS Animation。
在AngularJS中,如果用于状态变迁的动画,用后两种非常方便,只需要把各状态对应成CSS样式类,然后使用ng-class来绑定样式名就可以了。
如果是专门的动画效果,可以用directive封装起来,根据特征的不同,选择封装成元素或者属性。
图表
以AngularJS为代表的MVVM框架,使我们能够远离烦琐的DOM操作。我们回想在业务中使用的不同控件,似乎还有一类没有覆盖到,那就是图表库。
传统的JavaScript图表库,有些是基于Canvas的,从实现机制上来说,无需依赖jQuery这样的DOM操作库,这类通常封装了自己的基础操作,自成一体,本身做得很优秀,典型的有百度的ECharts。如果想把它跟Angular这样的框架集成,一般来说在上面套一个directive的壳即可,在内部调用真正的实现。
注意到还有另外一些图表库,核心是适配了SVG或者VML实现的,比如说,基于RaphaelJS做的图表控件。我们看一下Raphael的常见代码写法:
for (var i = 0; i < 5; i++) {
paper.circle(10 + 15 * i, 10, 10)
.attr({fill: "#000"})
.data("i", i)
.click(function () {
alert(this.data("i"));
});
}
哎,这代码的样子怎么这么熟悉?像不像jQuery?因为使用SVG或者VML来显示图形,本质是跟DOM操作一样的,所以它也选用了像jQuery一样的代码方式。
我们大胆再想一步,普通的基于HTML元素的控件,我们不用jQuery了,而是通过绑定的方式,那图表库是不是也可以这样呢?
来尝试一下:
http://jsbin.com/yokik/1
是不是很有意思?
这个例子本身很简单,用来代替成熟图表库的话,可以说差得非常远,但它说明了我们有可能用怎样的思路去实现图表库。
传统图表库的缺点是,整个视觉方面都只能由程序员控制,对视觉方面有经验的人只能给出配色和布局的建议,然后等程序员实现了之后,再回头来继续提出建议修改。
使用我们提到的这种方式,就把数据逻辑和界面展现分离得非常好,可以像写普通HTML界面那样,分别由不同的人员协作,然后组装在一起。
如果我们想要把同样的数据换一种图形来展示,也会非常容易,不需要改变模型,只要把视图层换掉,立刻完成。
比如这个例子,使用了同一个数据模型:
http://xufei.github.io/ng-charts/index.html
这个例子还可以进一步封装成directive,以SVG片段作模板,从元素属性和上级作用域中获取参数,这样使用起来更便利。
小结
我们回过头来想一想,控件的本质是什么?是特定数据结构的交互展现。会有哪些数据结构呢?总结起来,真的是很简单,因为常见的就这么几种:
其他好像都没有了。
传统的控件,封装的主要逻辑是数据模型跟DOM之间的对应关系,而这种关系被AngularJS这种MVVM框架作为基础设施提供了。把代码重构之后,我们会惊奇地发现,控件的界面和逻辑分离得干干净净,我们可以复用这个逻辑,在不同的场景下把控件界面多样化,以此来面对不停变更的需求。
因此,在MVVM的时代,我们需要把控件库用与以往完全不同的方式来重新设计,去掉一些不再适合作为控件的,把其他的控件展现跟行为分离,让模型更精炼,给UI层更多的自由度,控件这个概念会淡化很多。
从这一点看,新的模式会对我们的HTML和CSS规划能力要求更高,因为之前在控件内部封装了DOM的处理,当需要整体调整的时候,有机会在控件这个层面去统一处理,但把控件界面分离并多样化之后,这部分压力就会转移到DOM和样式规划者手中。
所以,我们会发现,那些使用AngularJS的人,会很倾向于用BootStrap或者Foundation这类样式框架,因为对他来说,样式和界面结构规划变成了一个非常重要的事情了,而这类框架会帮助他们把这个部分的基础工作做好。
总而言之,把数据模型从控件中提取出来,把UI层配置化,是使用AngularJS这类框架的核心要点。
随着时代的发展,浏览器特性逐渐增强,新框架层出不穷,我们能够有机会选用一些较新的实现技术,大幅简化或者完全改变之前的实现方式。
未来会更加美好。
本文提到的一些控件的基础demo可以参见这里,因为比较仓促,所以问题还有很多,只是大致说明了构建不同控件的思路,以后会逐步完善。
http://xufei.github.io/ng-control/index.html