原文地址:http://blog.shinetech.com/2011/07/25/cascading-select-boxes-with-backbone-js/
近几年,从软件设计的角度来看,客户端Javascript开发似乎在野蛮生长。jQuery之类的类库确实有一定帮助,但是随着单页应用的兴起,仅仅使用jQuery作为大型客户端开发的总体框架是不够的。幸运的是,最近Javascript MVC框架开始激增,有大型的也有小型的。Backbone.js就是其中之一。它是轻量级的,和jQuery一起使用(尽管Backbone.js并不需要jQuery),在当时它的出现似乎有某些契机。
Backbone.js不像Rails那样特别大或者opinionated。对于一个专家来说,这或许是好事,但是对于初学者却不是很好。Backbone.js的文档完整,然而加上dots对新手来说有时候可能有点令人生畏。简单的围绕文档的教程介绍了怎么把单一模型挂载到单一视图上,但是并没有明确说明怎么使用更复杂的UI来构建应用程序。什么应该放在模型里?什么应该放在视图里?模型和视图应该如何交互?
我最近有机会参与了一个有一定规模的Backbone.js应用程序的开发。在这篇文章中,我会演示一个比单一模型和视图大一点的例子。此外,我会用迭代的方式来演示,而不是一下子把所有的都展示给你。你将会看到,Backbone.js以一种MVC的风格,为构建应用程序提供了良好的基础,尽管你将会面临和其他MVC框架一样的设计决策。
请注意,这不是一篇Backbone.js的入门教程,并且假定你对框架如何工作有一点背景知识。
示例
我们将使用一个比单一模型和视图大一点的例子:经典的”级联选择“界面。这里我们将使用一系列HTML select元素来展示分层数据。当你选中一个选择框中的条目,二级选择框的选项列表会重新填充。因此,这是一个不管怎样,视图之间都有交互的例子。这也是一个很难正确实现的例子。
在这个例子中,我们的级联选择框会展示位置,也就是country、这些country中的city、city中的suburb。它的界面是这样的:
当然它不会赢得任何选美比赛,但是这不是我们的目的,不是吗?
在界面上,我们已经有了一个分层的数据结构,Country有很多City,City有很多Suburb。此外,我们假设所有数据是能通过Rails风格的RESTful URL获取的JSON格式。进一步来说:
开始
首先开始基础热身:加载国家列表,我们先看看HTML代码:
<html>
<head>
<script type='text/javascript' src='javascripts/jquery-1.6.2.js'></script>
<script type='text/javascript' src='javascripts/underscore.js'></script>
<script type='text/javascript' src='javascripts/backbone.js'></script>
<script type='text/javascript' src='javascripts/application.js'></script>
</head>
<body>
<form>
Country:
<select id="country">
<option value=''>Select</option>
</select>
City:
<select id="city" disabled="disabled">
<option value=''>Select</option>
</select>
Suburb:
<select id="suburb" disabled="disabled">
<option value=''>Select</option>
</select>
</form>
</body>
</html>
然后是application.js,使用Backbone 来填充Country
$(function(){
var Country = Backbone.Model.extend();
var Countries = Backbone.Collection.extend({
url: 'countries',
model: Country
});
var CountryView = Backbone.View.extend({
tagName: "option",
initialize: function(){
_.bindAll(this, 'render');
},
render: function(){
$(this.el).attr('value',
this.model.get('id')).html(this.model.get('name'));
return this;
}
});
var CountriesView = Backbone.View.extend({
initialize: function(){
_.bindAll(this, 'addOne', 'addAll');
this.collection.bind('reset', this.addAll);
},
addOne: function(country){
$(this.el).append(
new CountryView({ model: country }).render().el);
},
addAll: function(){
this.collection.each(this.addOne);
}
});
var countries = new Countries();
new CountriesView({el: $("#country"), collection: countries});
countries.fetch();
});
我不打算对这段代码做深入讲解,因为这不是Backbone.js的入门教程。但是如果你不能理解代码是在哪开始运行的,可以从countries.fetch()被调用开始看。当countries.fetch()被调用时,集合countries访问/countries URL地址,获取返回的JSON数组对象,把它们转换成Country模型对象,使用Country模型填充自身,并触发‘reset’事件。
然后,倒数第二行创建的CountriesView对象在监听‘reset’事件,触发自身的addAll方法。对于集合中的每个Country,创建新的CountryView,执行render()方法并把结果追加到id为‘country’的HTML元素上。这样我们的UI就得到了更新。
注意:在这种情况下,CountryView#render只简单的返回包含country ID和name的HTML option元素,我们可以用一个HTML模板来生成,但是在这里有点矫枉过正了。
*更高级的Backbone.js开发者请注意:是的,我知道/countries返回的JSON数据可以直接插入到页面中,并且可以使用Backbone.Collection.reset()直接装载进countries集合中,这样就少了一次服务器请求。但是,我试着使事情保持简单,轻松的引导读者理解从服务器加载数据的常规机制,也就是Backbone.Collection.fetch()。
继续往下讲
我们可以用同样的方法来渲染其他选择框。让我们前进一小步,添加cities集合,渲染视图。下面代码中高亮显示的行是更改或新增的代码段:
当然,城市列表还没有被填充,因为我们不知道该用什么填充。要实现这点,我们需要检测countries列表的条目什么时候被选中,并作出响应。此外,country视图要能访问city视图。
好了,废话少说。跟着我进行下一步(我没有把所有的代码都放在这里–只有新的代码片段,和一些上下文)
这段更新的代码中,我们设置LocationView监听它的元素的“change”事件。当“change”事件触发时,传递新的value值给setSelectedId方法。这个方法会推迟到子类执行。在CitiesView的子类,这个方法什么都不做。在CountriesView的子类,setSelectedId方法获取cities集合,并从对应的URL获取内容–反过来触发上文讨论过的cities的render方法。最后,我们使city选择框可用,然后就能看到内容了。
免责声明:这篇文章中的代码没有做减少服务器调用的优化。但是,这不是我们的目的–我会把优化的问题留给读者来解决。
总结一下,通过让一个视图访问另一个视图,我们可以进行一些基本的交互。这没有什么神奇的地方–毕竟,视图只是可以任意设置属性的Javascript对象。我们可以以全局变量访问视图,但是以我的经验看,这样会产生各种难以跟踪的bug,也意味着你不能在程序的任意地方使用这些代码。通过减小变量的作用域,我可以,比如,有两个完全不同的country和city导航选择框,而不与现有的选择框有任何交叉作用。
在我看来,这大概是Backbone.js框架最棒的事情之一–给你一个能把视图和模型代码组合起来的简单的模式,同时使你能控制作用域,重用这些代码。
然而,在我们更深入的讨论重用的可能性之前,我们需要先确保这些选择框能实际上正常工作。
清除和重建
不幸的是,上面的解决方案是经不起推敲的。例如,如果你选择另一个country,city列表会添加更多条目,而不是清除后再重新填充。结果,选择框看起来就是这样的:
这看起来一点都不酷。
为了解决这个问题,当我们渲染location时,我们需要跟踪那些把元素添加到DOM上的location视图。这样,当我们要渲染新的location视图时,我们可以先清除旧的元素。代码如下:
这很简单,为了避免你有疑惑,请注意,_.each不会做this.locationViews没有定义过的事情。
延伸到Suburbs
现在我们已经实现了基础部分,让我们来继续实现通过选择城市设置郊区的功能吧。
这里没有新的东西可解释,这真的很酷,我们通过实现LocationsView的子类,就得到了city选择框填充–重新填充的逻辑。
但是,还有一点诡异的地方–如果我们选择一个country和一个city,再回过来选择一个新的country,city选择框会被重新填充,但是suburb会显示成这样:
我告诉过你,这个功能是很难实现的,对吧?
要正确做到这一点,我们可能需要在清除suburbs集合的同时禁用suburb选择框。
注意使用Backbone.Collection.reset()函数。这将会清除集合并触发‘reset’事件–我们已经知道,视图正在监听这个事件。
删除重复代码
这时你或许开始注意到在view代码段中存在一些重复代码。有很多方法可以删除这些重复代码,当UI界面变得更复杂的时候,这些代码也会变得更复杂。这种情况下,我们可以实现Locations超类的两个方法:
附加点:直接设置一个Suburb
让用户能通过下拉选择country和city来选择suburb,这是极好的,但是如果我们想设置suburb预选项呢?我们不仅需要选择suburb,而且要选择suburb所在的city和city所在的country。此外,我们需要用city中所有其他suburb填充suburb列表,并用country中所有其他city填充city列表。
要实现这一点,我们来介绍一点RESTFul URLS的东西。
一旦我们在正确的位置得到了这些数据,我们就可以采用蛮力解决问题的方法:给定一个 suburb ID,查找suburb的所有记录,并获得它所在的city ID。然后查找city中所有其他suburb,显示出来,选中那个给定的suburb就行了。然后逐个重复这一步骤:查找city的所有记录,获取它所在的country ID,查找country中所有其他city并显示出来。让我们先给定ID为3,来自动加载suburb:
可以看到,我们在文件的顶部和底部做了一些改变。在顶部,我们必须为City和Suburb设置urlRoot属性,以便这些模型的实例能在它们的后端被加载,而不是必须成为集合的一部分。在文件的底部,我们通过ID获取指定的suburb和city实例,填充suburb和city列表。注意:每次fetch调用触发后端服务器的异步调用时都有回调函数。
我们也尝试对每个view设置选中值。但是,运行的时候是这样的:
发生了什么?city和suburb选择框的填充列表正确,但是没有选中正确的选项,为什么是这样呢?
原因在于,调用populateFrom也触发了后端异步调用。但是我们没有等到这些异步调用返回结果就试图选择列表的值了。因此,列表中什么都没有,调用val()也没有任何效果。事实上,country列表也是如此;不能保证我们选择列表项的同时就被正确填充了。这是一种竞争状态;在上面的截图中,很幸运它起作用了。
为了解决这个问题,我们必须要小心了:
在填充之前,我们先为LocationView设置selectedId属性。然后,在底层集合已被填充并且其余部分view被渲染之后,我们用selectedId设置选中项。最后,要确保选中父view的元素时清除子view的selectedId。
现在,我们加载页面,就能正确运行了:
清理
添加这个功能时引进了一些重复代码,现在让我们回去重构下代码:
这样,我们已经提取出了CitiesView.setCountry和SuburbsView.setCityId功能。可能还有更大的重构空间,但是我们不需要做那么巧妙。
下一步该怎么做?
在这篇文章中,我们看到了如何使用Backbone.js添加一些必须的步骤来解决从后端动态填充级联选择框的问题。
这个解决方案可以通过很多方法改善。一种方法是把更多的交互逻辑放到collection类中,让视图之间只能通过底层集合间接交互。这意味着是集合之间相互引用,而不是视图。根据我的经验,这可以用更少的代码实现,但是间接引用的增加也会带来更加难以预测和追踪副作用的问题。
另一种方法是,通过定制和预定义事件– a capability built into the very core of Backbone.js,来进一步分离组件。但是,这也会使代码难以跟踪。对于像这个例子中简单的用户界面,这种方法也是矫枉过正了。
那些熟悉编程用户界面的人会意识到,在使用MVC框架构建软件的过程中,这种设计决策是普遍存在的,并不是只有Backbone.js才有。Backbone.js做到的,是为使用Javascript构建复杂用户界面提供一种理想的基础。它是面向浏览器事件特点和DOM模型的,但是不会把你固定到任何特定的用户界面库。按你的方式去理解Backbone.js基本工具和模式,你的代码可以扩展来满足用户界面的复杂性。我期待看到更多的人分享他们使用类似Backbone.js构建复杂用户界面的经验。
原作者github项目地址:CascadingSelectsWithBackboneJS
原文时间:2011年
翻译时间:2015.10.13
注意:作者使用的unserscore、backbone和jquery不是最新版本