有一定Web前端開發經驗的人,很多都會有這麼個想法:那些寫框架的人好厲害,什麼時候我才能寫一個自己的框架呢?有時候看看別人的框架程式碼,又覺得很複雜,不知道從何看起,只有很少的人突破了這個界限,領悟到了更深層的東西。
對於這種情況,我覺得有必要改變一下。為此,打算自己寫幾個系列的文章來讓很多人能從中領會一些前端框架的知識,帶領他們走進框架開發的殿堂。
為了說明框架的一些基本原理,我寫了一個簡單的框架,取名為thin。thin框架的核心是模組定義和載入機制,整個框架唯一暴露的全域性變數是thin,包含了模組定義,模組獲取,日誌等基本功能,其餘一切功能都按照模組掛接在框架上。
thin框架的最小發布單元是模組定義和載入機制,其他一切功能都作為可選元件。
可選元件包括:
– 通用幫助類
– DOM操作
– 遠端呼叫
– 檢視模型和資料繫結
– 控制元件庫
1. 模組的定義和載入
1.1 模組的定義
一個框架想要能支撐較大的應用,首先要考慮怎麼做模組化。有了核心和模組載入系統,外圍的模組就可以一個一個增加。不同的JavaScript框架,實現模組化方式各有不同,我們來選擇一種比較優雅的方式作個講解。
先問個問題:我們做模組系統的目的是什麼?如果覺得這個問題難以回答,可以從反面來考慮:假如不做模組系統,有什麼樣的壞處?
我們經歷過比較粗放、混亂的前端開發階段,頁面裡充滿了全域性變數,全域性函式。那時候要複用js檔案,就是把某些js函式放到一個檔案裡,然後讓多個頁面都來引用。
考慮到一個頁面可以引用多個這樣的js,這些js互相又不知道別人裡面寫了什麼,很容易造成命名的衝突,而產生這種衝突的時候,又沒有哪裡能夠提示出來。所以我們要有一種辦法,把作用域比較好地隔開。
JavaScript這種語言比較奇怪,奇怪在哪裡呢,它的現有版本里沒package跟class,要是有,我們也沒必要來考慮什麼自己做模組化了。那它是要用什麼東西來隔絕作用域呢?
在很多傳統高階語言裡,變數作用域的邊界是大括號,在{}裡面定義的變數,作用域不會傳到外面去,但我們的JavaScript大人不是這樣的,他的邊界是function。所以我們這段程式碼,i仍然能打出值:
1 2 3 4 |
for (var i=0; i<5; i++) { //do something } alert(i); |
那麼,我們只能選用function做變數的容器,把每個模組封裝到一個function裡。現在問題又來了,這個function本身的作用域是全域性的,怎麼辦?我們想不到辦法,拔劍四顧心茫然。
我們有沒有什麼可參照的東西呢?這時候,腦海中一群語言飄過: C語言飄過:“我不是面嚮物件語言哦~不需要像你這麼組織哦~”,“死開!” Java飄過:“我是純面嚮物件語言哦,連main都要在類中哦,編譯的時候通過裝箱清單指定入口哦~”,“死開!” C++飄過:“我也是純面嚮物件語言哦”,等等,C++是純物件導向的語言嗎?你的main是什麼???main是特例,不在任何類中!
啊,我們發現了什麼,既然無法避免全域性的作用域,那與其讓100個function都全域性,不如只讓一個來全域性,其他的都由它管理。
本來我們打算自己當上帝的,現在只好改行先當個工商局長。你想開店嗎?先來註冊,不然封殺你!於是良民們紛紛來註冊。店名叫什麼,從哪進貨,賣什麼的,一一登記在案,為了方便下面的討論,我們連進貨的過程都讓工商局管理起來。
店名,指的就是這裡的模組名,從哪裡進貨,代表它依賴什麼其他模組,賣什麼,表示它對外提供一些什麼特性。
好了,考慮到我們的這個註冊管理機構是個全域性作用域,我們還得把它掛在window上作為屬性,然後再用一個function隔離出來,要不然,別人也定義一個同名的,就把我們覆蓋掉了。
1 2 3 4 5 6 7 |
(function() { window.thin = { define: function(name, dependencies, factory) { //register a module } }; })(); |
在這個module方法內部,應當怎麼去實現呢?我們的module應當有一個地方儲存,但儲存是要在工商局內部的,不是隨便什麼人都可以看到的,所以,這個儲存結構也放在工商局同樣的作用域裡。
用什麼結構去儲存呢?工商局備案的時候,店名不能跟已有的重複,所以我們發現這是用map的很好場景,考慮到JavaScript語言層面沒有map,我們弄個Object來存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
(function() { var moduleMap = {}; window.thin = { define: function(name, dependencies, factory) { if (!moduleMap[name]) { var module = { name: name, dependencies: dependencies, factory: factory }; moduleMap[name] = module; } return moduleMap[name]; } }; })(); |
現在,模組的儲存結構就搞好了。
1.2 模組的使用
存的部分搞好了,我們來看看怎麼取。現在來了一個商家,賣木器的,他需要從一個賣釘子的那邊進貨,賣釘子的已經來註冊過了,現在要讓這個木器廠能買到釘子。現在的問題是,兩個商家處於不同的作用域,也就是說,它們互相不可見,那通過什麼方式,我們才能讓他們產生呼叫關係呢?
個人解決不了的問題還是得靠政府,有困難要堅決克服,沒有困難就製造困難來克服。現在困難有了,該克服了。商家說,我能不能給你我的進貨名單,你幫我查一下它們在哪家店,然後告訴我?這麼簡單的要求當然一口答應下來,但是採用什麼方式傳遞給你呢?這可犯難了。
我們參考AngularJS框架,寫了一個類似的程式碼:
1 2 3 4 5 6 7 8 |
thin.define("A", [], function() { //module A }); thin.define("B", ["A"], function(A) { //module B var a = new A(); }); |
看這段程式碼特別在哪裡呢?模組A的定義,毫無特別之處,主要看模組B。它在依賴關係裡寫了一個字串的A,然後在工廠方法的形參寫了一個真真切切的A型別。嗯?這個有些奇怪啊,你的A型別要怎麼傳遞過來呢?其實是很簡單的,因為我們宣告瞭依賴項的陣列,所以可以從依賴項,挨個得到對應的工廠方法,然後建立例項,傳進來。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
use: function(name) { var module = moduleMap[name]; if (!module.entity) { var args = []; for (var i=0; i<module.dependencies.length; i++) { if (moduleMap[module.dependencies[i]].entity) { args.push(moduleMap[module.dependencies[i]].entity); } else { args.push(this.use(module.dependencies[i])); } } module.entity = module.factory.apply(noop, args); } return module.entity; } |
我們可以看到,這裡面遞迴獲取了依賴項,然後當作引數,用這個模組的工廠方法來例項化了一下。這裡我們多做了一個判斷,如果模組工廠已經執行過,就快取在entity屬性上,不需要每次都建立。以此類推,假如一個模組有多個依賴項,也可以用類似的方式寫,毫無壓力:
1 2 3 4 5 6 |
thin.define("D", ["A", "B", "C"], function(A, B, C) { //module D var a = new A(); var b = new B(); var c = new C(); }); |
注意了,D模組的工廠,實參的名稱未必就要是跟依賴項一致,比如,以後我們程式碼較多,可以給依賴項和模組名稱加名稱空間,可能變成這樣:
1 2 3 4 5 6 |
thin.define("foo.D", ["foo.A", "foo.B", "foo.C"], function(A, B, C) { //module D var a = new A(); var b = new B(); var c = new C(); }); |
這段程式碼仍然可以正常執行。我們來做另外一個測試,改變形參的順序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
thin.define("A", [], function() { return "a"; }); thin.define("B", [], function() { return "b"; }); thin.define("C", [], function() { return "c"; }); thin.define("D", ["A", "B", "C"], function(B, A, C) { return B + A + C; }); var D = thin.use("D"); alert(D); |
試試看,我們的D打出什麼結果呢?結果是”abc”,所以說,模組工廠的實參只跟依賴項的定義有關,跟形參的順序無關。我們看到,在AngularJS裡面,並非如此,實參的順序是跟形參一致的,這是怎麼做到的呢?
我們先離開程式碼,思考這麼一個問題:如何得知函式的形參名陣列?對,我們是可以用func.length得到形參個數,但無法得到每個形參的變數名,那怎麼辦呢?
AngularJS使用了一種比較極端的辦法,分析了函式的字面量。眾所周知,在JavaScript中,任何物件都隱含了toString方法,對於一個函式來說,它的toString就是自己的實現程式碼,包含函式簽名和註釋。下面我貼一下AngularJS裡面的這部分程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m; var FN_ARG_SPLIT = /,/; var FN_ARG = /^\s*(_?)(\S+?)\1\s*$/; var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg; function annotate(fn) { var $inject, fnText, argDecl, last; if (typeof fn == 'function') { if (!($inject = fn.$inject)) { $inject = []; fnText = fn.toString().replace(STRIP_COMMENTS, ''); argDecl = fnText.match(FN_ARGS); forEach(argDecl[1].split(FN_ARG_SPLIT), function(arg){ arg.replace(FN_ARG, function(all, underscore, name){ $inject.push(name); }); }); fn.$inject = $inject; } } else if (isArray(fn)) { last = fn.length - 1; assertArgFn(fn[last], 'fn'); $inject = fn.slice(0, last); } else { assertArgFn(fn, 'fn', true); } return $inject; } |
可以看到,這個程式碼也不長,重點是型別為function的那段,首先去除了註釋,然後獲取了形參列表字串,這段正則能獲取到兩個結果,第一個是全函式的實現,第二個才是真正的形參列表,取第二個出來split,就得到了形參的字串列表了,然後按照這個順序再去載入依賴模組,就可以讓形參列表不對應於依賴項陣列了。
AngularJS的這段程式碼很強大,但是要損耗一些效能,考慮到我們的框架首要原則是簡單,甚至可以為此犧牲一些靈活性,我們不做這麼複雜的事情了。
1.3 模組的載入
到目前為止,我們可以把多個模組都定義在一個檔案中,然後手動引入這個js檔案,但是如果一個頁面要引用很多個模組,引入工作就變得比較麻煩,比如說,單頁應用程式(SPA)一般比較複雜,往往包含數以萬計行數的js程式碼,這些程式碼至少分佈在幾十個甚至成百上千的模組中,如果我們也在主介面就載入它們,載入時間會非常難以接受。但我們可以這樣看:主介面載入的時候,並不是用到了所有這些功能,能否先載入那些必須的,而把剩下的放在需要用的時候再去載入?
所以我們可以考慮萬能的AJAX,從服務端獲取一個js的內容,然後……,怎麼辦,你當然說不能eval了,因為據說eval很evil啦,但是它evil在哪裡呢?主要是破壞全域性作用域啦,怎麼怎麼,但是如果這些檔案裡面都是按照我們規定的模組格式寫,好像也沒有什麼在全域性作用域的……,好吧。
算了,我們還是用最簡單的方式了,就是動態建立script標籤,然後設定src,新增到document.head裡,然後監聽它們的完成事件,做後續操作。真的很簡單,因為我們的框架不需要考慮那麼多種情況,不需要AMD,不需要require那麼麻煩,用這框架的人必須按照這裡的原則寫。
所以,說真的我們這裡沒那麼複雜啦,要是你們想看更詳細原理的不如去看這個,解釋得比我好哎:http://coolshell.cn/articles/9749.html#jtss-tsina
[補一段,@Franky 大神指出了這篇文章中一些不符合現狀的地方,我把它也貼在這裡,供讀者參考]
很多觀點都是 史蒂夫那本老書上的觀點. 和那時候同期產生的一些資料和資料…所以顯得不少東西說的太想當然了… 譬如script標籤的載入和執行會阻塞後面資源的載入和執行之類的.說的過於肯定了. 比如chrome7+就開始逐漸改進的 預載入機制 就分 head 裡的資源, body裡的資源 .兩個資源是否跨界三種情形. 不提這些瀏覽器. 我們看看ie10也同樣改進了 死迴圈10秒 這後面的圖片能被提前載入. 就更不用說其他A級瀏覽器的豐富的優化策略了. 所以還是建議博主, 別拿幾年前的老資料作為依據.尤其這些資料是用來說明更新速度像在賽跑一樣的各個瀏覽器了.
關於 defer , 似乎史蒂夫的老書上是這麼說的麼? 顯然沒有測試全非ie瀏覽器的各個版本.或者是他測試資料的時候ff某大版本的幾個beta子版本還沒出現?
其次是就你的載入器提到的預載入策略. 你有測過所有瀏覽器用object預載入可能涉及到的問題麼(比如chrome,8,9的預載入的會話級別的資源型別快取bug). 拋開這個問題不談,假設你預載入到一半,使用者再次觸發了載入.你覺得這種情況如果頻繁發生.是否合適? 你的預載入策略連script.onload狀態都無法測知,進一步優化的可能性就消失了. 考慮下為什麼seajs 的 umd要設計成那個樣子?
最後吐槽下你的程式碼. 有注意到你用 document.body.appendChild 來像dom 中插入指令碼. 我的建議是 永遠不要這樣做.除非你可以無視ie6使用者.以及ie7缺失某些補丁的子版本.
你可以選擇body 可以.但請用insertBefore. 但在某些極端情況下.這仍然會發生問題. 最佳實踐是 head.insertBefore 向其第一個子節點插入.(你甚至無需檢測是否存在子節點. 這個api會在沒有子節點的時候,行為同appendChild). 而更加穩妥的情況是. 如果注入script. 發現document.head還沒有被構建時. 可以自己造一個. 這才是一個通用載入器要做到的程度…
我也偷懶了,只是貼一下程式碼,順便解釋一下,介面把所依賴的js檔案路徑放在陣列裡,然後挨個建立script標籤,src設定為路徑,新增到head中,監聽它們的完成事件。在這個完成時間裡,我們要做這麼一些事情:在fileMap裡記錄當前js檔案的路徑,防止以後重複載入,檢查列表中所有檔案,看看是否全部載入完了,如果全載入好了,就執行回撥。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
require: function (pathArr, callback) { for (var i = 0; i < pathArr.length; i++) { var path = pathArr[i]; if (!fileMap[path]) { var head = document.getElementsByTagName('head')[0]; var node = document.createElement('script'); node.type = 'text/javascript'; node.async = 'true'; node.src = path + '.js'; node.onload = function () { fileMap[path] = true; head.removeChild(node); checkAllFiles(); }; head.appendChild(node); } } function checkAllFiles() { var allLoaded = true; for (var i = 0; i < pathArr.length; i++) { if (!fileMap[pathArr[i]]) { allLoaded = false; break; } } if (allLoaded) { callback(); } } } |
1.4 小結
到此為止,我們的簡易框架的模組定義系統就完成了。完整的程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
(function () { var moduleMap = {}; var fileMap = {}; var noop = function () { }; var thin = { define: function(name, dependencies, factory) { if (!moduleMap[name]) { var module = { name: name, dependencies: dependencies, factory: factory }; moduleMap[name] = module; } return moduleMap[name]; }, use: function(name) { var module = moduleMap[name]; if (!module.entity) { var args = []; for (var i=0; i<module.dependencies.length; i++) { if (moduleMap[module.dependencies[i]].entity) { args.push(moduleMap[module.dependencies[i]].entity); } else { args.push(this.use(module.dependencies[i])); } } module.entity = module.factory.apply(noop, args); } return module.entity; }, require: function (pathArr, callback) { for (var i = 0; i < pathArr.length; i++) { var path = pathArr[i]; if (!fileMap[path]) { var head = document.getElementsByTagName('head')[0]; var node = document.createElement('script'); node.type = 'text/javascript'; node.async = 'true'; node.src = path + '.js'; node.onload = function () { fileMap[path] = true; head.removeChild(node); checkAllFiles(); }; head.appendChild(node); } } function checkAllFiles() { var allLoaded = true; for (var i = 0; i < pathArr.length; i++) { if (!fileMap[pathArr[i]]) { allLoaded = false; break; } } if (allLoaded) { callback(); } } } }; window.thin = thin; })(); |
測試程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
thin.define("constant.PI", [], function() { return 3.14159; }); thin.define("shape.Circle", ["constant.PI"], function(pi) { var Circle = function(r) { this.r = r; }; Circle.prototype = { area : function() { return pi * this.r * this.r; } } return Circle; }); thin.define("shape.Rectangle", [], function() { var Rectangle = function(l, w) { this.l = l; this.w = w; }; Rectangle.prototype = { area: function() { return this.l * this.w; } }; return Rectangle; }); thin.define("ShapeTypes", ["shape.Circle", "shape.Rectangle"], function(Circle, Rectangle) { return { CIRCLE: Circle, RECTANGLE: Rectangle }; }); thin.define("ShapeFactory", ["ShapeTypes"], function(ShapeTypes) { return { getShape: function(type) { var shape; switch (type) { case "CIRCLE": { shape = new ShapeTypes[type](arguments[1]); break; } case "RECTANGLE": { shape = new ShapeTypes[type](arguments[1], arguments[2]); break; } } return shape; } }; }); var ShapeFactory = thin.use("ShapeFactory"); alert(ShapeFactory.getShape("CIRCLE", 5).area()); alert(ShapeFactory.getShape("RECTANGLE", 3, 4).area()); |
在這個例子裡定義了四個模組,每個模組只需要定義自己所直接依賴的模組,其他的可以不必定義。也可以來這裡看測試連結:http://xufei.github.io/thin/demo/demo.0.1.html