任何一門語言在大規模應用階段,必然要經歷拆分模塊的過程。便於維護與團隊協作,與java走的最近的dojo率先引入加載器,早期的加載器都是同步的,使用document.write與同步Ajax請求實現。后來dojo開始以JSONP的方法設計它的每個模塊結構。以script節點為主體加載它的模塊。這個就是目前主流的加載器方式。
不得不提的是,dojo的加載器與AMD規范的發明者都是james Burke,dojo加載器獨立出來就是著名的require。本章將深入的理解加載器的原理。
1.AMD規范
AMD是"Asynchronous Module Definition"的縮寫,意思是“異步模塊定義”。重點有兩個。
- 異步:有效的避免了采用同步加載導致頁面假死的情況。
- 模塊定義:每個模塊必須按照一定的格式編寫。主要的接口有兩個,define與require。define是模塊開發者關注的方法,require是模塊使用者所關注的方法。
define的參數的情況是define(id?,deps,factory)。第一個為模塊ID,第二個為依賴列表,第三個是工廠方法。前兩個都是可選,如果不定義ID,則是匿名模塊,加載器能應用一些“魔術”能讓它辨識自己叫什么,通常情況,模塊id約等於模塊在過程中的路徑(放在線上,表現為url)。在開發過程中,很多情況未確定,一些javascript文件會移來移去的,因此,匿名模塊就大發所長。deps和factory有個約定,deps有多少個元素,factory就有多少個傳參,位置一一對應。傳參為其它模塊的返回值。
define("xxx",["aaa","bbb"], function (aaa,bbb){ //code });
通常情況下,define中還有一個amd對象,里面存儲着模塊的相關信息。
require的參數的情況是 require(deps,callback) ,第一個為依賴列表,第二個為回調。deps有多少個元素,callback就有多少個傳參,情況與define方法一致。因此在內部,define方法會調用require來加載依賴模塊,一直這樣遞歸下去。
require(["aaa","bbb"],function(aaa,bbb){ //code })
接口就是這么簡單,但require本身還包含許多特性,比如使用“!”來引入插件機制,通過requirejs.config進行各種配置。模塊只是整合的一部分,你要拆的開,也要合的來,因此合並腳本的地位在加載器中非常重要,但前端javascript沒有這功能,requirejs利用node.js寫了一個r.js幫你進行合並。
2.加載器所在的路徑探知
要加載一個模塊,我們需要一個url作為加載地址,一個script作為加載媒介。但用戶在require時都用id,因此,我們需要一個將id轉換為url的方法。思路很簡單,約定為:
basePath + 模塊id + ".js"
由於瀏覽器自上而下的分析DOM,當瀏覽器在解析我們的javascript文件(這個javascript文件是指加載器)時,它就肯定DOM樹中最后加入script標簽,因此,我們下面的這個方法。
function getBasePath(){ var nodes = document.getElementsByTagName("script"); var node = nodes[nodes.length - 1]; var src = document.querySelector ? node.src : node.getAttribute("src",4); return src;
上面的這個辦法滿足99%的需求,但是我們不得不動態加載我們的加載器呢?在舊的版本的IE下很多常規的方法都會失效,除了API差異性,它本身還有很多bug,我們很難指出是什么,總之要解決,如下面的這個javascript判斷。
document.write('<script src="avalon.js"> <\/script>'); document.write('<script src="mass.js"> <\/script>'); document.write('<script src="jQuery.js"> <\/script>');
mass.js為我們的加載器,里面執行getBasePath方法,預期得到http://1.1.1/mass.js,但是IE7確返回http://1.1.1/jQuery.js
這時就需要readyChange屬性,微軟在document、image、xhr、script等東西都擁有了這個屬性。用來查看加載情況。
function getBasePath() { var nodes = document.getElementsByTagName("script"); if (window.VBArray){ //如果是IE for (var i = 0 ; nodes; node = nodes[i++]; ) { if (node.readyState === "interactive") { break; } } } else { node = nodes[nodes.length - 1]; } var src = document.querySelector ? node.src : node.getAttribute("src",4); return src; }
這樣就搞定了,訪問DOM比一般javascript代碼消耗高許多。這樣,我們就可以使用Error對象。
function getBasePath() { try { a.b.c() } catch (e) { if (e.fileName) { //FF return e.fileName; } else if ( e.sourceURL ){ //safari return e.sourceURL; } } var nodes = document.getElementsByTagName("script"); if (window.VBArray){//倒序查找的性能更高 for (var i = nodes.length; node ; node = nodes[--i];) { if ( node.readyState === "interactive") { break; } }; } else { node = nodes[nodes.length - 1]; } var src = document.querySelector ? node.src : node.getAttribute("src",4); return src; }
在實際使用中,我們為了防止緩存,這個后面可能帶版本號,時間戳什么的,也要去掉。
url = url.replace(/[?#].*/, "").slice(0, url.lastIndexOf("/") + 1);
3.require方法
require方法的作用是當前依賴列表都加載完畢,執行用戶回調。因此,這里有個加載過程,整個加載過程細分以下幾步:
(1) 取到依賴列表的第一個id ,轉換為url ,無論是通過basePath + ID + ".js"還是通過映射方式直接得到。
(2) 檢測此模塊有沒有加載過,或正在被加載。因此有一個對象保持所有模塊的加載情況,如果有模塊從來沒有加載過,就進入加載流程。
(3) 創建script節點,綁定onerror,onload,onredyChange等事件判定加載成功與否,然后添加src並插入DOM樹。開始加載url
(4) 將模塊的url,依賴列表等構建成一個對象,放到檢測隊列中,在上面事件觸發時進行檢測。
模塊id的轉換規則:http://wiki.commonjs.org/wiki/Modules/1.1.1
除了basePath,我們通常還用到映射,就是用戶事前用一個方法,把id和完整的url對應好,這樣就直接拿。此外,AMD規范還有shim技術。shim機制的目的是讓不符合AMD規范的js文件也能無縫切入我們的加載系統。
普通別名機制:
require.config({ alias:{ 'lang' : 'http://xxx.com/lang.js', 'css' : 'http://bbb.com/css.js' } })
jQuery或其它插件,我們需要shim機制
require.config ({ alias : { 'jQuery' : { src : 'http://ahthw.com/jQuery1.1.1.js', exports : "$" }, 'jQuery.tooltips' : { src : 'http://ahthw.com/xxx.js', exports : "$", deps : ["jQuery"] } } });
下面是require的源碼
window.require = $.require = function(list, factory, parent){ //用於檢測它的依賴是否都為2 var deps = {}, //用於保存依賴模塊的返回值 args = [], //需要安裝的模塊數 dn = 0, //已經完成安裝的模塊數 cn = 0, id = parent || "callback" + setTimeOut("1"); parent = parent || basePath; //basepash為加載器的路徑 String(list).replace($.rword,function(el){ var url = loadJSCSS(el,parent) if (url) { dn++; if (modules[url] && modules[url].state === 2){ cn++; } if (!deps[url]) { args.push(url); deps[url] = "http://baidu.com" //去重 } } }); modules[id] = {//創建一個對象,記錄模塊加載情況與其他信息 id: id, factory: factory, deps: deps, args: args, state: 1 }; if (dn === cn){//如果需要的安裝等於已經安裝好 fireFactory(id, args, factory);//安裝到框架中 } else {//放到檢測隊里中,等待 checkDeps處理 loadings.unshift(id); } checkDeps(); }
每require一次,相當於把當前用戶回調當成一個不用加載的匿名模塊,ID是隨機生成,回調是否執行,需要到deps所有的值為2
require里有三個重要的方法,loadJSCSS,它用於轉換ID為url,然后再調用loadJS,loadCSS,或再調用require方法;factory,就是執行用戶回調,我們最終的目的,checkDeps,檢測依賴是否安裝好,安裝好就執行fireFactory()。
function loadJSCSS(url, parent, ret, shim){ //略去 }
loadJS和loadCSS方法就比較純粹了,不過loadJS會做一個死鏈測試的方法
function loadJS(url, callback){ //通過script節點加載目標模塊 var node = DOC.createElement("script"); node.className = moduleClass; //讓getCurrentScript只處理類名為moduleClass的script節點 node[W3C ? "onload" : "onreadystatechange" ] = function () { //factorys里邊裝着define方法的工廠函數(define(id?,deps?,factory)) var factory = factorys.pop(); if (callback) { callback(); } if (checkFail(node, false, !W3C)) { console.log("已經成功加載" + node.src, 7) }; } node.onerror = function(){ checkFail(node,true); }; //插入到head第一個節點前,防止ie6下head標簽沒有閉合前使用appendchild node.src = url; head.insertBefore(node, head.firstChild); }
checkFail主要是為了開發調試,有3個參數。node=>script節點,onError=>是否為onerror觸發,fuckIE=>對於舊版IE的Hack。
執行辦法是,javascript從加載到執行有一個過程,在interact階段,我們的javascript部分已經可以執行了,這時我們將模塊對象的state改為1,如果還是undefined,我們就可識別為死鏈。不過,此Hack對於不是AMD定義的javascript無效,因為將state改為1的邏輯是由define方法執行。如果判定是死鏈,我們就將此節點移除。
function checkFail(node, onError, fuckIE){ //多恨IE啊,哈哈 var id = node.src; //檢測是否為死鏈 node.onload = node.onreadystatechange = node.onerror = null ; if (onError || (fuckIE && !modules[id].state)){ setTimeOut(function(){ head.removeChild(node); }); console.log("加載" + id + "失敗" + onerror + " " + (!modules[id].state), 7); } esle { return true; } }
checkDeps 方法會在用戶加載模塊之前和script.onload后各執行一次,檢測模塊的依賴情況,如果模塊沒有任何依賴或者state為2了,我們調用fireFactory()方法
function checkDeps(){ loop : for (var i = loadings.length ; id ; id = loadings[--1]) { var obj = modules[id], deps = obj.deps; for (var key in deps) { if (hasOwn.call(deps, key) && modules[key].state !== 2) { continue loop; } } //如果deps為空對象或者其他依賴的模塊state為2 if (obj.state !== 2) { loadings.splice(i,1);//必須先移除再安裝,防止在IE下DOM樹建完之后會多次執行它 fireFactory (obj.id, obj.args, obj.factory); checkDeps();//如果成功,再執行一次,以防止有些模塊沒有加載好 } }; }
終於到fireFactory方法了,它的工作是從modules中收集各種模塊的返回值,執行factory,完成模塊的安裝。
function fireFactory(id, deps, factory) { for (var i = 0; array = [] , d ; d = deps[i++]; ) { array.push(modules[d].exports); }; var module = Object(modules[id]), ret = factory.apply(global, array); module.state = 2; if (ret !== void 0) { modules[id].exports = ret; } return ret; }
4.define方法
define有3個參數,前面兩個為可選,事實上這里的ID沒有什么用,就是給開發者看的,它還是用getCurrentScript方法得到script節點路徑做ID,deps沒有就補上一個空數組。
此外,define還要考慮循環依賴的問題,比如說加載A,要依賴B與C,加載B要依賴A於C,這時候,A與B就循環依賴了 。A與B在判定各自的deps鍵值都為2才執行,否則都無法執行了。
模塊加載器會讓我們前端開發變得更工業化,維護和調試都非常方便。現在國內Seajs,requirejs,KISSY都是很好的選擇。
(本章完)
上一章:第二章 : 種子模塊 下一章:第四章:語言模塊