JavaScript 模組化及 SeaJs 原始碼分析

joytoy發表於2021-09-09

網頁的結構越來越複雜,簡直可以看做一個簡單APP,如果還像以前那樣把所有的程式碼都放到一個檔案裡面會有一些問題:

  • 全域性變數互相影響
  • JavaScript檔案變大,影響載入速度
  • 結構混亂、很難維護

和後端(比如Java)比較就可以看出明顯的差距。2009年Ryan Dahl建立了node.js專案,將JavaScript用於伺服器程式設計,這標誌“JS模組化程式設計”正式誕生。

基本原理

模組就是一些功能的集合,那麼可以將一個大檔案分割成一些小檔案,在各個檔案中定義不同的功能,然後在HTML中引入:

var module1 = new Object({
    _count : 0,
    m1 : function (){
        //...
    },
    m2 : function (){
        //...
    }
});

這樣做的壞處是:把模組中所有的成員都暴露了!我們知道函式的本地變數是沒法從外面進行訪問的,那麼可以用立即執行函式來優化:

var module1 = (function(){
    var _count = 0;
    var m1 = function(){
        //...
    };
    var m2 = function(){
        //...
    };
    return {
        m1 : m1, m2 : m2
    };
})();

大家定義模組的方式可能五花八門,如果都能按照一定的規範來,那好處會非常大:可以互相引用!

模組規範

在node.js中定義math.js模組如下:

function add(a, b){
    return a + b;
}
exports.add = add;

在其他模組中使用的時候使用全域性require函式載入即可:

var math = require('math');
math.add(2,3);

在伺服器上同步require是沒有問題的,但是瀏覽器在網路環境就不能這麼玩了,於是有了非同步的AMD規範

require(['math'], function (math) {// require([module], callback);
    math.add(2, 3);
});

模組的定義方式如下(模組可以依賴其他的模組):

define(function (){ // define([module], callback);
    var add = function (x,y){
        return x+y;
    };
    return { add: add };
});

用RequireJS可以載入很多其他資源(看這裡),很好很強大!在工作中用的比較多的是SeaJS,所使用的規範稱為CMD,推崇(應該是指非同步模式):

as lazy as possible!

對於依賴的模組的處理方式和AMD的區別在於:

AMD是提前執行(依賴前置),CMD是延遲執行(依賴就近)。

在CMD中定義模組的方式如下:

define(function(require, exports, module) {
    var a = require('./a');
    a.doSomething();
    var b = require('./b');
    b.doSomething();
});

使用方式直接看文件,這裡就不贅述了!

SeaJS原始碼分析

剛接觸模組化的時候感覺這個太簡單了,不就是:

建立script標籤的時候設定一下onload和src!

事實上是這樣的,但也不完全是!下面來開始看SeaJS的程式碼(sea-debug.js)。一個模組在載入的過程中可能經歷下面幾種狀態:

var STATUS = Module.STATUS = {
    // 1 - The `module.uri` is being fetched
    FETCHING: 1,
    // 2 - The meta data has been saved to cachedMods
    SAVED: 2,
    // 3 - The `module.dependencies` are being loaded
    LOADING: 3,
    // 4 - The module are ready to execute
    LOADED: 4,
    // 5 - The module is being executed
    EXECUTING: 5,
    // 6 - The `module.exports` is available
    EXECUTED: 6,
    // 7 - 404
    ERROR: 7
}

記憶體中用Modul物件來維護模組的資訊:

function Module(uri, deps) {
    this.uri = uri
    this.dependencies = deps || [] // 依賴模組ID列表
    this.deps = {} // 依賴模組Module物件列表
    this.status = 0 // 狀態
    this._entry = [] // 在模組載入完成之後需要呼叫callback的模組
}

在頁面上啟動模組系統需要使用seajs.use方法:

seajs.use(‘./main’, function(main) {// 依賴及回撥方法
    main.init();
});

載入過程的整體邏輯可以在Module.prototype.load中看到:

Module.prototype.load = function() {
    var mod = this
    if (mod.status >= STATUS.LOADING) {
        return
    }
    mod.status = STATUS.LOADING
    var uris = mod.resolve() // 解析依賴模組的URL地址
    emit("load", uris)
    for (var i = 0, len = uris.length; i < len; i++) {
        mod.deps[mod.dependencies[i]] = Module.get(uris[i])// 從快取取或建立
    }
    mod.pass(); // 將entry傳遞給依賴的但還沒載入的模組
    if (mod._entry.length) {// 本模組載入完成
        mod.onload()
        return
    }
    var requestCache = {};
    var m;
    // 載入依賴的模組
    for (i = 0; i < len; i++) {
        m = cachedMods[uris[i]]
        if (m.status < STATUS.FETCHING) {
            m.fetch(requestCache)
        } else if (m.status === STATUS.SAVED) {
            m.load()
        }
    }
    for (var requestUri in requestCache) {
        if (requestCache.hasOwnProperty(requestUri)) {
            requestCache[requestUri]()
        }
    }
}

總體上邏輯很順就不講了,唯一比較繞的就是_entry陣列了。網上沒有找到比較通俗易懂的文章,於是看著程式碼連蒙帶猜地大概看懂了,其實只要記住它的目標即可:

當依賴的所有模組載入完成後執行回撥函式!

換種說法:

陣列_entry中儲存了當前模組載入完成之後、哪些模組的依賴可能載入完成的列表(依賴的反向關係)!

舉個例子,模組A依賴於模組B、C、D,那麼經過pass之後的狀態如下:

此時A中的remain為3,也就是說它還有三個依賴的模組沒有載入完成!而如果模組B依賴模組E、F,那麼在它load的時候會將A也傳遞出去:

有幾個細節:

  1. 已經載入完成的模組不會被傳播;
  2. 已經傳播過一次的模組不會再次傳播;
  3. 如果依賴的模組正在載入那麼會遞迴傳播;

維護好依賴關係之後就可以通過Module.prototype.fetch來載入模組,有兩種sendRequest的實現方式:

  1. importScripts
  2. script

然後根據結果執行load或者error方法。依賴的所有模組都載入完成後就會執行onload方法:

Module.prototype.onload = function() {
    var mod = this
    mod.status = STATUS.LOADED
    for (var i = 0, len = (mod._entry || []).length; i < len; i++) {
        var entry = mod._entry[i]
        if (--entry.remain === 0) {
            entry.callback()
        }
    }
    delete mod._entry
}

其中--entry.remain就相當於告訴entry對應的模組:你的依賴列表裡面已經有一個完成了!而entry.remain === 0則說明它所依賴的所有的模組都已經載入完成了!那麼此時將執行回撥函式:

for (var i = 0, len = uris.length; i < len; i++) {
    exports[i] = cachedMods[uris[i]].exec();
}
if (callback) {
    callback.apply(global, exports)// 執行回撥函式
}

指令碼下載完成之後會馬上執行define方法來維護模組的資訊:

沒有顯式地指定dependencies時會用parseDependencies來用正則匹配方法中的require()片段(指定依賴列表是個好習慣)。

接著執行factory方法來生成模組的資料:

var exports = isFunction(factory) ?
    factory.call(mod.exports = {}, require, mod.exports, mod) :
    factory

然後執行你在seajs.use中定義的callback方法:

if (callback) {
    callback.apply(global, exports)
}

當你寫的模組程式碼中require時,每次都會執行factory方法:

function require(id) {
    var m = mod.deps[id] || Module.get(require.resolve(id))
    if (m.status == STATUS.ERROR) {
        throw new Error('module was broken: ' + m.uri)
    }
    return m.exec()
}

到這裡核心的邏輯基本上講完了,補一張狀態的轉換圖:

以後在用的時候就可以解釋一些詭異的問題了!

總結

模組化非常好用,因此在ECMAScript 6中也開始支援,但是瀏覽器支援還是比較堪憂的~~

相關文章