跳到主要內容
黯羽輕揚每天積累一點點

Backbone 為什麼還活著

免費2016-10-07#JS#backbone#backbone与angular#backbone与react#backbone优点

原以為 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() 發出請求

用圖來說是這樣的:

[caption id="attachment_1178" align="alignnone" width="814"]backbone-0.1.0 backbone-0.1.0[/caption]

最底層最重要的部分是 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 參數並執行路由回調、觸發路由事件

用圖來描述是這樣:

[caption id="attachment_1179" align="alignnone" width="761"]backbone-1.3.3 backbone-1.3.3[/caption]

把路由分為建立路由表(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/)的路由,用著當然難受

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論