RequireJS結構分析,實現自己的模組載入系統

積木村の研究所發表於2015-12-11

近年來,前端變的越來越重,頁面中的javascript程式碼量上升了一個量級,為了便於維護和團隊協作,模組化是必經之路。針對javascript模組化,業界逐漸產出兩種方案AMD和CMD,它們有什麼區別呢?看看大牛玉帛在知乎上的回答:http://www.zhihu.com/question/20342350/answer/14828786

其中最重要的區別是AMD是提前執行,CMD是延遲執行。

//by foio.github.io
//CMD 推崇依賴就近,代表為seajs
define(function(require,exports,module){
    var a = require('./a');    
    a.doSomething();
    var b = require('./b');
    b.doSomething();
})

//AMD 推崇依賴前置,代表為requireJs
//by foio.github.io
define(['./a','./b'],function(a,b){
    a.doSomething();    
    b.doSomething();
});

本篇文章我們實現一個自己的requreJS,我並不打算在原始碼中處理瀏覽器相容性等細節,過於關注細節,反而會影響我們從巨集觀上理解程式碼機構。本文的目的是實現一個基本可用的javascript模組載入系統,帶領讀者理解要實現一個模組載入系統需要的知識結構。瞭解幾種在網頁中非同步載入javascript的方案(可以參考我的一篇部落格),對理解本文有很大幫助。我提出如下兩個基本問題:

(1).RequireJS是如何非同步載入js模組

(2).RequireJS是如何處理模組間的依賴關係的(包括如何處理迴圈依賴)

接下來我就來分別分析這兩個問題。在開始之前,我們先看一下本文中javascript程式碼的層次結構。

require structure

1. RequireJS是如何非同步載入js模組

如果你看過這篇專門講解如何非同步載入javascript模組的文章,就會發現RequireJS用的就是其中的script dom element的方法。下面是具體實現的虛擬碼。

//by foio.github.io
//foioRequireJS的js載入函式 
foioRequireJS.loadJS = function(url,callback){
    //建立script節點
    var node = document.createElement("script");
    node.type="text/javascript";
    //監聽指令碼載入完成事件,針對符合W3C標準的瀏覽器監聽onload事件即可
    node.onload = function(){
        if(callback){
            callback();
        }
    };
    //監聽onerror事件處理javascript載入失敗的情況
    node.onerror = function(){
        throw Error('load script:'+url+" failed!"); 
    }
    node.src=url;
    //插入到head中
    var head = document.getElementsByTagName("head")[0];
    head.appendChild(node);
}

2. RequireJS如何按順序載入模組

用過RequireJS都知道,它主要就是兩個函式require和define。其中define用於定義模組,require用於執行模組。

//by foio.github.io
//c.js
define(id,['a','b'],function(a,b){
    return function(){
        a.doSomething();    
        b.doSomething();    
        //doSomething();
    }    
});

//logic.js
require(id,['c'],function(c){
    return function(){
        c.doSomething();
        //doSomething();
    }    
});

要保證javascript模組的執行順序,首先必須組織好依賴關係。

(1)組織依賴關係

為了組織RquireJS需要哪些資料結構呢?看起來無從下手,我們可以對問題進行拆分。

<1>.模組放在哪裡,如何標記模組的載入狀態?

moudules儲存了所有已經開始載入的模組,包括載入狀態資訊、依賴模組資訊、模組的回撥函式、以及回撥函式callback返回的結果。

//by foio.github.io
modules = {
    ...
    id:{
        state: 1,//模組的載入狀態    
        deps:[],//模組的依賴關係
        factory: callback,//模組的回撥函式
        exportds: {},//本模組回撥函式callback的返回結果,供依賴於該模組的其他模組使用
    }    
    ...    
}

<2>.正在載入但是還沒有載入完成的模組id列表

每個指令碼載入完成事件onload觸發時,都需要檢查loading佇列,確認哪些模組的依賴已經載入完成,是否可以執行

//by foio.github.io
loadings = [
    ...
    id,
    ...
]

(2). define函式的基本實現

再次強調,本文的目的是理解結構,而不是具體實現,因此程式碼中不會考慮瀏覽器相容性,也不會考慮邏輯的完整性。define函式主要目的是將模組註冊到factorys列表中,方便require可以找到。同時必須處理迴圈依賴問題。

//by foio.github.io
foioRequireJS.define = function(deps, callback){
    //根據模組名獲取模組的url
    var id = foioRequireJS.getCurrentJs();
    //將依賴中的name轉換為id,id其實是模組javascript檔案的全路徑
    var depsId = []; 
    deps.map(function(name){
        depsId.push(foioRequireJS.getScriptId(name));
    });
    //如果模組沒有註冊,就將模組加入modules列表
    if(!modules[id]){
            modules[id] = {
                id: id, 
                state: 1,//模組的載入狀態   
                deps:depsId,//模組的依賴關係
                callback: callback,//模組的回撥函式
                exports: null,//本模組回撥函式callback的返回結果,供依賴於該模組的其他模組使用
                color: 0,
            };
    }
};

(3). require函式的基本實現

require函式的實現是相當複雜的,我們先確立程式的基本框架,再逐步深入到具體細節。 其實require函式主要的邏輯就是將main模組放入modules和loadings佇列。然後就開始呼叫loadDepsModule載入main模組的依賴模組。下面我們來看loadDepsModule的實現。

//by foio.github.io
foioRequireJS.require = function(deps,callback){
    //獲取主模組的id
    id = foioRequireJS.getCurrentJs();

    //將主模組main註冊到modules中
    if(!modules[id]){

        //將主模組main依賴中的name轉換為id,id其實是模組的對應javascript檔案的全路徑
        var depsId = []; 
        deps.map(function(name){
            depsId.push(foioRequireJS.getScriptId(name));
        });

        //將主模組main註冊到modules列表中
        modules[id]  = {
            id: id, 
            state: 1,//模組的載入狀態   
            deps:depsId,//模組的依賴關係
            callback: callback,//模組的回撥函式
            exports: null,//本模組回撥函式callback的返回結果,供依賴於該模組的其他模組使用
            color:0,
        };
        //這裡為main入口函式,需要將它的id也加入loadings列表,以便觸發回撥
        loadings.unshift(id);                       
    }
    //載入依賴模組
    foioRequireJS.loadDepsModule(id);
}

可以說loadDepsModule是模組載入系統中最重要的函式了。 loadDepsModule函式主要是遞迴的載入一個模組的依賴模組,通過loadJS在dom結構中插入script元素來完成js檔案的載入和執行。這裡loadJS的callback函式也很值得研究。 每一個模組都是通過define函式定義的,由於callback函式在模組載入完成後才會執行,所以callback函式執行時模組已經存在於modules中了。相應的,我們也要將該模組放入loadings佇列以便檢查執行情況;同時遞迴的呼叫 loadDepsModule載入該模組的依賴模組。loadJS的在瀏覽器的onload事件觸發時執行,這是整個模組載入系統的驅動力。

//by foio.github.io
foioRequireJS.loadDepsModule = function(id){
    //依次處理本模組的依賴關係
    modules[id].deps.map(function(el){
        //如果模組還沒開始載入,則載入模組所在的js檔案
        if(!modules[el]){
            foioRequireJS.loadJS(el,function(){
                //模組開始載入時,放入載入佇列,以便檢測載入情況
                loadings.unshift(el);                       
                //遞迴的呼叫loadModule函式載入依賴模組
                foioRequireJS.loadDepsModule(el);
                //載入完成後執行依賴檢查,如果依賴全部載入完成就執行callback函式
                foioRequireJS.checkDeps();  
            });
        }
    });
}   

下面我們再來分析一下checkDeps函式,該函式在每次onload事件觸發時執行,檢查模組列表中是否已經有滿足執行條件的模組,然後開始執行。checkDeps也有一個小技巧,就是當存在滿足執行條件的模組時會觸發一次遞迴,因為該模組執行完成後,可能使得依賴於該模組的其他模組也滿足了執行條件。

//檢測模組的依賴關係是否處理完畢,該函式在每一次js的onload事件都會觸發一次
foioRequireJS.checkDeps = function(){
    //遍歷載入列表
    for(var i = loadings.length, id; id = loadings[--i];){
        var obj = modules[id], deps = obj.deps, allloaded = true;                                   
        //遍歷每一個模組的載入
        foioRequireJS.checkCycle(deps,id,colorbase++);
        for(var key in deps){
            //如果存在未載入完的模組,則退出內層迴圈
            if(!modules[deps[key]] || modules[deps[key]].state !== 2){
                allloaded = false;
                break;
            }
        }

        //如果所有模組已經載入完成
        if(allloaded){
            loadings.splice(i,1); //從loadings列表中移除已經載入完成的模組                          
            //執行模組的callback函式
            foioRequireJS.fireFactory(obj.id, obj.deps, obj.callback);
            //該模組執行完成後可能使其他模組也滿足執行條件了,繼續檢查,直到沒有模組滿足allloaded條件
            foioRequireJS.checkDeps();
        }
    }       
} 

最後我們分析一下,具體的執行函式fireFactory。我們知道,無論是require函式還是define函式,都有一個引數列表,fireFactory首先處理的問題就是收集各個依賴模組的返回值,構建callback函式的引數列表;然後呼叫callback函式,同時記錄模組的返回值,以便其他依賴於該模組的模組作為引數使用。

//fireFactory的工作是從各個依賴模組收集返回值,然後呼叫該模組的後調函式
foioRequireJS.fireFactory = function(id,deps,callback){
    var params = [];
    //遍歷id模組的依賴,為calllback準備引數
    for (var i = 0, d; d = deps[i++];) {
        params.push(modules[d].exports);
    };
    //在context物件上呼叫callback方法
    var ret = callback.apply(global,params);    
    //記錄模組的返回結果,本模組的返回結果可能作為依賴該模組的其他模組的回撥函式的引數
    if(ret != void 0){
        modules[id].exports = ret;
    }
    modules[id].state = 2; //標誌模組已經載入並執行完成
    return ret;
}

這些內容是我用將近一週的業餘時間的研究心得,希望對你有幫助。當然,這些程式碼都只是javascript載入系統的基本框架,如果有考慮疏忽的地方,還請你指正。文中的程式碼只是片段,完整的程式碼在我的github:https://github.com/foio/MyRequireJS

本文同時發表在我的部落格積木村の研究所http://foio.github.io/requireJS/

相關文章