MVVM大比拼之AngularJS源碼精析
簡介
AngularJS的學習資源已經非常非常多了,AngularJS基礎請直接看官網文檔。這里推薦幾個深度學習的資料:
- AngularJS學習筆記 作者:鄒業盛 。這個筆記非常細致,記錄了作者對於AngularJS各個方面的思考,其中也不乏源碼級的分析。
- 構建自己的AngularJS 。雖然放出第一章后作者就寫書去了。但這第一部分已經足以帶領讀者深入窺探angularJS在核心概念上的實現,特別是dirty check。有願意繼續深入的讀者可以去買書。
- Design Decisions in AngularJS。 google io 上AngularJS作者的演講視頻,非常值得一看。
其實隨便google一下就會看到非常的多的AngularJS的深度文章,AngularJS的開發團隊本身對外也非常活躍。特別是現在AngularJS 2.0也在火熱設計和開發中,大家完全可以把握這個機會跟進一下。設計文檔在這里。在這些資料面前,我的源碼分析只能算是班門弄斧了。不過人總要自己思考,否則和咸魚沒有區別。 以下源碼以1.3.0為准。
入口
除了使用 ng-app,angular還有手工的入口:
angular.bootstrap(document,['module1','module2'])
angularJS build的相關信息和文件結構翻閱一下gruntFile就清楚了。我們直擊/src/Angular.js 的1381行 bootstrap 定義:
function bootstrap(element, modules, config) { if (!isObject(config)) config = {}; var defaultConfig = { strictDi: false }; config = extend(defaultConfig, config); var doBootstrap = function() { element = jqLite(element); if (element.injector()) { var tag = (element[0] === document) ? 'document' : startingTag(element); throw ngMinErr('btstrpd', "App Already Bootstrapped with this Element '{0}'", tag); } modules = modules || []; modules.unshift(['$provide', function($provide) { $provide.value('$rootElement', element); }]); modules.unshift('ng'); var injector = createInjector(modules, config.strictDi); injector.invoke(['$rootScope', '$rootElement', '$compile', '$injector', '$animate', function(scope, element, compile, injector, animate) { scope.$apply(function() { element.data('$injector', injector); compile(element)(scope); }); }] ); return injector; }; var NG_DEFER_BOOTSTRAP = /^NG_DEFER_BOOTSTRAP!/; if (window && !NG_DEFER_BOOTSTRAP.test(window.name)) { return doBootstrap(); } window.name = window.name.replace(NG_DEFER_BOOTSTRAP, ''); angular.resumeBootstrap = function(extraModules) { forEach(extraModules, function(module) { modules.push(module); }); doBootstrap(); }; }
已經熟練使用AngularJS的讀者應該馬上就注意到,代碼中部的createInjector和后面的幾行代碼就已經暴露了兩個核心概念的入口:“依賴注入”和“視圖編譯”。
依賴注入
先不要急着去看 createInjector 的定義, 先看看后面這一句 injector.invoke()。在angular中有顯式注入和隱式注入,這里是顯式。往 invoke 中傳如的參數是個數組,數組前n-1個參數對應着對最后一個函數的每一個參數,也就是最后一個函數中要傳入的依賴。不難猜想,injector應該是個對象,其中保存了所有已經實例化過的service等可以作為依賴的函數或對象,調用invoke時就會按名字去取依賴。現在讓我們去驗證吧。翻到 /src/auto/injector.js 609:
function createInjector(modulesToLoad, strictDi) { strictDi = (strictDi === true); var INSTANTIATING = {}, providerSuffix = 'Provider', path = [], loadedModules = new HashMap(), providerCache = { $provide: { provider: supportObject(provider), factory: supportObject(factory), service: supportObject(service), value: supportObject(value), constant: supportObject(constant), decorator: decorator } }, providerInjector = (providerCache.$injector = createInternalInjector(providerCache, function() { throw $injectorMinErr('unpr', "Unknown provider: {0}", path.join(' <- ')); }, strictDi)), instanceCache = {}, instanceInjector = (instanceCache.$injector = createInternalInjector(instanceCache, function(servicename) { var provider = providerInjector.get(servicename + providerSuffix); return instanceInjector.invoke(provider.$get, provider, undefined, servicename); }, strictDi)); forEach(loadModules(modulesToLoad), function(fn) { instanceInjector.invoke(fn || noop); }); return instanceInjector; /*下面省略若干函數定義*/ }
我們從最后的返回值看到,真實的injector對象又是由 createInternalInjector 創造的。只不過最后對於所有需要加載的模塊(也就是參數modulesToLoad),主動使用instanceInjector.invoke執行了一次。明顯這個invoke和前面講到的invoke是同一個函數,但是前面傳的參是數組,用來顯示傳入依賴,這里傳的參看起來是函數,那很有可能是隱式注入的調用。 另外值得注意的是這里有個 providerInjector 也是用 createInternalInjector 創造的。它在instancInjector 的 createInternalInjector 中被用到了。
下面讓我們看看 createInternalInjector :
function createInternalInjector(cache, factory) { function getService(serviceName) { /*省略*/ } function invoke(fn, self, locals, serviceName){ if (typeof locals === 'string') { serviceName = locals; locals = null; } var args = [], $inject = annotate(fn, strictDi, serviceName), length, i, key; for(i = 0, length = $inject.length; i < length; i++) { key = $inject[i]; if (typeof key !== 'string') { throw $injectorMinErr('itkn', 'Incorrect injection token! Expected service name as string, got {0}', key); } args.push( locals && locals.hasOwnProperty(key) ? locals[key] : getService(key) ); } if (!fn.$inject) { // this means that we must be an array. fn = fn[length]; } // http://jsperf.com/angularjs-invoke-apply-vs-switch // #5388 return fn.apply(self, args); } function instantiate(Type, locals, serviceName) { /*省略*/ } return { invoke: invoke, instantiate: instantiate, get: getService, annotate: annotate, has: function(name) { return providerCache.hasOwnProperty(name + providerSuffix) || cache.hasOwnProperty(name); } }; }
我們快先看看之前對 invoke 函數的猜測是否正確,我們前面看到了調用它時第一個參數為數組或者函數,如果你記性不錯的話,應該也注意到前面還有一句:
instanceInjector.invoke(provider.$get, provider, undefined, servicename)
好,我們來看 invoke。注意 $inject = annotate(fn, strictDi, serviceName) 。這里的第一個參數 fn 就是之前提到的可以是數組也可以是函數。大家自己去看 annotate 的定義吧,就是這一句,提取出了所有依賴的名字,對於隱式注入試用 toString 加上 正則匹配來提取的,所以如果 angular 應用代碼壓縮時進行了變量名混淆的話,隱式注入就失效了。繼續看,提取出名字之后,通過 getService 獲取到了每一個依賴的實例,最后在用 fn.apply 傳入依賴即可。 還記得之前的 providerInjector 嗎,它其實是用來提供一些快速注冊 service 等可依賴實例的。它提供的一些方法其實都直接暴露到了 angular 對象上,大家如果仔細看過文檔其實就很明了了:
總體來說依賴注入在實現上並沒有什么特別巧妙的地方,但有價值的是angular從很早就有了完整的模塊化體系,依賴是模塊化體系中很重要的一部分。而模塊化的意義也不只是拆分、解耦而已,從工程實踐的角度來說,模塊化是實現那些超越單個工程師所能掌握的大工程的基石之一。
視圖編譯
關於 $compile 的使用和相應地內部機制其實文檔已經很詳細了。看這里。我們這里看源碼的目的有兩個:一是看數據改動時觸發的 $digest 具體是如何更新視圖的;二是看源碼是否有些精妙之處可以學習。 打開 /src/ng/compile.js 511行,注意到這里定義的 $compileProvider 是 provider 的寫法,不熟悉的請去看下文檔。provider在用的時候會實例化,而我們在用的 $compile 函數實際上就是 this.$get 這個數組的最后一個元素(一個函數)的返回值。跳到638行看定義,源碼太長,我就不貼了。后面只貼關鍵的地方。這個函數的返回了一個叫compile的函數:
function compile($compileNodes, transcludeFn, maxPriority, ignoreDirective, previousCompileContext) { /*省略若干行預處理節點的代碼*/ var compositeLinkFn = compileNodes($compileNodes, transcludeFn, $compileNodes, maxPriority, ignoreDirective, previousCompileContext); safeAddClass($compileNodes, 'ng-scope'); return function publicLinkFn(scope, cloneConnectFn, transcludeControllers){ /*省略若干行和cloneConnectFn等有關的代碼*/ if (compositeLinkFn) compositeLinkFn(scope, $linkNode, $linkNode); return $linkNode; }; }
沒有什么神奇的,返回的這個publicLinkFn就是我們用來link scope的函數。而這個函數實際上又是調用了 compileNodes 生成的 compositeLinkFn。如果你熟悉 directive 的使用,那我們不妨輕松地猜測一下這個 compileNodes 應該就是收集了節點中的各種指令然后調用相應地compile函數,並將link函數組合起來成為一個新函數,也就是這個compositeLinkFn以供調用。而 directive 里的link函數扮演了將scope的變化映射到節點上(使用 scope.$watch),將節點變化映射到scope(通常要用scope.$apply來觸發scope.$digest)的角色。 我可以直接說“恭喜你,猜對了”嗎?這里沒什么復雜的,大家自己看下吧。值得再看看的是scope.$watch 和 scope.$digest。通常我們用 watch 來將視圖更新函數注冊相應地scope下,用digest來對比當前scope的屬性是否有變動,如果有變化就調用注冊的這些函數。我前面文章中說的angular性能不如ko等框架並且可能遇到瓶頸就是出於這個機制。我們來翻一下$digest的底:
$digest: function() { /*省略若干變量定義代碼*/ beginPhase('$digest'); lastDirtyWatch = null; do { // "while dirty" loop dirty = false; current = target; /*省略若干行異步任務代碼*/ traverseScopesLoop: do { // "traverse the scopes" loop if ((watchers = current.$$watchers)) { // process our watches length = watchers.length; while (length--) { try { watch = watchers[length]; // Most common watches are on primitives, in which case we can short // circuit it with === operator, only when === fails do we use .equals if (watch) { if ((value = watch.get(current)) !== (last = watch.last) && !(watch.eq ? equals(value, last) : (typeof value == 'number' && typeof last == 'number' && isNaN(value) && isNaN(last)))) { dirty = true; lastDirtyWatch = watch; watch.last = watch.eq ? copy(value) : value; watch.fn(value, ((last === initWatchVal) ? value : last), current); /*省略若干行log代碼*/ } else if (watch === lastDirtyWatch) { // If the most recently dirty watcher is now clean, short circuit since the remaining watchers // have already been tested. dirty = false; break traverseScopesLoop; } } } catch (e) { clearPhase(); $exceptionHandler(e); } } } // Insanity Warning: scope depth-first traversal // yes, this code is a bit crazy, but it works and we have tests to prove it! // this piece should be kept in sync with the traversal in $broadcast if (!(next = (current.$$childHead || (current !== target && current.$$nextSibling)))) { while(current !== target && !(next = current.$$nextSibling)) { current = current.$parent; } } } while ((current = next)); // `break traverseScopesLoop;` takes us to here if((dirty || asyncQueue.length) && !(ttl--)) { clearPhase(); /*省略若干 throw error*/ } } while (dirty || asyncQueue.length); clearPhase(); while(postDigestQueue.length) { try { postDigestQueue.shift()(); } catch (e) { $exceptionHandler(e); } } }
這段代碼有兩個關鍵的loop,對應兩個關鍵概念。大loop就是所謂的dirty check。什么是dirty?只要進入了這個循環,就是dirty的,直到值已經穩定下來。我們看到源碼中用了lastDirtyWatch來作為標記,要使watch === lastDirtyWatch,至少第二次循環才能實現。這是因為在調用監聽函數的時候,監聽函數本身可能去修改屬性,所以我們必須等到值已經完全不變了(或者超過了最大循環值)才能結束digest。另外看那個insanity warning,digest是進行深度優先遍歷檢測的。所以在設計復雜的directive時,要非常注意在scope哪個層級調用digest。在寫簡單應用的時候,dirty check和遍歷子元素都沒有什么問題,但是相比於基於observer的模式,最主要的缺點是它的所有監聽函數都是注冊在scope上的,每次digest都要檢測所有的watcher是否有變化。
最后總結一下視圖,angular在視圖層的設計上較為完備,但同時概念也更多更復雜,在首屏渲染時速度不夠快。並且內存開銷是vue ko等輕框架倍數級的。但它的本身的規范和各個方面考慮的周全性確是非常值得學習,實際上也對后來者產生了極大的指導性意義。
其他
這里再記錄一個實踐中的問題,就是如何對數據實現getter 和setter?比如說這樣一個場景:有個三個輸入框,第一個讓用戶填姓,第二個填名,第三個自動顯示“姓+空格+名”。用戶也可以直接在第三個框中填,第一框和第二框會自動變化。這個時候如果有類似於ko的computed property就簡單了,不然只能用$watch加中間變量去實現,代碼會有點難看。有代碼潔癖的話相信各位遲早會碰到這個問題,以下提供幾個參考資料:
- https://github.com/angular/angular.js/issues/768
- http://stackoverflow.com/questions/11216651/computed-properties-in-angular-js 請看第二個答案。
- http://stackoverflow.com/questions/21289577/getter-setter-support-with-ng-model-in-angularjs
總結
總體來說,AngularJS無論在設計還是實踐上都具有指導性意義。對新手來說學習曲線較陡,但如果能深入,收獲是很大的。AngularJS本身在工程上也有很多其他產出,比如karma,從它中間獨立出來發展成了通用測試框架。還是建議各位讀者可以跟一跟AngularJS2.0的開發,必能受益。