簡介
本文主要對源碼和內部機制做較深如的分析,基礎部分請參閱官網文檔。
knockout.js (以下簡稱 ko )是最早將 MVVM 引入到前端的重要功臣之一。目前版本已更新到 3 。相比同類主要有特點有:
-
雙工綁定基於 observe 模式,性能高。
-
插件和擴展機制非常完善,無論在數據層還是展現層都能滿足各種復雜的需求。
-
向下支持到IE6
-
文檔、測試完備,社區較活躍。
入口
以下分析都將對照 github 上3.x的版本。有一點需要先了解:ko 使用 google closure compiler 進行壓縮,因為 closure compiler 會在壓縮時按一定規則改變代碼本身,所以 ko 源碼中有很多類似ko.exportSymbol('subscribable', ko.subscribable)
的語句來防止壓縮時引用丟失。願意深入了解的讀者可以自己先去讀一下 closure compiler,不了解也可以跳過。
啟動代碼示例:
var App = function(){ this.firstName = ko.observable('Planet'); this.lastName = ko.observable('Earth'); this.fullName = ko.computed({ read: function () { return this.firstName() + " " + this.lastName(); }, write: function (value) { var lastSpacePos = value.lastIndexOf(" "); if (lastSpacePos > 0) { this.firstName(value.substring(0, lastSpacePos)); this.lastName(value.substring(lastSpacePos + 1)); } }, owner: this }); } ko.applyBindings(new App,document.getElementById('ID'))
直接翻到源碼 /src/subscribables/observable.js
第一行。
ko.observable = function (initialValue) { var _latestValue = initialValue; function observable() { if (arguments.length > 0) { // Write // Ignore writes if the value hasn't changed if (observable.isDifferent(_latestValue, arguments[0])) { observable.valueWillMutate(); _latestValue = arguments[0]; if (DEBUG) observable._latestValue = _latestValue; observable.valueHasMutated(); } return this; // Permits chained assignments } else { // Read ko.dependencyDetection.registerDependency(observable); // The caller only needs to be notified of changes if they did a "read" operation return _latestValue; } } ko.subscribable.call(observable); ko.utils.setPrototypeOfOrExtend(observable, ko.observable['fn']); if (DEBUG) observable._latestValue = _latestValue; /**這里省略了專為 closure compiler 寫的語句**/
return observable; }
這就是knockout核心 ,observable對象的定義。可以看到這個函數最后返回了一個也叫做 observable
的函數,也就是用戶定義值的讀寫器(accessor)。讓我們可以通過 app.firstName()
來讀屬性,用app.firstName('William')
來寫屬性。源碼還通過 ko.subscribable.call(observable);
使這個函數有了被訂閱的功能,讓 firstName
在改變時能通知所有訂閱了它的對象。可以簡單猜想,這個訂閱功能的實現,其實就只是維護了一個回調函數的隊列,當自己的值改變時,就執行這些回調函數。根據上面的代碼,我們可以猜測回調函數應 該是在 observable.valueHasMutated();
執行的,稍后驗證。
除此之外這里只有一點要注意的,就是 ko.dependencyDetection.registerDependency(observable);
這是之后實現訂閱的核心,稍后細講。
我們再看 ko 如何將數據綁定到頁面元素上,翻到 /src/binding/bindingAttrbuteSyntax.js
426行:
ko.applyBindings = function (viewModelOrBindingContext, rootNode) { if (!jQuery && window['jQuery']) { jQuery = window['jQuery']; } if (rootNode && (rootNode.nodeType !== 1) && (rootNode.nodeType !== 8)) throw new Error("ko.applyBindings: first parameter should be your view model; second parameter should be a DOM node"); rootNode = rootNode || window.document.body; applyBindingsToNodeAndDescendantsInternal(getBindingContext(viewModelOrBindingContext), rootNode, true); };
剛開始可能覺得長函數名不太好讀,但習慣之后注釋都可以不用看了。從這里可以看到源碼創造了一個叫做 bingdingContext 的東西,並且開始和節點及其子節點綁定。我們先不繼續深入,到這里可以先看一眼 ko 的整體機制了,為了之后能清楚知道講到哪里了。

數據依賴實現
我們現在重新回過頭來看 啟動代碼和 observable 的代碼。啟動代碼中通過 computed
定義的屬性被 ko 稱為computed observables(我們暫且稱為"計算屬性") (示例中的fullName),特點是它的值是依賴於其他普通屬性的,當其他的屬性的值發生變化時,它也應該自動發生變化。我們在剛才 observable 的代碼中看到 普通屬性 已經有了 subscribe 的功能。那么我們只需要根據 計算屬性 的定義函數來生成一個 更新計算屬性值 的函數,並將它注冊到它所依賴的普通屬性(示例中的 firstName 和 lastName )的回調隊列就行了,然后等着普通屬性修改時調用這個回調函數。這些機制都很簡單,接下來的問題是,我們怎么知道 計算屬性 依賴哪些 普通屬性 ?還記得剛才代碼中的ko.dependencyDetection.registerDependency(observable);
嗎?這是寫在屬性被讀取的函數里的。我們不難想到,我們只要執行一下計算屬性的定義函數,其中被依賴的普通屬性就會被讀到。如果我們在執行計算屬性定義函數之前,把生成的計算屬性更新函數放到一個第三方作用域中保存起來,在普通屬性被讀到時,再去這個作用域中取出這個更新函數放到自己的subsrcibe隊列中,不就實現了計算屬性對普通屬性的訂閱了嗎?翻到這個registerDependency的源碼中去,/src/subscribables/dependencyDetection.js
:
registerDependency: function (subscribable) { if (currentFrame) { if (!ko.isSubscribable(subscribable)) throw new Error("Only subscribable things can act as dependencies"); currentFrame.callback(subscribable, subscribable._id || (subscribable._id = getId())); } },
發現里面有一個私有變量 currentFrame,猜想應該是用來保存計算屬性的更新函數的。在看 compute 的定義函數,/src/subscribables/dependencyObservable.js
第一行,不要被代碼長度和長函數名嚇到,直接翻到最后的return值,和普通屬性一樣返回了一個函數,叫做dependentObservable
。很明顯,它也是一個讀寫器。我們繼續往下看那些主動執行的語句,目的是找到它是否在剛才第三方的 currentFrame 中注冊了自己的更新函數。在233行找到 evaluateImmediate()
。再看這個函數的定義,果然在 81 行找到了 :
ko.dependencyDetection.begin({ callback: function(subscribable, id) { if (!_isDisposed) { if (disposalCount && disposalCandidates[id]) { _subscriptionsToDependencies[id] = disposalCandidates[id]; ++_dependenciesCount; delete disposalCandidates[id]; --disposalCount; } else { addSubscriptionToDependency(subscribable, id); } } }, computed: dependentObservable, isInitial: !_dependenciesCount });
ko.dependencyDetection.begin
並在其中注冊了一個回調函數和一些相關屬性。我們去看 這個begin 函數的定義:
function begin(options) { outerFrames.push(currentFrame); currentFrame = options; }
果然,這些注冊的東西就是被保存到了currentFrame里面。至此,計算屬性的實現機制就已經理清楚了,即:
先將自己的更新函數及相關信息注冊到第三方作用域中,再立即執行自己的定義函數。當被依賴的屬性在定義函數中被讀取時,它們會去第三方用域中取出 當前計算屬性 的更新函數等信息,並注冊到自己的回調列表中去。這其實是一種被動注冊的過程。
雙工綁定
為什么先要講數據依賴呢,因為konckout源碼的精彩之處正在於此。實際上,我們完全可以把計算屬性和普通屬性的這套實現機制應用到視圖元素與數據之間,我們把視圖元素也看做一個計算屬性不就行了嗎?我們生成一個更新視圖的函數,注冊到所依賴的數據回調中不就行了嗎。對應到之前的applyBindings代碼和圖。我們先看ko生成的那個BindingContext是什么? 通過 getBindingContext
我們發現它返回了個 bindingContext 的實例。找到定義函數,略過上面函數定義,我們找到最關鍵的76行,這里使用 ko.dependentObservable
(如果你還有印象,這個函數就是computed的別名)生成那個一個計算屬性。這個計算屬性的定義函數是 updateContext
,我們再來看這個函數的定義,里面往當前實例的成員里填充了一些作用域相關的數據,如$parent、$root等。並且它讀取傳入的數據(之后稱為ViewModel)的相關屬性,意味着只要ViewModel有變化,它也會自動變化。我們可以這樣理解,視圖除了需要數據本身外,常常還需要一些其他信息,比如上級作用域等等,因此創造了一個bingdingContext對象,它不僅能完美隨着數據變化而變化,還包含了其他信息以供視圖使用。之后我們只要把視圖函數的更新函數注冊到這個對象的回調隊列里就好了。
好,我們回到源碼看看真實實現,還是回到applyBindings函數,開始看applyBindingsToNodeAndDescendantsInternal
函數。跟着直覺都應該知道主線在 225 行的
applyBindingsToNodeInternal
函數。繼續跳,274行。記住剛才傳遞給這個函數的值,node就是一個視圖node,sourceBindings是null,bindingContext就是之前生成的。這里源碼比較復雜了,讀者最好自己也對照一下源碼。讀到這里要重新強調了一下了,我們當前的目的是挖掘節點是如何和bingdingContext進行綁定的。不妨先自己想想。我們回顧一下 ko 在節點進行綁定的語法是什么樣的 :
<div data-bind="text : c,visible: shouldShowMessage""></div>
這個節點上有兩個綁定,一個是text一個是visible。他們以 ,
分割,並且對應不同的ViewModel屬性。那么我們肯定要通過詞法解析或其他手段從節點的data-bind中取出這些綁定信息,然后一個一個將相應的視圖更新函數注冊到相應的屬性回調隊列中。看源碼:
300 行又得到一個計算屬性bindingsUpdater
(這時候已經不是什么屬性了,不過我們暫時還是這樣稱呼吧)。
var bindingsUpdater = ko.dependentObservable( function() { bindings = sourceBindings ? sourceBindings(bindingContext, node) : getBindings.call(provider, node, bindingContext); // Register a dependency on the binding context to support obsevable view models. if (bindings && bindingContext._subscribable) bindingContext._subscribable(); return bindings; }, null, { disposeWhenNodeIsRemoved: node } );
它的定義函數中通過 getBindings
函數讀到了 bingdingContext。並且賦值給 bingdings
。看注釋你也知道了這個bindings保存的就是節點上的綁定信息。這里插入一下,你應該已經發現 ko 代碼里廣泛地用到了dependentObservable
,實際上,你只要想讓什么數據和其他數據保持更新聯動,你就可以通過它來實現。比如這段代碼就把bingdings這個變量和bindingContext關聯起來了。如果你想再把什么數據和bindings綁定起來,只要使用dependentObservable注冊一個函數,並在函數讀到bindingsUpdater就行了。一個簡單地機制,構建了一個多么精彩的世界。
好了,繼續往下看,345行有個 forEach,應該就是為把每一個綁定和相應地屬性綁在一起了。果然,如果你仔細看了ko文檔里關於自定義banding的章節,你應該一看到handler['init']和handler['update']就明白了。正是這里,bingding通過init函數將node的變化映射到數據變化上,再將數據變化通過dependentObservable和node的update綁定起來。
至此,視圖到數據,數據到視圖的雙工引擎搞定!
其他
看完雙工模型,再對着ko的文檔看看它的插件機制,你應該已經能很輕松地運用把它了。推薦讀者再自己看看它對數組數據的處理。對數組的和嵌套對象的處理一直是MVVM在性能等方面的一大課題。我之后在其他框架源碼分析中也會講到。ko在這方面實現上並無亮點,讀者自己看看就好。
總體來說,ko的文檔、注釋之完備,源碼之精彩可謂業界楷模。聊以此文拋磚引玉,與君共賞。明天將帶來avalon源碼精析,敬請期待。