1. 程式人生 > >模組化載入的實現

模組化載入的實現


原文連結:http://caibaojian.com/module-definition.html

淺談模組化載入的實現原理

A- A+ 前端部落格 2015-03-04 前端開發 AMD | CMD | JavaScript | RequireJS | SeaJS 1913View 0
文章目錄

相信很多人都用過 seajs、 requirejs 等這些模組載入器,他們都是十分便捷的工程管理工具,簡化了程式碼的結構,更重要的是消除了各種檔案依賴和命名衝突問題,並利用 AMD / CMD 規範統一了格式。如果你不太明白模組化的作用,建議看看玉伯寫的一篇文章

為什麼他們會想到使用模組化載入呢,我覺得主要是兩點。

  • 一是按需載入,業務越來越大,基礎程式碼也會越來越多,開發人員可能開發了一百個小工具,而且都塞在一個叫做 utils.JS 的包裡,但是一個頁面可能只需要三到五個小工具,如果直接去載入這個 utils.js 豈不是很大的浪費,PC 端還好,主要是無線端,省下 1KB 那都是很大的價值啊,所以呢,如今很多框架的開發都體現出細顆粒度的分化,像百度研究比較賣力的 tangram,阿里放滿產品線的 kissy,幾乎是細分到了微粒程度,這種細分方式也促進了模組化載入技術的發展,比如為了減少請求數量,kissy 的 config 中開啟 combo 就可以合併多個請求為一個等等。
  • 第二點,應該也是從伺服器那邊參考而來的,伺服器指令碼很多都是以檔案為單位分離的,如果要利用其它檔案的功能,可以輕而易舉的 require 或者 include 進來,我沒有去研究這些載入函式的內部實現原理,稍微猜猜應該是把檔案寫入到快取,遇到 include 之類的載入函式,暫停寫入,找到需要 include 的檔案地址,把找到的檔案接著上面繼續寫入快取,以此類推,直到結束,然後編譯器進行統一編譯。

一、模組化載入的技術原理

先不考慮各種模組定義規範,本文目的只是簡要的分析載入原理, CMD / AMD 規範雖內容然不多,但是要實現起來,工程量還是不小。文章後面會提到。

1. 資料模組的載入

既然是模組化載入,想辦法把模組內容拿到當然是重頭戲,無論是 script 還是 css 檔案的載入,一個 script 或者 link 標籤就可以搞定問題,不過我這裡採用的是 ajax,目的是為了拿到 script 的程式碼,也是為了照顧後面要說的 CMD 規範。

var require = function(path){
    var xhr = new XMLHttpRequest(), res;
    xhr.open("GET", path, true);
    xhr.onreadystatechange = function(){
        if(xhr.readyState == 4 && xhr.status == 200){
            // 獲取原始碼
            res = xhr.responseText;
        }
    }
    xhr.send();
};

建立 script 標籤載入指令碼不會存在跨域問題,不過拿到的指令碼會被瀏覽器立馬解析出來,如果要做同異步的處理就比較麻煩了。沒有跨域的檔案我們就通過上面的方式載入,如果指令碼跨域了,再去建立標籤,讓文件自己去載入。

// 跨域處理
if(crossDomain){
    var script = document.createElement("script");
    script.src = path;

    (document.getElementsByTagName("head")[0] || document.body).appendChild(script);
}

2. 解析模組的層次依賴關係

模組之間存在依賴關係是十分正常的,如一個工程的檔案結構如下:

project/
├── css/
│   └── main.css
├── js/
│   ├── require.js
│   └── modlues/
│     ├── a.js
│     ├── b.js
│     └── c.js
└── index.html

而這裡幾個模組的依賴關係是:

            ┌> a.js -> b.js
index.html -|
            └> c.js

// a.js
require("./js/test/b.js");

// b.js
console.log("i am b");

// c.js
console.log("i am c");

我們要從 index.html 中利用 require.js 獲取這一連串的依賴關係,一般採用的方式就是正則匹配。如下:先拿到 function 的程式碼,然後正則匹配出第一層的依賴關係,接著載入匹配到關係的程式碼,繼續匹配。

// index.html
<script type="text/javascript" src="./js/require.js"></script>
<script type="text/javascript">
    function test(){
        var a = require("./js/modlues/a.js");
        var c = require("./js/modlues/c.js");
    }

    // toString 方法可以拿到 test 函式的 code
    start(test.toString());
</script>

整個函式的入口是 start,正則表示式為:

//code from http://caibaojian.com/module-definition.html
var r = /require\((.*)\)/g;

var start = function(str){
    while(match = r.exec(str)) {
        console.log(match[1]);
    }
};

由此我們拿到了第一層的依賴關係,

["./js/modlues/a.js", "./js/modlues/c.js"]

接著要拿到 a.js 和 b.js 的檔案層次依賴,之前我們寫了一個 require 函式,這個函式可以拿到指令碼的程式碼內容,不過這個 require 函式要稍微修改下,遞迴去查詢和下載程式碼。

var cache = {};
var start = function(str){
    while(match = r.exec(str)) {
        console.log(match && match[1]);
        // 如果匹配到了內容,下載 path 對應的原始碼
        match && match[1] && require(match[1]);
    }
};

var require = function(path){
    var xhr = new XMLHttpRequest(), res;
    xhr.open("GET", path, true);
    xhr.onreadystatechange = function(){
        if(xhr.readyState == 4 && xhr.status == 200){
            res = xhr.responseText;
            // 快取檔案
            cache[path] = res;
            // 繼續遞迴匹配
            start(res);
        }
    }
    xhr.send();
};

上面的程式碼已經可以很好地拿到檔案遞迴關係了:

原文來自http://caibaojian.com/module-definition.html

3. 新增事件機制,優化管理程式碼

但是我們有必要先把 responseText 快取起來,如果不快取檔案,直接 eval 得到的 responseText 程式碼,想想會發生什麼問題~ 如果模組之間存在迴圈引用,如:

            ┌> a.js -> b.js
index.html -|
            └> b.js -> a.js

那 start 和 require 將會陷入死迴圈,不斷的載入程式碼。所以我們需要先拿到依賴關係,然後解構關係,分析出我們需要載入哪些模組。值得注意的是,我們必須按照載入的順序去 eval 程式碼,如果 a 依賴 b,先去執行 a 的話,一定會報錯!

有兩個問題我糾結了半天,上面的請求方式,何時會結束?用什麼方式去記錄檔案依賴關係?

最後還是決定將 start 和 require 兩個函式的相互遞迴修改成一個函式的遞迴。用一個物件,發起請求時把 URL 作為 key,在這個物件裡儲存 XHR 物件,XHR 物件請求完成後,把抓取到的新請求再用同樣的方式放入這個物件中,同時從這個物件中把自己刪除掉,然後判斷這個物件上是否存在 key, 如果存在說明還有 XHR 物件沒完成。

var r = /require\(\s*"(.*)"\s*\)/g;
var cache = {};    // 檔案快取
var relation = []; // 依賴過程控制
var obj = {};      // xhr 管理物件

//輔助函式,獲取鍵值陣列
Object.keys = Object.keys || function(obj){
    var a = [];
    for(a[a.length] in obj);
    return a ;
};

// 入口函式
function start(str){
    while(match = r.exec(str)){
        obj[match[1]] = new XMLHttpRequest();
        require(obj[match[1]], match[1]);
    }
}

// 遞迴請求
var require = function(xhr, path){
    //記錄依賴過程
    relation.push(path);

    xhr.open("GET", path, true);
    xhr.onreadystatechange = function(){
        if(xhr.readyState == 4 && xhr.status == 200){
            var res = xhr.responseText;
            // 快取檔案
            cache[path] = res;
            // 從xhr物件管理器中刪除已經載入完畢的函式
            delete obj[path];

            // 如果obj為空則觸發 allLoad 事件
            Object.keys(obj).length == 0 ? Event.trigger("allLoad") : void 0;
            //遞迴條件
            while(match = r.exec(res)){
                obj[match[1]] = new XMLHttpRequest();
                require(obj[match[1]], match[1]);
            }
        }
    }
    xhr.send();
};

上面的程式碼已經基本完成了檔案依賴分析,檔案的載入和快取工作了,我寫了一個demo,有興趣可以看一看。這個demo的檔案結構為:

project/
├── js/
│   ├── require.js
│   └── test/
│     ├── a.js
│     ├── b.js
│     ├── c.js
│     ├── d.js
│     └── e.js
└── index.html

//檔案依賴關係為
                       ┌> c.js
            ┌> a.js --|
index.html -|          └> d.js
            └> b.js -> e.js

戳我 → Demo

淺談模組化載入的實現原理

4. CMD 規範的介紹

上面寫了一大堆內容,也實現了模組載入器的原型,但是放在實際應用中,他就是個廢品,回到最開始,我們為什麼要使用模組化載入。目的是為了不去使用麻煩的名稱空間,把複雜的模組依賴交給 require 這個函式去管理,但實際上呢,上面拿到的所有模組都是暴露在全域性變數中的,也就是說,如果 a.js 和 b.js 中存在命名相同的變數,後者將會覆蓋前者,這是我們不願意看到的。為了處理此類問題,我們有必要把所有的模組都放到一個閉包中,這樣一來,只要不使用 window.vars 命名,閉包之間的變數是不會相互影響的。我們可以使用自己的方式去管理程式碼,不過有人已經研究處理一套標準,而且是全球統一,那就拿著用吧~

關於 CMD 規範,我這裡就不多說了,可以去看看草案,玉伯也翻譯了一份,戳我。每一模組有且僅有一個對外公開的介面 exports,如:

define(function(require, exports) {

  // 對外提供 foo 屬性
  exports.foo = 'bar';

  // 對外提供 doSomething 方法
  exports.doSomething = function() {};

});

剩下的工作就是針對 CMD 規範寫一套符合標準的程式碼介面,這個比較瑣碎,就不寫了。

二、額外的話題

上面的程式碼中提到了關於 Event 的事件管理。在模組全部加在完畢之後,需要有個東西告訴你,所以順手寫了一個 Event 的事件管理器。

// Event
var Event = {};
Event.events = [];
Event.on = function(evt, func){
    for(var i = 0; i < Event.events.length; i++){
        if(Event.events[i].evt == evt){
            Event.events[i].func.push(func);
            return;
        }
    }

    Event.events.push({
        evt: evt,
        func: [func]
    });
};
Event.trigger = function(evt){
    for(var i = 0; i < Event.events.length; i++){
        if(Event.events[i].evt == evt){
            for(var j = 0; j < Event.events[i].func.length; j++){
                Event.events[i].func[j]();
            }
            return;
        }
    }
};
Event.off = function(evt){
    for(var i = 0; i < Event.events.length; i++){
        Event.events.splice(i, 1);
    }       
};

我覺得 seajs 是一個很不錯的模組載入器,如果感興趣,可以去看看他的原始碼實現,程式碼不長,只有一千多行。模組的載入它採用的是建立文字節點,讓文件去載入模組,實時檢視狀態為 interactive 的 script 標籤,如果處於互動狀態就拿到他的程式碼,接著刪除節點。當節點數目為 0 的時候,載入工作完成。

本文沒有考慮 css 檔案的載入問題,我們可以把它當做一個沒有 require 關鍵詞的 js 檔案,或者把它匹配出來之後另作處理,因為他是不可能存在模組依賴關係的。

然後就是很多很多細節,本文的目的並不是寫一個類似 seajs 的模組管理工具,只是稍微說幾句自己對這玩意兒的看法,如果說的有錯,請多多吐槽!

三、參考資料

原文:http://www.cnblogs.com/hustskyking/p/how-to-achieve-loading-module.html