MVVM大比拼之knockout.js源码精析
简介
本文主要对源码和内部机制做较深如的分析,基础部分请参阅官网文档。
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源码精析,敬请期待。