backbone源碼解讀(一篇全)

寫在前面

backbone是我兩年多前入門前端的時候接觸到的第一個框架,當初被backbone的強大功能所吸引(當然的確比裸寫js要好得多),雖然現在backbone並不算最主流的前端框架了,但是,它裏面大量設計模式的靈活運用,以及令人讚歎的處理技巧,還是非常值得學習。個人認爲,讀懂老牌框架的源代碼比會用流行框架的API要有用的多。

另外,backbone的源代碼最近也改了許多(特別是針對ES6),所以有些老舊的分析,可能會和現在的源代碼有些出入。

所以我寫這一篇分析backbone的文章,供自己和大家一起學習,本文適合使用過backbone的朋友,筆者水平有限,而內容又實有點多,難免會出差錯,歡迎大家在GitHub上指正

接下來,我們將通過一篇文章解析backbone,我們是按照源碼的順序來講解的,這有利於大家邊看源代碼邊解讀,另外,我給源代碼加了全部的中文註釋和批註,請見這裏,強烈建議大家邊看源碼邊看解析,並且遇到我給出外鏈的地方,最好把外鏈的內容也看看(如果能夠給大家幫助,歡迎給star鼓勵~)

當然,這篇文章很長[爲了避免文章有上沒下,我還是整合到一篇文章中了]。

backbone宏觀解讀

backbone是很早期將MVC的思想帶入前端的框架,現在MVC以及後來的MVVM這麼火可以在一定程度上歸功於backbone。關於前端MVC,我在自己的這篇文章中結合阮一峯老師的圖示簡單分析過,簡單來講就是Model層控制數據,View層通過發佈訂閱(在backbone中)來處理和用戶的交互,Controller是控制器,在這裏主要是指backbone的路由功能。這樣的設計非常直接清晰,有利於前端工程化。

backbone中主要實現了Model、Collection、View、Router、History幾大功能,前四種我們用的比較多,另外backbone基於發佈-訂閱模式自己實現了一套對象的事件系統Events,簡單來說Events可以讓對象擁有事件能力,其定義了比較豐富的API,並且如果你引入了backbone,這套事件系統還可以集成到自己的對象上,這是一個非常好的設計。

另外,源代碼中所有的以_開頭的方法,可以認爲是私有方法,是沒有必要直接使用的,也不建議用戶覆蓋。

backbone模塊化處理、防止衝突和underscore混入

代碼首先進行了區分使用環境(self或者是global,前者代表瀏覽器環境(self和window等價),後者代表node環境)和模塊化處理操作,之後處理了在AMD和CommonJS加載規範下的引入方式,並且明確聲明瞭對jQuery(或者Zepto)和underscore的依賴。

很遺憾的是,雖然backbone這樣做了,但是backbone並不適合在node端直接使用,也不適合服務端渲染,另外還和ES6相處的不是很融洽,這個我們後面還會陸續提到原因。

backbone noConflict

backbone也向jQuery致敬,學習了它的處理衝突的方式:

var previousBackbone = root.Backbone;
//...
Backbone.noConflict = function() {
    root.Backbone = previousBackbone;
    return this;
};

這段代碼的邏輯非常簡單,我們可以通過以下方式使用:

var localBackbone = Backbone.noConflict();   
var model = localBackbone.Model.extend(...);

混入underscore的方法

backbone通過addUnderscoreMethods將一些underscore的實用方法混入到自己定義的幾個類中(注:確切地說是可供構造調用的函數,我們下文也會用類這個簡單明瞭的說法代替)。

這裏面值得一提的是關於underscore的方法(underscore的源碼解讀請移步這裏,fork from韓子遲),underscore的所有方法的參數序列都是固定的,也就是說第一個參數代表什麼第二個參數代表什麼,所有函數都是一致的,第一個參數一定代表目標對象,第二個參數一定代表作用函數(有的函數可能只有一個參數),在有三個參數的情況下,第三個參數代表上下文this,另外如果有第四個參數,第三個參數代表初始值或者默認值,第四個參數代表上下文。所以addMethod就是根據以上規定來使用的。

另外關於javascript中的this,我曾經寫過博客在這裏,有興趣的可以看

混入方法的實現邏輯:

var addMethod = function(length, method, attribute) {
  //... 
};
var addUnderscoreMethods = function(Class, methods, attribute) {
    _.each(methods, function(length, method) {
      if (_[method]) Class.prototype[method] = addMethod(length, method, attribute);
    });
};
//之後使用:
var modelMethods = {keys: 1, values: 1, pairs: 1, invert: 1, pick: 0,
      omit: 0, chain: 1, isEmpty: 1};
//混入一些underscore中常用的方法
addUnderscoreMethods(Model, modelMethods, 'attributes');

backbone Events

backbone的Events是一個對象,其中的方法(on\listenTo\off\stopListening\once\listenToOnce\trigger)都是對象方法。

總體上,backbone的Events實現了監聽/觸發/解除對自己對象本身的事件,也可以讓一個對象監聽/解除監聽另外一個對象的事件。

綁定對象自身的監聽事件on

關於對象自身事件的綁定,這個比較簡單,除了最基本的綁定之外(一個事件一個回調),backbone還支持以下兩種方式的綁定:

//傳統方式
model.on("change", common_callback);  

//傳入一個名稱,回調函數的對象
model.on({ 
     "change": on_change_callback,
     "remove": on_remove_callback
});  

//使用空格分割的多個事件名稱綁定到同一個回調函數上
model.on("change remove", common_callback);  

這用到了它定義的一箇中間函數eventsApi,這個函數比較實用,可以根據判斷使用的是哪種方式(實際上這個判斷也比較簡單,根據傳入的是對象判斷屬於上述第二種方式,根據正則表達式判斷是上述的第三種方式,否則就是傳統的方式)。然後再進行遞歸或者循環或者直接處理。

在對象中存儲事件實際上大概是下述形式:

events:{
    change:[事件一,事件二]
    move:[事件一,事件二,事件三]
}

而其中的事件實際上是一個整理好的對象,是如下形式:

{callback: callback, context: context, ctx: context || ctx, listening: listening}

這樣在觸發的時候,一個個調用就是了。

監聽其他對象的事件listenTo

backbone還支持監聽其他對象的事件,比如,B對象上面發生b事件的時候,通知A調用回調函數A.listenTo(B, “b”, callback);,而這也是backbone處理非常巧妙的地方,我們來看看它是怎麼做的。

實際上,這和B監聽自己的事件,並且在回調函數的時候把上下文變成A,是差不多的:B.on(“b”, callback, A);(on的第三個參數代表上下文)。

但是backbone還做了另外的事情,這裏我們假設是A監聽B的一個事件(比如change事件好了)。

首先A有一個A._listeningTo屬性,這個屬性是一個對象,存放着它監聽的別的對象的信息A._listeningTo[id] = {obj: obj, objId: id, id: thisId, listeningTo: listeningTo, count: 0},這個id並不是數字,是每一個對象都有的唯一字符串,是通過_.uniqueId這個underscore方法生成的,這裏的obj是B,objId是B的_listenId,id是A的_listenId,count是一個計數功能,而這個A._listeningTo[id]會被直接引用賦值到上面事件對象的listening屬性中。

爲什麼要多listenTo?Inversion of Control

通過以上我們似乎有一個疑問,好像on就能把listenTo的功能搞定了,用一個listenTo純屬多餘,並且許多其他的類庫也是隻有一個on方法。

首先,這裏會引入一個概念:控制反轉,所謂控制反轉,就是原來這個是B對象來控制的事件我們現在交由A對象來控制,那現在假設A分別listenTo B、C、D三個對象,那麼這個時候假設A不監聽了,那麼我們直接對A調用一個stopListening方法,則可以同時解除對B、C、D的監聽(這裏我講的可能不是十分正確,這裏另外推薦一個文章)。

另外,我們需要從backbone的設計初衷來看,backbone的重點是View、Model和Collection,實際上,backbone的View可以對應一個或者多個Collection,當然我們也可以讓View直接對應Model,但問題是View也並不一定對應一個Model,可能對應多個Model,那麼這個時候我們通過listenTo和stopListening可以非常方便的添加、解除監聽。

//on的方式綁定
var view = {
    DoSomething :function(some){
       //...
    }
}
model.on('change:some',view.DoSomething,view);
model2.on('change:some',view.DoSomething,view);

//解綁,這個時候要做的事情比較多且亂
model.off('change:some',view.DoSomething,view);
model2.off('change:some',view.DoSomething,view);

//listenTo的方式綁定
view.listenTo(model,'change:some',view.DoSomething);
view.listenTo(model2,'change:some',view.DoSomething);

//解綁
view.stopListening();

另外,在實際使用中,listengTo的寫法也的確更加符合用戶的習慣.

以下是摘自backbone官方文檔的一些解釋,僅供參考:

The advantage of using this form, instead of other.on(event, callback, object), is that listenTo allows the object to keep track of the events, and they can be removed all at once later on. The callback will always be called with object as context.

解除綁定事件off、stopListening

與on不同,off的三個參數都是可選的

  • 如果沒有任何參數,off相當於把對應的_events對象整體清空
  • 如果有name參數但是沒有具體指定哪個callback的時候,則把這個name(事件)對應的回調隊列全部清空
  • 如果還有進一步詳細的callback和context,那麼這個時候移除回調函數非常嚴格,必須要求上下文和原來函數完全一致

off的最終實現函數是offApi,這個函數算上註釋有大概50行。

var offApi = function(events, name, callback, options) {
  //... 
}

這裏面需要單獨提一下,前面有這樣的幾行:

if (!name && !callback && !context) {
      var ids = _.keys(listeners);//所有監聽它的對應的屬性
      for (; i < ids.length; i++) {
        listening = listeners[ids[i]];
        delete listeners[listening.id];
        delete listening.listeningTo[listening.objId];
      }
      return;
}

這幾行是做了一件什麼事呢?
刪除了所有的多對象監聽事件記錄,之後刪除自身的監聽事件。我們假設A監聽了B的一個事件,這個時候A._listenTo中就會多一個條目,存儲這個監聽事件的信息,而這個時候B的B._listeners也會多一個條目,存儲監聽事件的信息,注意這兩個條目都是按照id爲鍵的鍵值對來存儲,但是這個鍵是不一樣的,值都指向同一個對象,這裏刪除對這個對象的引用,之後就可以被垃圾回收機制回收了。如果這個時候調用B.off(),那麼這個時候,以上的兩個條目都被刪除了。另外,注意最後的return,以及Events.off中的:

this._events = eventsApi(offApi, this._events, name, callback, {
      context: context,
      listeners: this._listeners
});

所以如果B.off()這樣調用然後直接把 B._events 在之後也清空了,太巧妙了

之後有一個對names(事件名)的循環(如果沒有指定,那麼默認就是所有names),這個循環內容理解起來比較簡單,裏面也順便照顧了_listeners_listenTo這些變量。這裏不過多解釋了。

另外,stopListening實際上也是調用offApi,先處理了一下交給off函數,這也是設計模式運用典範(適配器模式)。

once和listenToOnce

這兩個函數顧名思義,和on以及listenTo的區別不大,唯一的區別就是回調函數只供調用一次,多觸發調用也沒有用(實際上不會被觸發了)。

兩者都用到了onceMap這個函數,我們分析一下這個函數:

 var onceMap = function(map, name, callback, offer) {
    if (callback) {
      //_.once:創建一個只能調用一次的函數。重複調用改進的方法也沒有效果,只會返回第一次執行時的結果。 作爲初始化函數使用時非常有用, 不用再設一個boolean值來檢查是否已經初始化完成.
      var once = map[name] = _.once(function() {
        offer(name, once);
        callback.apply(this, arguments);
      });
      //這個在解綁的時候有一個分辨效果
      once._callback = callback;
    }
    return map;
 };

backbone的設計思路是這樣的:用_.once()創建一個只能被調用一次的函數,這個函數在第一次被觸發調用的時候,進行解除綁定(offer實際上是一個已經綁定好this的解除綁定函數,這個可以參見once和listenToOnce的源代碼),然後再調用callback,這樣既實現了調用一次的目的,也方便了垃圾回收。

其他和on以及listenTo的時候一樣,這裏就不過多介紹了。

trigger

trigger函數是用於觸發事件,支持多個參數,除了第一個參數以外,其他的參數會依次放入觸發事件的回調函數的參數中(backbone默認對3個參數及以下的情況下進行call調用,這種處理方式原因之一是call調用比apply調用的效率更高從而優先使用(關於call和apply的性能對比:https://jsperf.com/call-apply-segu),另外一方面源碼中並沒有超過三個參數的情況,所以用call支持到了三個參數,其餘情況採用性能較差但是寫起來方便的apply)。

另外值得一提的是,Events支持all事件,即如果你監聽了all事件,那麼任何事件的觸發都會調用all事件的回調函數列。

關於trigger部分的源代碼比較簡單,並且我也增加了一些評註,這裏就不貼代碼了。

context 和 ctx

有心的朋友也許注意到,backbone在事件中用到了context和ctx這兩個”貌似”表示當前上下文的對象,並且在如果有context的情況下,這兩個幾乎一樣:

 handlers.push({callback: callback, context: context, ctx: context || ctx, listening: listening});

這裏我根據自己的理解,儘量解釋一下。

我們可以主要看off方法及trigger方法,我們發現上面兩屬性在這兩個方法中分別被使用了。

off裏需要對context進行比較決定是否要刪除對應的事件,所以model._events中保存下來的context,必須是未做修改的。

而trigger裏在執行回調函數時,需要指定其作用域,當綁定事件時沒有給定作用域,則會使用被監聽的對象當回調函數的作用域。

實際上,我覺得這個ctx有點多餘,我們完全可以在trigger中這樣寫:

(ev = events[i]).callback.call(ev.context || ev.obj)

backbone Model

backbone的Model實際上是一個可供構造調用的函數,backbone採用污染原型的方式把定義好的屬性都定義在了prototype上,這可能並不是一個非常妥當的做法,但是在backbone中這樣做卻是沒有什麼不可以的,這個我們在之後講extend方法的時候會進行補充。

我們先看看這個函數在實例化的時候會做點什麼:

 var Model = Backbone.Model = function(attributes, options) {
    var attrs = attributes || {};
    options || (options = {});
    //這個preinitialize函數實際上是爲空的,可以給有興趣的開發者重寫這個函數,在初始化Model之前調用
    this.preinitialize.apply(this, arguments);
    //Model的唯一的id
    this.cid = _.uniqueId(this.cidPrefix);
    this.attributes = {};
    if (options.collection) this.collection = options.collection;
    //如果之後new的時候傳入的是JSON,我們必須在options選項中聲明parse爲true
    if (options.parse) attrs = this.parse(attrs, options) || {};
    //_.result:如果指定的property的值是一個函數,那麼將在object上下文內調用它;否則,返回它。如果提供默認值,並且屬性不存在,那麼默認值將被返回。如果設置defaultValue是一個函數,它的結果將被返回。
    //這裏調用_.result相當於給出了餘地,自己寫defaults的時候可以直接寫一個對象,也可以寫一個函數,通過return一個對象的方式把屬性包含進去
    var defaults = _.result(this, 'defaults');
    //defaults應該是在Backbone.Model.extends的時候由用戶添加的,用defaults對象填充object 中的undefined屬性。 並且返回這個object。一旦這個屬性被填充,再使用defaults方法將不會有任何效果。
    attrs = _.defaults(_.extend({}, defaults, attrs), defaults);
    this.set(attrs, options);
    //存儲歷史變化記錄
    this.changed = {};
    //這個initialize也是空的,給初始化之後調用
    this.initialize.apply(this, arguments);
};

我們可以看出,this.attributes是存儲實際內容的。

另外,preinitialize和initialize不僅在Model中有,在之後的Collection、View和Router中也都出現了,一個是在初始化前調用,另外一個是在初始化之後調用。

關於preinitialize的問題,我們後文還要繼續討論,它的出現和ES6有關。

Model set

Model的set方法是一個重點的方法,這個方法的功能比較多,本身甚至還可以刪除屬性,因爲unset內部和clear的內部等也調用了set方法。在用戶手動賦值的時候,支持下面兩種賦值方式:"key", value{key: value}兩種賦值方式。

我們分析這個函數總共做了哪些事情:

  • 對兩種賦值方式的支持"key", value{key: value}的預處理。
  • 如果你寫了validate驗證函數沒有通過驗證,那麼就不繼續做了(需要顯式聲明使用validate)。
  • 進行變量的更改或者刪除,順便把歷史版本的問題解決掉。
  • 如果不是靜默set的,那麼這個時候開始進行change事件的觸發。

具體這一塊註釋筆者寫的非常詳細,所以在這裏也不再贅述。

fetch、save、destroy

這幾個功能是需要跟服務端交互的,所以我們放在一起來分析一下。

backbone通過封裝好模型和服務器交互的函數,大大方便了開發者和服務端數據同步的工作,當然,這需要一個對應的後端,不僅需要支持POST、PUT、PATCH、DELETE、GET多種請求,甚至連url的格式都給定義好了,url的格式爲:yourUrl/id,這個id肯定是需要我們傳入的,並且要求跟服務器上的id對應(畢竟服務器要識別處理)

注意:url並不一定非要按照backbone的來,我們完全可以調用這幾個方法的時候再指定一個url{url:myurl,success:successFunction},這個部分backbone 在sync函數中進行了一個判斷處理,優先選擇後指定的url,不過這樣對我們來說是比較麻煩的,也並不符合backbone的設計初衷

這三個函數最後都用到了sync函數,所以我們要先分析sync函數:

Backbone.sync = function(method, model, options) {
  //...
};

Backbone.ajax = function() {
  return Backbone.$.ajax.apply(Backbone.$, arguments);
};

sync函數在其中調用了ajax函數,而ajax函數就是jQuery的ajax,這個我們非常熟悉,它可以插入非常多的參數,我們可以這裏查看文檔。

另外,這個sync支持兩個特殊情況:

  • emulateHTTP:如果你想在不支持Backbone的默認REST/ HTTP方式的Web服務器上工作, 您可以選擇開啓Backbone.emulateHTTP。 設置該選項將通過 POST 方法僞造 PUT,PATCH 和 DELETE 請求 用真實的方法設定X-HTTP-Method-Override頭信息。 如果支持emulateJSON,此時該請求會向服務器傳入名爲 _method 的參數。
  • emulateJSON:如果你想在不支持發送 application/json 編碼請求的Web服務器上工作,設置Backbone.emulateJSON = true;將導致JSON根據模型參數進行序列化, 並通過application/x-www-form-urlencoded MIME類型來發送一個僞造HTML表單請求

具體的這個sync方法,就是構造ajax參數的過程。

fetch

fetch可以傳入一個回調函數,這個回調函數會在ajax的回調函數中被調用,另外ajax的回調函數是在fetch中定義的,這個回調函數做了這樣幾件事情:

 options.success = function(resp) {
        //處理返回數據
        var serverAttrs = options.parse ? model.parse(resp, options) : resp;
        //根據服務器返回數據設置模型屬性
        if (!model.set(serverAttrs, options)) return false;
        //觸發自定義回調函數
        if (success) success.call(options.context, model, resp, options);
        //觸發事件
        model.trigger('sync', model, resp, options);
 };
save

save方法爲向服務器提交保存數據的請求,如果是第一次保存,那麼就是POST請求,如果不是第一次保存數據,那麼就是PUT請求。

其中,傳遞的options中可以使用的字段以及意義爲:

  • wait: 可以指定是否等待服務端的返回結果再更新model。默認情況下不等待
  • url: 可以覆蓋掉backbone默認使用的url格式
  • attrs: 可以指定保存到服務端的字段有哪些,配合options.patch可以產生PATCH對模型進行部分更新
  • patch:boolean 指定使用部分更新的REST接口
  • success: 自己定義一個回調函數
  • data: 會被直接傳遞給jquery的ajax中的data,能夠覆蓋backbone所有的對上傳的數據控制的行爲
  • 其他: options中的任何參數都將直接傳遞給jquery的ajax,作爲其options

關於save函數具體的處理邏輯,我在源代碼中添加了非常詳細的註釋,這裏就不展開了。

destroy

銷燬這個模型,我們可以分析,銷燬模型要做以下幾件事情:

  • 停止對該對象所有的事件監聽,本身都沒有了,還監聽什麼事件
  • 告知服務器自己要被銷燬了(如果isNew()返回true,那麼其實不用向服務器發送請求)
  • 如果它屬於某一個collection,那麼要告知這個collection要把這個模型移除

其中,傳遞的options中可以使用的字段以及意義爲:

  • wait: 可以指定是否等待服務端的返回結果再銷燬。默認情況下不等待
  • success: 自己定義一個回調函數

Model的其他內容

另外值得一提的是,Model是要求傳入的id唯一的,但是對這個id如果重複的情況下的錯誤處理做的不是很到位,所以有的時候你看控制檯報錯並不能及時發現問題。

backbone Collection

Collection也是一個可供構造調用的函數,我們還是先看看這個Collection做了些什麼:

var Collection = Backbone.Collection = function(models, options) {
    options || (options = {});
    this.preinitialize.apply(this, arguments);
    //實際上我們在創建集合類的時候大多數都會定義一個model, 而不是在初始化的時候從options中指定model
    if (options.model) this.model = options.model;
    //我們可以在options中指定一個comparator作爲排序器
    if (options.comparator !== void 0) this.comparator = options.comparator;
    //_reset用於初始化
    this._reset();
    this.initialize.apply(this, arguments);
    //如果我們在new構造調用的時候聲明瞭models,這個時候需要調用reset函數
    if (models) this.reset(models, _.extend({silent: true}, options));
  };

實際上,我覺得backbone的Model、View、Collection裏的邏輯還是比較清楚的,可讀性也比較強,所以主要就是把註釋寫在代碼裏面。

Collection set

collection的一個核心方法,內容很長,我們可以把它理解爲重置:給定一組新的模型,增加新的,去除不在這裏面的(在添加模式下不去除),混合已經存在的。但是這個方法同時也很靈活,可以通過參數的設定來改變模式

set可能有如下幾個調用場景:

  1. 重置模式,這個時候不在models裏的model都會被清除掉。對應上文的:var setOptions = {add: true, remove: true, merge: true};
  2. 添加模式,這個時候models裏的內容會做添加用,如果有重複的(cid來判斷),會覆蓋。對應上文的:var addOptions = {add: true, remove: false};

我們還是理一理裏面做了哪些事情:

  • 先規範化models和options兩個參數
  • 遍歷models:
    • 如果是重置模式,那麼遇到重複的就直接覆蓋掉,並且也添加到set隊列,遇到新的就先添加到set隊列。之後還要刪除掉models裏沒有而原來collection裏面有的
    • 如果是添加模式,那麼遇到重複的,就先添加到set隊列,遇到新的也是添加到set隊列
  • 之後進行整理,整合到collection中(可能會觸發排序操作)
  • 如果不是靜默處理,這個時候會觸發各類事件

當然,我們在進行調用的時候,是不需要考慮這麼複雜的,這個函數之所以做的這麼複雜,是因爲它也供許多內置的其他函數調用了,這樣可以減少重複代碼的冗餘,符合函數式編程的思想。另外set函數雖然繁雜卻不贅餘,裏面定義的函數內變量邏輯都有自己的作用。

sort

上文中提到了sort函數,sort所依據的是用戶傳入的comparator參數,這個參數可以是一個字符串表示的單個屬性也可以是一個函數,另外也可以是一個多個屬性組成的數組,如果是單個屬性或者函數,就調用underscore的排序方法,如果是一個多個屬性組成的數組,就調用原生的數組排序方法(原生方法支持按照多個屬性分優先級進行排序)

fetch、create

這是Collection中涉及到和服務端交互的方法,這兩個方法非常有區別。

fetch是直接從服務器拉取數據,並沒有調用model的fetch方法,返回的數據格式應當是直接可以調用上文的set函數的數據格式,另外值得注意的是,想要調用這個方法,一定要先指定url

create是指將特定的model上傳到服務器上去,並沒有調用自己的方法而是最後調用了model自身的方法model.save(null, options),這裏第一個參數被賦值成null還是有意義的,我們通過分析save函數前幾行代碼就可以很明顯地分析出原因。

CollectionIterator

這是一個基於ES6的新的內容,目的是創建一個遍歷器,之後,我們可以在collection的一些方法中運用這個可遍歷對象。

這個方面的知識可以看這裏補充,三言兩語也無法說清,簡單地講,就是如果正確地定義了一個next屬性方法,這個對象就可以按照自己定義的方式來遍歷了。

而backbone這裏定義的這個遍歷器更加強大,可以分別按照key、value、key和value三種方式遍歷

我這裏給出一個使用方式:

window.Test = Backbone.Model.extend({
    defaults: {content: ''
    }
});
// 創建集合模型類  
window.TestList = Backbone.Collection.extend({
    model: Test
});
// 向模型添加數據
var data = new TestList(
        [
            {
                id:100,
                content: 'hello,backbone!'
            },
            {
                id:101,
                content: 'hello,Xiaotao!'
            }
        ]
);
for(var ii of data.keys()){
    console.log(ii);
}
for( ii of data.values()){
    console.log(ii);
}
for( ii of data.entries()){
    console.log(ii);
}

具體這裏是如何實現的,我相信大家看了上文鏈接給出的擴展知識之後,然後再結合我寫了註釋的源代碼,應該都能看懂了。

Collection其他內容

另外,Collection還實現了非常多的小方法,也混入了很多underscore的方法,但核心都是操作this.modelsthis.models是一個正常的數組(所以,在js中本身實現了的方法也是可以在這裏使用的),可以直接訪問。

另外值得一提的是,Collection中有一個_byId變量,這個變量通過cid和id來存取,起到一個方便直接存取的作用,在某些時候非常方便。

_addReference: function(model, options) {
      this._byId[model.cid] = model;
      var id = this.modelId(model.attributes);
      if (id != null) this._byId[id] = model;
      model.on('all', this._onModelEvent, this);
},

另外實際上,model除了作爲Collection裏面的元素,並且通過一個collection屬性指向對應的Collection,實際上聯繫也並不是非常多,這也比較符合低耦合高內聚的策略。

backbone View

接下來我們進入backbone的View部分,也就是和用戶打交道的部分,我一開始用backbone的時候就是被View層可以通過定義events對象數組來方便地進行事件管理所吸引(雖然現在看來還有更方便的方案)

我們先來看一下View函數在用戶新建View的時候做了些什麼:

 var View = Backbone.View = function(options) {
    this.cid = _.uniqueId('view');
    this.preinitialize.apply(this, arguments);
    //_.pick(object, *keys):返回一個object副本,只過濾出keys(有效的鍵組成的數組)參數指定的屬性值。或者接受一個判斷函數,指定挑選哪個key。
    _.extend(this, _.pick(options, viewOptions));
    //初始化dom元素和jQuery元素工作
    this._ensureElement();
    //自定義初始化函數
    this.initialize.apply(this, arguments);
};

這裏面值得一提的是this._ensureElement()這個函數,這個函數內部調用了很多函數,做了很多工作,我們首先看這個函數:

_ensureElement: function() {
      if (!this.el) {
        var attrs = _.extend({}, _.result(this, 'attributes'));
        if (this.id) attrs.id = _.result(this, 'id');
        if (this.className) attrs['class'] = _.result(this, 'className');
        this.setElement(this._createElement(_.result(this, 'tagName')));
        this._setAttributes(attrs);
      } else {
        this.setElement(_.result(this, 'el'));
      }
},

根據你是否傳入一個dom元素(這個dom元素用來和View對應,也可以是jQuery元素)分成了兩種情況執行,我們先看不傳入的情況:

這個時候我們可以定義一些屬性,這些屬性都在接下來賦值到生成的dom對象上:

 _setAttributes: function(attributes) {
      this.$el.attr(attributes);
}

接下來看假設傳入了了的情況:

 setElement: function(element) {
      this.undelegateEvents();
      this._setElement(element);
      this.delegateEvents();
      return this;
},

這裏面又調用了三個函數,我們看一下這三個函數:

undelegateEvents: function() {
      if (this.$el) this.$el.off('.delegateEvents' + this.cid);
      return this;
},

_setElement: function(el) {
      this.$el = el instanceof Backbone.$ ? el : Backbone.$(el);
      this.el = this.$el[0];
},

delegateEvents: function(events) {
      events || (events = _.result(this, 'events'));
      if (!events) return this;
      this.undelegateEvents();
      for (var key in events) {
        var method = events[key];
        if (!_.isFunction(method)) method = this[method];
        if (!method) continue;
        var match = key.match(delegateEventSplitter);
        this.delegate(match[1], match[2], _.bind(method, this));
    }
    return this;
},

delegate: function(eventName, selector, listener) {
      this.$el.on(eventName + '.delegateEvents' + this.cid, selector, listener);
      return this;
},

上面第四個函數爲第三個函數所調用的,因此我們放在了一起。

第一個函數是解綁backbone所用的jQuery事件命名空間下的事件(.delegateEvents),這個是方式這個事件被之前的其他View使用過,從而造成污染(實際上,這個一般情況下用的是不多的)。

第二個函數是初始化dom對象和jQuery對象,$el代表jQuery對象,el代表dom對象。

第三個函數是把我們寫的監聽事件進行重新綁定,我們寫的事件滿足下面的格式:

 //舉個例子: 
 {
     'mousedown .title':  'edit',
     'click .button':     'save',
     'click .open':       function(e) { ... }
 }

上面第三個函數就是一個解析函數,解析好後直接調用delegate函數進行事件的綁定,這裏要注意你定義的事件的元素必須在提供的el內的,否則無法訪問到。

render

另外,backbone中有一個render函數:

render: function() {
      return this;
},

這個render函數實際上有比較深遠的意義,render函數默認是沒有操作的,我們可以自己定義操作,然後可以在事件中'change' 'render'這樣對應,這樣每次變化就會重新調用render重繪,我們也可以自定義好render函數並且在初始化函數initialize中調用。另外,render函數默認的return this;隱含了backbone的一種期望:返回this從而支持鏈式調用。

render可以使用underscore的模版,並且這也是推薦做法,以下是一個非常簡單的demo:

var Bookmark = Backbone.View.extend({
  template: _.template(...),
  render: function() {
    this.$el.html(this.template(this.model.attributes));
    return this;
  }
});

backbone router、history

router

backbone相比於一些流行框架的好處就是自己實現了router部分,不用再引入其他插件,這點十分方便。

我們在使用router的時候,通常會採用如下寫法:

var Workspace = Backbone.Router.extend({

  routes: {
    "help":                 "help",    // #help
    "search/:query":        "search",  // #search/kiwis
    "search/:query/p:page": "search"   // #search/kiwis/p7
  },

  help: function() {
    ...
  },

  search: function(query, page) {
    ...
  }

});

router的供構造調用的函數的主體部分也相當簡單,沒有做多餘的事情:

var Router = Backbone.Router = function(options) {
    options || (options = {});
    this.preinitialize.apply(this, arguments);
    //注意這個地方,options的routes會直接this的routes,所以如果在建立類的時候指定routes,實例化的時候又擴展了routes,是會被覆蓋的
    if (options.routes) this.routes = options.routes;
    //對自己定義的路由進行處理
    this._bindRoutes();
    //調用自定義初始化函數
    this.initialize.apply(this, arguments);
};

這裏我們展開_bindRoutes:

 _bindRoutes: function() {
      if (!this.routes) return;
      this.routes = _.result(this, 'routes');
      var route, routes = _.keys(this.routes);
      while ((route = routes.pop()) != null) {
        this.route(route, this.routes[route]);
      }
},

route函數是把路由處理成正則表達式形式,然後調用history.route函數進行綁定,history.route函數在網址每次變化的時候都會檢查匹配,如果有匹配就執行回調函數,也就是下文Backbone.history.route傳入的第二個參數,這樣路由部分和history部分就聯繫在一起了。

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;
},

上面的這段代碼首先可能會調用_routeToRegExp這個函數進行正則處理,這個函數可能是backbone中最難懂的函數,不過不懂也並不影響我們繼續分析(實際上,筆者也並沒有完全懂這個函數,所以希望經驗人士可以在這裏給予幫助)。

 _routeToRegExp: function(route) {
      route = route.replace(escapeRegExp, '\\$&')//這個匹配的目的是將正則表達式字符進行轉義
                   .replace(optionalParam, '(?:$1)?')
                   .replace(namedParam, function(match, optional) {
                     return optional ? match : '([^/?]+)';
                   })
                   .replace(splatParam, '([^?]*?)');
      return new RegExp('^' + route + '(?:\\?([\\s\\S]*))?$');
},

另外調用了_extractParameters這個函數和router.execute這個函數,前者的作用就是將匹配成功的URL中蘊含的參數轉化成一個數組返回,後者接受三個參數,分別是回調函數,參數列表和函數名(這裏之前只有兩個函數,後來backbone增加了第三個參數)。

 _extractParameters: function(route, fragment) {
      var params = route.exec(fragment).slice(1);
      return _.map(params, function(param, i) {
        // Don't decode the search params.
        if (i === params.length - 1) return param || null;
        return param ? decodeURIComponent(param) : null;
      });
}
execute: function(callback, args, name) {
      if (callback) callback.apply(this, args);
},

router的內容也就這些了,實現的比較簡單清爽,代碼也不多,關於處理歷史記錄瀏覽器兼容性的問題都放在了history部分,所以接下來我們來分析難啃的history部分。

history

這一塊的內容比較重要,並且相比於之前的內容有些複雜,我儘量把自己的理解全都講解出來。

我們先說明一下這個歷史記錄的作用:
當你在瀏覽器訪問的時候,可以通過左上角的前進後退進行切換,這就是因爲產生了歷史記錄。

那麼什麼方式可以產生歷史記錄呢?

  1. 頁面跳轉(肯定的,但是並不適用於SPA)
  2. hash變化:形如<a href="#123"></a>這種點擊後會觸發歷史記錄),但是不幸的是在IE7下並不能被寫入歷史記錄(雖然筆者是對IE9以下堅決說不的)
  3. pushState,這種比較牛逼,可以默默的改變路由,比如把article.html#article/54改成article.html#article/53但是不觸發頁面的刷新,因爲一般情況下這算是兩個頁面的,另外,這種情況需要服務端的支持,因此我在用backbone的時候較少採用這種做法(現在有一個概念叫做pjax,就是ajax+pushState,具體可以Google之)
  4. iframe內url變化,變化iframe內的url也會觸發歷史記錄,但是這個比較麻煩,另外,在IE中,無論iframe是一開始靜態寫在html中的還是後來用js動態創建的,都可以被寫入瀏覽器的歷史記錄,其他瀏覽器一般只支持靜態寫在html中的iframe。所以,我們一般在2&3都不可用的情況下,才選用這種情況(IE7以下)

以上講的基本就是backbone使用的方式,接下來我們再按照backbone使用邏輯和優先級進行一些講解:

backbone默認是使用hash的,在不支持hash的瀏覽器中使用iframe,如果想要使用pushState,需要顯式聲明並且瀏覽器本身要支持(如果使用了pushState的話hash就不用了)。

所以backbone的history有一個非常大的start函數,這個函數從頭到尾做了如下幾件事情:

  • 將頁面的根部分保存在root中,默認是/
  • 判斷是否想用hashChange(默認爲true)以及支持與否,判斷是否想用pushState以及支持與否。
  • 判斷一下到底是用hash還是用push,並且做一些url處理
  • 如果需要用到iframe,這個時候初始化一下iframe
  • 初始化監聽事件:用hash的話可以監聽hashchange事件,用pushState的話可以監聽popState事件,如果用了iframe,沒辦法,只能輪詢了,這個主要是用來用戶的前進後退。
  • 最後最重要的:先處理以下當前頁面的路由,也就是說,假設用戶直接訪問的並不是根頁面,不能什麼也不做呀,要調用相關路由對應的函數,所以這裏要調用loadUrl

和start對應的stop函數,主要做了一些清理工作,如果能讀懂start,那麼stop函數應該是不難讀懂的。

另外還有一個比較長的函數是navigate,這個函數的作用主要是存儲/更新歷史記錄,主要和瀏覽器打交道,如果用hash的話,backbone自身是不會調用這個函數的(因爲用不到),但是可以供開發者調用:

開發者可以通過這個函數用js代碼自動管理路由:

openPage: function(pageNumber) {
  this.document.pages.at(pageNumber).open();
  this.navigate("page/" + pageNumber);
}

另外,backbone在這一部分定義了一系列工具函數,用於處理url。

backbone的history這一部分寫的非常的優秀,兼容性也非常的高,並且充分滿足了高聚合低耦合的特點,如果自己也要實現history管理這一部分,那麼backbone的這個history絕對是一個優秀的範例。

extend

最後,backbone還定義了一個extend函數,這個函數我們再熟悉不過了,不過它的寫法並沒有我們想象的那麼簡單,

這個函數並沒有直接將屬性assign到parent上面(this),是因爲這樣會產生一個顯著的問題:污染原型
所以實際上backbone的做法是新建了一個子類,這個子對象承擔着所有內容.

另外,這個extend函數也借鑑了ES6的一些寫法,內容不多,理解起來也是簡單的。

ES6&backbone

backbone支持ES6的寫法,關於這個寫法問題,曾經GitHub上面有過激烈的爭論,這裏我稍作總結,先給出一個目前可行的寫法:

class DocumentRow extends Backbone.View {

    preinitialize() {
        _.extend(this, {
          tagName:  "li",
          className: "document-row",
          events: {
            "click .icon":          "open",
            "click .button.edit":   "openEditDialog",
            "click .button.delete": "destroy"
          }
        });
    }

    initialize() {
        this.listenTo(this.model, "change", this.render);
    }

    render() {
        //...
    }
}

實際上,這個問題出現之前backbone的源代碼中是沒有preinitialize函數的,關於爲什麼最終是這樣,我總結以下幾點:

  • ES6的class不能直接寫屬性(直接報錯),都要寫成函數,因爲如果有屬性的話會出現共享屬性的問題。
  • ES6的class寫法和ES5的不一樣,也和backbone自己定義的extend是不一樣的。是先要調用父類的構造方法,然後再有子類的this,在調用constructor之前是無法使用this的。所以下面這種寫法就不行了:
class DocumentRow extends Backbone.View {

    constructor() {
        this.tagName =  "li";
        this.className = "document-row";
        this.events = {
            "click .icon":          "open",
            "click .button.edit":   "openEditDialog",
            "click .button.delete": "destroy"
        };
        super();
    }

    initialize() {
        this.listenTo(this.model, "change", this.render);
    }

    render() {
        //...
    }
}

但是如果把super提前,那麼這個時候tagName什麼的還沒有賦值呢,element就已經建立好了。

另外,把屬性強制寫成函數的做法是被backbone支持的,但是我相信沒有多少人願意這樣做吧:

class DocumentRow extends Backbone.View {

    tagName() { return "li"; }

    className() { return "document-row";}

    events() {
        return {
            "click .icon":          "open",
            "click .button.edit":   "openEditDialog",
            "click .button.delete": "destroy"
        };
    }

    initialize() {
        this.listenTo(this.model, "change", this.render);
    }

    render() {
        //...
    }
}

所以我們需要:及早把一些屬性賦給父類覆蓋掉父類默認屬性,然後調用父類構造函數,然後再調用子類構造函數。所以加入一個preinitialize方法是一個比較好的選擇。

如果還沒有理解,不妨看看下面這個本質等價的小例子:

class A{
    constructor(){
        this.s=1;
        this.preinit();
        this.dosomething();
        this.init();
    }
    preinit(){}
    init(){}
    dosomething(){console.log("dosomething:",this.s)}//dosomething 2
}
class B extends A{
    preinit(){this.s=2;}
    init(){}
}
var b1 = new B();
console.log(b1.s);//2

總結

經過以上漫長的對backbone源代碼分析的過程,我們瞭解了一個優秀的框架的源代碼,我總結了backbone源碼的幾個特點如下:

  • 充分發揮函數式編程的精神,符合函數式編程,之前有位前輩說對js的運用程度就取決於對js的函數式編程的認識程度,也是不無道理的。
  • 高內聚低耦合可擴展,這一方面方便了我們使用backbone的一部分內容(比如只使用Events或者router),另外一方面也方便了插件開發,以及能和其他的庫比較好的兼容,我認爲,這並不是一個強主張的庫,你可以小規模地按照自己的方式使用,也可以大規模的完全按照backbone的期望使用。
  • 在使用和兼容ES6的新特性上做了不少努力,在源代碼中好幾處都體現了ES6的內容,這讓backbone作爲一個老牌框架,在如今大規模使用做網頁應用,依然十分可行。

缺點:

  • backbone嚴重依賴jQuery和underscore,這對backbone起到了牽制作用,假設jQuery或者underscore改變了一個方法或者一個接口,那麼backbone也要跟着改,另外backbone依賴的jQuery和underscore也有一些限制,直接隨便引入這三個文件很可能就會報錯(一般情況下都引入最新的是沒有問題的),這是backbone比較不好的一個地方(要不然自身也不可能做到這麼輕量級)

參考資料
backbone官方文檔:http://backbonejs.org/
backbone中文文檔:http://www.css88.com/doc/backbone/
Why Backbone.js and ES6 Classes Don’t Mix:http://benmccormick.org/2015/04/07/es6-classes-and-backbone-js/

關於backbone&ES6的討論:
https://github.com/jashkenas/backbone/issues/3560
https://github.com/jashkenas/backbone/pull/3827

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章