使用Backbone的正确姿势

2012年来到点乐之后,我开始大规模开发Web单页应用。期间Backbone一直是我的主力框架,不过直到最近我才明白使用Backbone的正确姿势是什么。希望本文能让后来者少走些弯路。

日落

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,不仅节省资源,并且做列表类应用需要操作多个子节点时,无需再绑定事件。

不过请注意,这里的事件委托仍然有赖冒泡机制,所以诸如loaderror等不冒泡的事件无法委托给$el处理(submit在早期IE下也不行,但是jQuery会代发冒泡版),只能手工侦听具体节点。另外,Backbone会把处理函数代理给View的实例,所以函数中的this指向是实例,而不再是触发事件的DOM元素。

列表的前世今生

接着,来点代码,看看最常用的UI范式——列表。Backbone中,搭配使用Backbone.CollectionBackbone.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有三个特点:

  1. model的事件会由collection向外转播(相当于冒泡)
  2. 取得数据后,collection会逐个创建model,每次都会广播add事件
  3. .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。

这个时候,我们就可以利用optionsdestroy()支持参数{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从服务器端把数据取来,然后再resetcollection或者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是一个轻量级,入侵程度很低的框架。可以很方便的结合各种其他库来使用,对使用者的要求也很少,易于学习。不过如果能以正确的姿势操作,又能达到事半功倍的效果。这篇文章写得很费劲,有些东西总是感觉话到嘴边写不出来,反复修改多次对最后两段仍不满意。哎,以后再说吧,先发了。

作者: meathill

爱编程,爱旅游,爱吐槽。 今年的目标是完成并运营至少一个 Side Project。 《Electron + Vue 实战开发》龟速创作中……

欢迎吐槽,请勿装死

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据