從零開始編寫自己的JavaScript框架(一)
1. 模組的定義和載入
1.1 模組的定義
一個框架想要能支撐較大的應用,首先要考慮怎麼做模組化。有了核心和模組載入系統,外圍的模組就可以一個一個增加。不同的JavaScript框架,實現模組化方式各有不同,我們來選擇一種比較優雅的方式作個講解。
先問個問題:我們做模組系統的目的是什麼?如果覺得這個問題難以回答,可以從反面來考慮:假如不做模組系統,有什麼樣的壞處?
我們經歷過比較粗放、混亂的前端開發階段,頁面裡充滿了全域性變數,全域性函式。那時候要複用js檔案,就是把某些js函式放到一個檔案裡,然後讓多個頁面都來引用。
考慮到一個頁面可以引用多個這樣的js,這些js互相又不知道別人裡面寫了什麼,很容易造成命名的衝突,而產生這種衝突的時候,又沒有哪裡能夠提示出來。所以我們要有一種辦法,把作用域比較好地隔開。
JavaScript這種語言比較奇怪,奇怪在哪裡呢,它的現有版本里沒package跟class,要是有,我們也沒必要來考慮什麼自己做模組化了。那它是要用什麼東西來隔絕作用域呢?
在很多傳統高階語言裡,變數作用域的邊界是大括號,在{}裡面定義的變數,作用域不會傳到外面去,但我們的JavaScript大人不是這樣的,他的邊界是function。所以我們這段程式碼,i仍然能打出值:
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隔離出來,要不然,別人也定義一個同名的,就把我們覆蓋掉了。
(function() {
window.thin = {
define: function(name, dependencies, factory) {
//register a module
}
};
})();
在這個module方法內部,應當怎麼去實現呢?我們的module應當有一個地方儲存,但儲存是要在工商局內部的,不是隨便什麼人都可以看到的,所以,這個儲存結構也放在工商局同樣的作用域裡。
用什麼結構去儲存呢?工商局備案的時候,店名不能跟已有的重複,所以我們發現這是用map的很好場景,考慮到JavaScript語言層面沒有map,我們弄個Object來存。
(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框架,寫了一個類似的程式碼:
thin.define("A", [], function() {
//module A
});
thin.define("B", ["A"], function(A) {
//module B
var a = new A();
});
看這段程式碼特別在哪裡呢?模組A的定義,毫無特別之處,主要看模組B。它在依賴關係裡寫了一個字串的A,然後在工廠方法的形參寫了一個真真切切的A型別。嗯?這個有些奇怪啊,你的A型別要怎麼傳遞過來呢?其實是很簡單的,因為我們宣告瞭依賴項的陣列,所以可以從依賴項,挨個得到對應的工廠方法,然後建立例項,傳進來。
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屬性上,不需要每次都建立。以此類推,假如一個模組有多個依賴項,也可以用類似的方式寫,毫無壓力:
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模組的工廠,實參的名稱未必就要是跟依賴項一致,比如,以後我們程式碼較多,可以給依賴項和模組名稱加名稱空間,可能變成這樣:
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();
});
這段程式碼仍然可以正常執行。我們來做另外一個測試,改變形參的順序:
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裡面的這部分程式碼:
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檔案的路徑,防止以後重複載入,檢查列表中所有檔案,看看是否全部載入完了,如果全載入好了,就執行回撥。
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 小結
到此為止,我們的簡易框架的模組定義系統就完成了。完整的程式碼如下:
(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;
})();
測試程式碼如下:
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
相關文章
- 從零開始編寫自己的JavaScript框架(二)JavaScript框架
- 從零開始寫JavaScript框架(一)JavaScript框架
- 從零開始寫JavaScript框架(二)JavaScript框架
- 從零開始編寫一個 Python 非同步 ASGI WEB 框架Python非同步Web框架
- 從零開始使用JavaScript編寫資料表格控制元件(一)JavaScript控制元件
- 從零開始寫一個Javascript解析器JavaScript
- 從零開始編寫一個babel外掛Babel
- 從零開始編寫指令碼引擎指令碼
- 從零開始寫一個微前端框架-沙箱篇前端框架
- 從零開始打造自己的PHP框架――第2章PHP框架
- 從零開始手寫Koa2框架框架
- 從零開始手寫一個微前端框架-渲染篇前端框架
- 從零開始使用JavaScript編寫資料表格控制元件(二)JavaScript控制元件
- 從零開始寫一個ExporterExport
- 從零開始寫Java Web框架——maven 外掛JavaWeb框架Maven
- 從零開始仿寫一個抖音App——開始APP
- 從零開始寫一個網頁網頁
- 深度:從零編寫一個微前端框架前端框架
- 【從零開始寫一個簡單的ImageLoader框架】ImageLoader分析框架
- 從零開始實現一個RPC框架(零)RPC框架
- 從零開始寫一個微前端框架-樣式隔離篇前端框架
- 從零開始寫一個微前端框架-資料通訊篇前端框架
- 如何從零開始學習一個框架框架
- 從零開始用golang編寫一個分散式測試工具Golang分散式
- 從零開始寫一個node爬蟲(一)爬蟲
- 【從零開始寫一個簡單的ImageLoader框架】專案介紹框架
- 如何從零開始寫一個網站網站
- 從零開始 實現一個自己的指令碼引擎指令碼
- 從零開始實現一個自己的指令碼引擎指令碼
- VuePress從零開始搭建自己的部落格Vue
- Re從零開始的UI庫編寫生活之表格元件UI元件
- Re從零開始的UI庫編寫生活之表單UI
- Re從零開始的UI庫編寫生活之按鈕UI
- 從零開始實現一個RPC框架(一)RPC框架
- 開始編寫自己的技術部落格...
- 從零開始:用REACT寫一個格鬥遊戲(一)React遊戲
- 從零開始構建自己的第一個vue專案Vue
- 從零開始搭建屬於自己的網站網站