2012年来到点乐之后,我开始投身Web应用开发。当时我选择Backbone作为主力框架,刚开始做难免会带着各种旧习惯,只有一边使用一边摸索。最近我终于搞明白使用Backbone的正确姿势,记叙于此,希望能让后来者少走些弯路。
使用框架时,我的原则是:既然使用了这个框架,就应该按照这个框架的思路解决问题,一定要把它的功能都用上,要按照它的方式组织代码,这样才对得起学习使用的成本。所以下面的“正确姿势”,自然也是奔着全面使用Backbone的内建功能、尽量符合Backbone的设计思路,这样的目的总结的。
本文假定读者熟悉JavaScript,对Backbone有一定程度的了解。
View
我比较同意《JavaScript设计模式》中的观点,Backbone的设计很难讲是更接近MVP还是更接近MVC,而是在Web前端这个大环境下,基于其技术特点,吸取各种设计模式的优点做出最合适的的实现。Web应用中,视图的实现自然应该交给HTML和CSS负责,而在数据变化时更新视图,以及响应用户操作的事情,就交给Backbone.View
。
Backbone.View
提供非常直观的events
帮助我们注册事件,免受使用jQuery的.on.on.on
之苦。事件被委托给$el
,不仅节省资源,并且做列表类应用需要操作多个子节点时,无需再绑定事件。
不过请注意,这里的事件委托仍然有赖冒泡机制,所以诸如load
、error
等不冒泡的事件无法委托给$el
处理(submit
在早期IE下也不行,但是jQuery会代发冒泡版),只能手工侦听具体节点。另外,Backbone会把处理函数代理给View
的实例,所以函数中的this
指向是实例,而不再是触发事件的DOM元素。
列表的前世今生
接着,来点代码,看看最常用的UI范式——列表。Backbone中,搭配使用Backbone.Collection
和Backbone.View
可以方便的实现列表类组件,不过因为Backbone本身的限制很少,实现方法很多。早期我习惯于通过reset
方法和reset
事件来操作列表,后来慢慢体会到,接下来的做法更合适。
HTML部分:
// 这里假设我们要做一个todo列表
<ul>
<script type="text/x-handlebars-template">
<li id="{{id}}">
<input type="checkbox" name="todo" value="{{id}}">
{{title}} <time datetime="{{create_time}}">{{create_time}}</time>
</li>
</script>
</ul>
JavaScript部分(使用Handlebars作为模板引擎):
var ListView = Backbone.View.extend({
fragment: '',
events: {
},
initialize: function () {
this.template = Handlebars.compile(this.$('script').remove().html().replace(/r|n|s{2,}/g, '');
this.collection.on('add', this.collection_addHandler, this);
this.collection.on('remove', this.collection_removeHandler, this);
this.collection.on('sync', this.collection_syncHandler, this);
this.collection.fetch();
},
collection_addHandler: function (model) {
this.fragment += this.template(model.toJSON());
},
collection_removeHandler: function (model) {
this.$('#' + model.id).remove();
},
collection_syncHandler: function () {
if (this.fragment) {
this.$el.append(this.fragment);
this.fragment = '';
}
}
});
之所以建议这么做,因为Backbone.Collection
有三个特点:
model
的事件会由collection
向外转播(相当于冒泡)- 取得数据后,
collection
会逐个创建model
,每次都会广播add
事件 .fetch()
后.set()
时,新数据中不曾出现的对象(以id
为标识)会被移除
列表中还有一些技巧,将在后文呈现。
钦差大臣options
早先取数据时为了向服务器传递变量,或者使用特定的jQuery参数,我经常覆写.sync()
方法,甚至直接使用$.ajax()
。后来发现,各函数的options
(除去少数几个return
之外)都会传递到下一个函数,直至最后;期间每一步的参数都会合并进来向后传递。
所以我们只需要在第一次调用函数时,将需要的值放在options
里即可 。比如,要保存model
里的数据,API服务器和当前服务器不在同域,就可以这样:
// 关于xhrFields,可以参考jQuery文档:http://api.jquery.com/jQuery.ajax/
model.save(null, {
xhrFields: {
withCredentials: true
}
});
其实,options
是个很巧妙的设计。Backbone作为框架,必须给其它库和业务逻辑留出足够的空间,使用options
,随便其他开发者传什么值,最后都能传回业务逻辑中。另外,当参数个数比较多的时候,使用options
也有助于阅读代码。
options
进阶
基于“从头传到尾”这个性质,我们还可以发明一些特殊用法。比如上一节的例子,我希望给每个元素增加一个删除按钮,点击后移除元素。重点是:以渐隐动画来表现移除动作。在Backbone中,.destroy()
方法会通知服务器删除对象(.remove()
方法只是从当前集合中移除model
,无法满足需要),并且触发destroy
事件,我们可以在这里插入动画;但立刻就又会触发remove
事件,所以只是在collection_destroyHandler
的时候fadeOut
是不够的,还要防止collection_removeHandler
在动画结束前直接移除dom。
这个时候,我们就可以利用options
。destroy()
支持参数{wait: true}
,可以等待服务器返回成功后才移除model
。于是我们就能确保对象已经从服务器上清除后,再以视图体现;同时,只要检查options
里是否包含这个属性,就可以知道当前触发collection_removeHandler
的是.destroy()
还是.remove()
,再决定是否立刻移除节点就很容易了。(其实随便传个什么标记都可以,这里使用{wait: true}
可以获得更好的体验。)
// 一致的代码我就不写了
events: {
'click .delele-button': 'deleteButton_clickHandler'
},
initialize: function () {
// 一致的代码
// ....
// 不再重写
this.collection.on('destroy', this.collection_destroyHandler, this);
},
collection_destroyHandler: function (model) {
this.$('#' + model.id).fadeOut(function () { $(this).remove(); });
},
collection_removeHandler: function (model, collection, options) {
if (!options.wait) { // 没有wait
this.$('#' + model.id).remove();
}
},
deleteButton_clickHandler: function (event) {
var id = $(event.target).closest('li').attr('id');
this.collection.get(id).destroy({wait: true}); // 服务器返回确认才真正移除
}
利用options
能达成的效果还有很多。比如,有些时候我们想往model
里放一些特殊用途的数据,只在渲染时候用,不保存到服务器上。通过研究源码我们发现,同样调用.toJSON()
,.save()
会传入含有各种参数的options
,而手动调用则不会。于是我们又能根据options
里的属性返回不同的数据,满足不同的需要。
这些实现本文不再一一详述,大家请自行琢磨,欢迎留言探讨。
和服务器步调一致—— sync
& fetch
有些习惯延续自之前的项目,比如请求远程数据。早期我总想用$.ajax
从服务器端把数据取来,然后再reset
给collection
或者model
。直到最近开发新项目:tiger-prawn + lemon-grass(也即点乐后台V5),我开始开发RESTful的后端,配合专为RESTful设计的Backbone,贯彻“同步”思路,于是我终于醒悟,发出文章开头那句感慨——我终于明白使用Backbone的正确姿势了。
“同步”是Backbone非常重要的设计思路,也是用户体验里非常重要的一环。我们经常要面对多端的环境,尤其是开发企业级应用,多人协作办公,必须保证数据在每个终端看起来一致。我一开始很难理解为什么.fetch()
回来数据后,除非指定{remove: false}
,否则新数据中不再存在的对象会被移出collection
。这个疑问后来在开发集体协作的todo列表时得到解答。
还拿前文的todo列表做例子。想象列表面向一个工作组,组内成员均从列表当中接受工作,完成后勾上checkbox表示结案;别的地方还会有需求源源不断的塞入这个列表中。这个时候,数据同步就显得尤为重要。某甲勾掉一条任务,需要从其他人的列表中勾掉同一条任务。由于我们数据交互的主要手段仍然是Ajax,服务器无法向浏览器发出指令,只能由浏览器通过返回值进行操作,所以,将“同步”作为强制性要求,本地collection
根据返回值的变化,该修改的修改,该移除的移除,就是最佳选择了。
trigger: false
未必都有用
最后再说个小问题。有时候我希望改变路径,但不要刷新页面,比如创建文章/article/create/,
.save()后得到文章id,跳转到
/article/id。因为仍然处于编辑状态,所以不需要刷新页面。一开始我以为
router.navigate(‘#/article/id’, {trigger: false});,加个参数
{trigger: false}`就可以防止路由生效,后来发现不行。
出于种种原因,比如服务器没做重定向,我一直没用pushState
。此种状态下,Backbone使用setInterval
检查地址栏变化,所以只要地址栏有修改,就会触发相应的路由。
相关项目
下面是我做过并且在维护的一些使用了Backbone的项目。这些项目未必都做到了以上几点,所以仅供参考。
团队培训项目和团队培训项目二期,其实从这两个项目中就能看出我思路的变化。
游戏宝典 手机应用,虽然项目停摆了……找时间更新移植到phonegap上。
总结
Backbone是一个轻量级,入侵程度很低的框架。可以很方便的结合各种其他库来使用,对使用者的要求也很少,易于学习。不过如果能以正确的姿势操作,又能达到事半功倍的效果。这篇文章写得很费劲,有些东西总是感觉话到嘴边写不出来,反复修改多次对最后两段仍不满意。哎,以后再说吧,先发了。
欢迎吐槽,共同进步