Backbone为什么还活着

写在前面

Backbone似乎是第一个被广泛接受的前端MVC框架,好像确实上年纪了(第一个release在2010年)。现在面对让人眼花缭乱的MV*框架,几乎很难再想起Backbone,更没有理由去选择它

真的是这样吗?还在用Backbone只是因为懒得换,现在还选Backbone只是因为情怀?

不是。因为它足够灵活,Backbone不是MV*框架里最强大的(实际上确实很弱),却是最灵活的,没有之一

一.Backbone v0.1.0

回到2010年,看看Backbone最初想做什么

结构

View        包裹DOM元素。结合jq事件代理,管理视图相关逻辑
-------
Collection  Model集合
    Model   数据结构
数据增删改查 -> 触发CRUD(create、read、update、delete) -> Backbone.sync() -> Server
-------
Events                      sync(method, model, success, error)
给任意对象提供自定义事件支持     完成CRUD到RESTful API的转换,由jq.ajax()发出请求

用图来说是这样的:

backbone-0.1.0

backbone-0.1.0

最底层最重要的部分是Events,M到V的通信、M变更通知Server都是由Events提供的自定义事件完成的,甚至有一种技巧是把Backbone对象自身当做事件总线来用,如下:

// 主题
var EVENT_DATA_READY = 'dataReady';
// 订阅
Backbone.on(EVENT_DATA_READY, function(data) {
    console.log(data);  // Object {res: 1}
});
// 发布
Backbone.trigger(EVENT_DATA_READY, {res: 1});

Backbone提供的所有类(Model、Collection、View,以及最新版本的Router、History)都是基于Events的,所以,可以在任何一个Backbone实例上使用自定义事件,比如Model实例:

var EVENT_BEFORE_CHANGE = 'beforeChange';
var Model = Backbone.Model.extend({
    // Overriding set
    set: function(attributes, options) {
        this.trigger(EVENT_BEFORE_CHANGE, attributes, options);

        // Will be triggered whenever set is called
        if (attributes.hasOwnProperty('prop')) {
           this.trigger('change:prop');
        }

        return Backbone.Model.prototype.set.call(this, attributes, options);
    }
});
var model = new Model();
model.on(EVENT_BEFORE_CHANGE, function() {
    console.log(arguments); // ["key", "value"]
});
// test
model.set('key', 'value');

model属性改变之前,我们发出了beforeChange通知,也就是说,如果Backbone提供的原生事件不够用的话,我们可以随意添加各种beforeXXXafterXXX,以及完全独立的自定义事件

数据同步

关键源码如下:

// Map from CRUD to HTTP for our default `Backbone.sync` implementation.
var methodMap = {
  'create': 'POST',
  'update': 'PUT',
  'delete': 'DELETE',
  'read'  : 'GET'
};

// Override this function to change the manner in which Backbone persists
// models to the server. You will be passed the type of request, and the
// model in question. By default, uses jQuery to make a RESTful Ajax request
// to the model's `url()`. Some possible customizations could be:
//
// * Use `setTimeout` to batch rapid-fire updates into a single request.
// * Send up the models as XML instead of JSON.
// * Persist models via WebSockets instead of Ajax.
//
Backbone.sync = function(method, model, success, error) {
  $.ajax({
    url       : getUrl(model),
    type      : methodMap[method],
    data      : {model : JSON.stringify(model)},
    dataType  : 'json',
    success   : success,
    error     : error
  });
};

在Model中说明资源对应的url,然后Model实例变化时触发CRUD,交由jQuery.ajax()通知Server

提供了RESTful API支持,但鼓励根据实际场景重写,或者不想用这一套数据同步机制的话,不在Model中说明url即可,没有任何强加的规则

路由

实际上Backbone第一版没有提供路由控制,只是提供了一个相对通用的工具集,用来实现MVC,连数据同步机制都算是赠品(可用可不用)

MVC

M和V都有明确的定位(分别对应Model和View),但C没有清晰的位置,只好分散在M和V里,数据校验、数据同步等业务逻辑放在Model里,交互逻辑放在View里

二.Backbone v1.3.3

设计理念

Philosophically, Backbone is an attempt to discover the minimal set of data-structuring (models and collections) and user interface (views and URLs) primitives that are generally useful when building web applications with JavaScript.

想在原始数据和界面之上,提出一个通用的最小集

所以这么多年过去了,仅添加了路由支持,最初的Events、Model、Collection、View几乎没什么变化

路由结构

Router      负责建立路由表
-------
History     实际实现路由控制,修改url,监听变动,提取URL参数并执行路由回调、触发路由事件

用图来描述是这样:

backbone-1.3.3

backbone-1.3.3

把路由分为建立路由表(Router)和路由控制(History)两部分,实际使用时是这样的:

var App = Backbone.Router.extend({
    routes: {
        // key为path模式,val为事件名
        '': 'index',
        ':list1ItemId': 'list2',     // 'rsshelper/2'
        ':list1ItemId/:list2ItemId': 'detail'   // 'rsshelper/2/5'
    }
});
//--- run
var app = new App();
// listen route events
app.on('route:index', function() {
    console.log('trigger route:index');
}).on('route:list2', function(list1ItemId) {
    console.log('trigger route:list2');
}).on('route:detail', function(list1ItemId) {
    console.log('trigger route:list2');
});
// 启用pushState,默认是hashchange
Backbone.history.start({pushState: true,
    root: location.pathname.replace('index.html', '')
});

在Router中建立路由表,通过Router实例监听路由事件,最后通过History启用路由

看起来由Router全权负责,实际上Router是History的依赖项,实际控制是由History完成的

路由实现

//--- History
// 跳转函数,传入的fragment需要手动进行encode
// 传入options {trigger: true}才会触发路由事件,默认不会
// 传入options {replace: true}才会盖掉当前URL,无法退回,默认是插入一条,退回
// pushState/replaceState | location.hash赋值/location.replace | location.assign(url)刷新
navigate: function(fragment, options) {
  if (!History.started) return false;
  if (!options || options === true) options = {trigger: !!options};

  // Normalize the fragment.
  fragment = this.getFragment(fragment || '');

  // Don't include a trailing slash on the root.
  var rootPath = this.root;
  if (fragment === '' || fragment.charAt(0) === '?') {
    rootPath = rootPath.slice(0, -1) || '/';
  }
  var url = rootPath + fragment;

  // Strip the hash and decode for matching.
  fragment = this.decodeFragment(fragment.replace(pathStripper, ''));

  if (this.fragment === fragment) return;
  this.fragment = fragment;

  // If pushState is available, we use it to set the fragment as a real URL.
  if (this._usePushState) {
//!!! backbone不使用state参数,传入了{}
    this.history[options.replace ? 'replaceState' : 'pushState']({}, document.title, url);

  // If hash changes haven't been explicitly disabled, update the hash
  // fragment to store history.
  } else if (this._wantsHashChange) {
    this._updateHash(this.location, fragment, options.replace);
    // 更新iframe url
    if (this.iframe && fragment !== this.getHash(this.iframe.contentWindow)) {
      var iWindow = this.iframe.contentWindow;

      // Opening and closing the iframe tricks IE7 and earlier to push a
      // history entry on hash-tag change.  When replace is true, we don't
      // want this.
      if (!options.replace) {
        iWindow.document.open();
        iWindow.document.close();
      }

//! 把iframe的window.location对象传过去了
      this._updateHash(iWindow.location, fragment, options.replace);
    }

  // If you've told us that you explicitly don't want fallback hashchange-
  // based history, then `navigate` becomes a page refresh.
  } else {
    return this.location.assign(url);
  }
  // 查路由表
  if (options.trigger) return this.loadUrl(fragment);
}

//--- Router
// Manually bind a single named route to a callback. For example:
//
//     this.route('search/:query/p:num', 'search', function(query, num) {
//       ...
//     });
//
route: function(route, name, callback) {
  if (!_.isRegExp(route)) route = this._routeToRegExp(route);
  if (_.isFunction(name)) {
    callback = name;
    name = '';
  }
  if (!callback) callback = this[name];
  var router = this;
  Backbone.history.route(route, function(fragment) {
    var args = router._extractParameters(route, fragment);
    if (router.execute(callback, args, name) !== false) {
      router.trigger.apply(router, ['route:' + name].concat(args));
      router.trigger('route', name, args);
      Backbone.history.trigger('route', router, name, args);
    }
  });
  return this;
}

实际建立路由表时(Router.route()中的Backbone.history.route()),把Router实例整个交给了History,所以History是在Router之上的

History中路由实现方式也是pushState -> onhashchange -> iframe poll的降级方案,但Backbone默认不使用pushState,需要手动启用pushState,这与其它框架的路由实现方案不同:

// 启用pushState,默认是hashchange
Backbone.history.start({pushState: true,
    root: location.pathname.replace('index.html', '')
});

服务端支持

Backbone路由需要服务端支持,如下:

Note that using real URLs requires your web server to be able to correctly render those pages, so back-end changes are required as well. For example, if you have a route of /documents/100, your web server must be able to serve that page, if the browser visits that URL directly.

至少要保证定义的路由可访问,强制要求路径可访问,对于不愿意/没必要做同步镜像站的单页面应用来说,这一点就很难受,大大限制了路由的灵活性

还需要注意root的问题

If your application is not being served from the root url / of your domain, be sure to tell History where the root really is, as an option: Backbone.history.start({pushState: true, root: “/public/search/”})

默认history.start()后直接跳转到/,而不是页面所在的当前路径,所以上面的示例做了一件很奇怪的事情:

// 启用pushState,默认是hashchange
Backbone.history.start({pushState: true,
    //!!! 把root改为当前路径
    root: location.pathname.replace('index.html', '')
});

虽然路由在单页面应用中有关键性作用,但毕竟不是业务逻辑,为个路由折腾这么多就显得麻烦了

三.Backbone与单页面应用

单页面应用至少需要:

  • 视图(假页面)

  • 路由(建立页面与URL的联系)

  • 模版(用于从缓存恢复页面)

  • 缓存机制(单页面应用的一大优势)

Backbone提供了前三个,但很难满足应用场景,因为:

  • 不支持子视图,而稍复杂的场景都需要嵌套视图

  • 路由很不好用,麻烦,且不灵活

  • 模版来自underscore,同样不支持子模版

当然,勉强要用的话,也是可以的,只是需要多做一些事情,多写一些代码,然后好处是一切尽在掌控之中

几乎所有细节都在掌控之中,这是很实在的好处,也是Backbone与其它框架最大的差别,约束少,非常灵活

但仍然不推荐单页面应用采用Backbone,因为太费劲了,没有必要,Angular显然更合适更方便

四.灵活性

作为框架,Backbone是最灵活的了,对比Angular等重量级选手,Backbone限制最少,自由度最高:

  • 原生View事件不够多。很容易自己添

  • 不想修改Model,非要手动操作DOM。随意,然后手动保持Model一致就好

  • 嫌性能太差,有很多不必要的render()。不用内置addremovechangesort事件了,需要渲染的时候再自定义事件通知View渲染

  • 嫌underscore模版太弱了,想换jade。随便换,反正view.render()是完全可控的

  • 嫌jQuery太大了,想换Zepto。可以,换这个几乎没有成本(如果没有用到Zepto不支持的jQuery API的话)

  • 嫌underscore太慢了,想换lodash。当然可以,完全没有成本

Backbone把DOM操作交给jQuery/Zepto实现,把Collection操作交给underscore/lodash实现,自己仅保证数据与界面之间最核心的那一部分,并且没有内置的渲染机制,Backbone只是建议哪些东西应该放在哪里,而不放在建议位置也完全可以,尤其适用于需要精细控制的场景

五.应用场景

之前以为Backbone过时了,没什么用,因为比起其它各种MV*框架,Backbone显得太弱了

业务需要实现类似于易企秀、搜狐快站等通过拖放快速生成页面的东西,最初考虑数据绑定是个问题,决定采用Angular,很快发现Drag&Drop很难与Angular结合起来,因为DnD需要持有DOM元素,而Angular要不停的创建新的DOM元素,又不希望直接访问DOM元素,因为可能会引起状态不一致

灵活组合

改用Backbone,立即发现了其巨大优势,Backbone很容易和其它第三方东西组合起来,因为没有约束和限制,喜欢访问DOM?随意。还想直接修改?随意。状态不一致会不会影响渲染?当然会,但渲染部分也全都是自己写的,会影响什么一清二楚

依赖DOM元素的第三方库,比如DnD,很难和重量级MV*框架组合起来,因为大而全的框架一般都有道德约束:强烈不建议直接修改DOM,而应该修改Model,让渲染机制去重新渲染DOM。用Backbone就不会有这样的问题,它很松散,很随意,自由可控

即便勉强组合起来了,如果发现第三方库无法满足需求,需要扩展改写,就会发现MV*框架的层层限制让一切都变得很困难,因为框架提供了很多未知的内部机制,可能牵一发而动全身,而Backbone几乎没有内置什么机制,Model、View想怎么捏就怎么捏

细节控制

用Backbone能保证一切都在掌控之中,每一个Model属性,每一个change,每一次render()都是完全可控的,Backbone似乎只提供了事件机制,其它的都是自己捏的。逻辑很清晰,没有那么多未知的内部机制,不用小心翼翼的遵守厚厚的最佳实践

发现存在多余的render(),很容易在render()里滤掉,发现内置的change事件不好用,完全可以不用,轻松改用自定义事件……几乎所有细节都是可控的,Model、View一点都不神秘,也不特殊,都只是支持自定义事件的普通对象而已

简单干净

如果只想要个简单的数据绑定(Model、View),那Backbone无疑是最好的选择,因为它没有强加任何限制,也没有引入太多不需要的东西,恰好勉强能满足需求,又没有额外成本(学习成本、迁移成本等等)

P.S.其实第一版Backbone提供的东西已经足够了,后来添的Router和History倒是有些鸡肋,因为路由不需要非常灵活,但一定要足够方便,我们想要的路由就是URL与对应处理函数之间的联系,像Angular路由那样,而一贯追求通用的Backbone却提供了这样一个既不灵活(强约束,需要服务端支持)又不方便(默认root/)的路由,用着当然难受

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code