JS模塊載入器載入原理是怎麼樣的?

例如Require、Seajs模塊器載入的原理是怎麼樣的?模塊載入都有什麼方式?不同方式各有什麼利弊?


既然都打廣告,我也來補一個:zengjialuo/kittyjs · GitHub

-----------

我又來貢獻乾貨了。

原理一:id即路徑 原則。

通常我們的入口是這樣的: require( [ "a", "b" ], callback ) 。這裡的 "a"、"b" 都是 ModuleId。通過 id 和路徑的對應原則,載入器才能知道需要載入的 js 的路徑。在這個例子里,就是 baseUrl + "a.js" 和 baseUrl + "b.js"。

但 id 和 path 的對應關係並不是永遠那麼簡單,比如在 AMD 規範里就可以通過配置 Paths 來給特定的 id 指配 path。

原理二:createElement("script") appendChild

知道路徑之後,就需要去請求。一般是通過 createElement("script") appendChild 去請求。這個大家都知道,不多說。有時候有的載入器也會通過 AJAX 去請求腳本內容。

一般來說,需要給 & 設置一個屬性用來標識模塊 id, 作用後面會提到。

原理三:document.currentScript

a.js 里可能是 define( id, factory ) 或者是 define( factory ),後者被稱為匿名模塊。那麼當 define(factory) 被執行的時候,我們怎麼知道當前被定義的是哪個模塊呢,具體地說,這個匿名模塊的實際模塊 id 是什麼? 答案是通過 document.currentScript 獲取當前執行的&,然後通過上面給 script 設置的屬性來得到模塊 id。

需要注意的是,低級瀏覽器是不支持 currentScript 的,這裡需要進行瀏覽器兼容。在高級瀏覽器裡面,還可以通過 script.onload 來處理這個事情。

原理四:依賴分析

在繼續講之前,需要先簡單介紹下模塊的生命周期。模塊在被 Define 之後並不是馬上可以用了,在你執行它的 factory 方法來生產出最終的 export 之前,你需要保證它的依賴是可用的。那麼首先就要先把依賴分析出來。

簡單來說,就是通過 toString 這個方法得到 factory 的內容,然後用正則去匹配其中的 require( "moduleId" )。當然也可以不用正則。

這就是為什麼 require( var ); 這種帶變數的語句是不被推薦的,因為它會影響依賴分析。如果一定要用變數,可以用 require( [ var ] ) 這種非同步載入的方式。

原理五:遞歸載入

在分析出模塊的依賴之後,我們需要遞歸去載入依賴模塊。用偽代碼來表達大概是這樣的:

Module.prototype.load = function () {
var deps = this.getDeps();
for (var i = 0; i &< deps.length; i++) { var m = deps[i]; if (m.state &< STATUS.LOADED) { m.load(); } } this.state = STATUS.LOADED; }

上面的代碼只是表達一個意思,實際上 load 方法很可能是非同步的,所以遞歸的返回要特殊處理下。

實現一個可用的載入器並沒有那麼簡單,比如你要處理循環依賴,還有各種各樣的牽一髮動全身的細節。但要說原理,大概就是這麼幾條。個人覺得,比起照著規範實現一個載入器,更加吸引人的是 AMD 或者 CommonJS 這些規範的完善和背後的設計思路。


cmd 對於 nodejs 的使用者來說更加友好,使得類似 commonJS 模塊的寫法可以在瀏覽器中使用,同時解決了瀏覽器中模塊非同步載入的困擾。

關於 cmd 更詳細的內容可以移步specification/module.md at master · cmdjs/specification · GitHub

我們可以通過實現一個簡單的 cmd loader,來學習其中的原理和細節。

模塊載入流程

下圖展示了一個 cmd loader 的模塊載入大體流程:

1. 首先,通過 use 方法來載入入口模塊,並接收一個回調函數, 當模塊載入完成, 會調用回調函數,並傳入對應的模塊。use 方法會 check 模塊有沒有緩存,如果有,則從緩存中獲取模塊,如果沒有,則創建並載入模塊。

2. 獲取到模塊後,模塊可能還沒有 load 完成,所以需要在模塊上綁定一個 "complete" 事件,模塊載入完成會觸發這個事件,這時候才調用回調函數。

3. 創建一個模塊時,id就是模塊的地址,通過創建 script 標籤的方式非同步載入模塊的代碼(factory),factory 載入完成後,會 check factory 中有沒有 require 別的子模塊:

- 如果有,繼續載入其子模塊,並在子模塊上綁定 "complete" 事件,來觸發本身 的 "complete" 事件;

- 如果沒有則直接觸發本身的 "complete" 事件。

4. 如果子模塊中還有依賴,則會遞歸這個過程。

5. 通過事件由里到外的傳遞,當所有依賴的模塊都 complete 的時候,最外層的入口模塊才會觸發 "complete" 事件,use 方法中的回調函數才會被調用。

功能劃分

理解了整個過程,那麼我們就來開始實現我們的代碼,我們暫且給這個載入器命名為 mcmd 吧。首先是載入器的功能模塊劃分:

- mcmd:入口文件,用於定義默認配置,參數,常量等,同時使用或載入其他的功能模塊;

- define:實現 cmd 中的 "define" 方法;

- require:實現 cmd 中的 "require" 方法;

- use:實現 cmd 中的 "use" 方法;

- module:模塊類,實現模塊的創建、載入、事件等功能;

- load:用於獲取模塊,把模塊從新建和從 cache 中獲取封裝成統一的介面;

- promise:非同步任務處理器;

- util:工具類函數;

構建

我們使用 commonJS 的方式進行編碼,並使用 browserify 配合 gulp 來構建我們的項目。

var gulp = require("gulp");
var uglify = require("gulp-uglify");
var concat = require("gulp-concat");
var browserify = require("browserify");
var source = require("vinyl-source-stream");
var buffer = require("vinyl-buffer")

var pg = require("./package");
var versionName = pg.name + "." + pg.version

gulp.task("default", ["build"]);

gulp.task("build", function () {
browserify("./src/mcmd.js")
.bundle()
.pipe(source(versionName))
.pipe(buffer())
.pipe(concat(versionName + ".js"))
.pipe(gulp.dest("./prd"))
.pipe(uglify())
.pipe(concat(versionName + ".min.js"))
.pipe(gulp.dest("./prd"));
});

確定好了功能劃分和構建方式,下面我們就來實現每一個功能模塊:

入口文件 mcmd.js

將我們的 cmd loader 掛在 window.mcmd 上,把 define 方法也掛在 window.define 上,初始化其他的方法和配置。

var g = window;
g.define = require("./define");
g.mcmd = {
use: require("./use"),
require: require("./require"),

// 模塊緩存
modules: {},

// 默認配置
config: {
root: "/"
},

// 修改配置
setConfig: function (obj) {
for (var key in obj) {
this.config[key] = obj[key];
}
},

// 模塊狀態常量
MODULE_STATUS: {
PENDDING: 0,
LOADING: 1,
COMPLETED: 2,
ERROR: 3
}
};

use.js

實現了 mcmd.use 方法,接收兩個參數,第一個是id或者id數組,第二個是回調函數。內部會使用 load.js 來獲取模塊,並通過 promise 來處理獲取多個模塊的並發非同步場景。

var Promise = require("./promise");
var load = require("./load");

module.exports = function use(ids, callback) {

if (!Array.isArray(ids)) {
ids = [ids]
}

Promise.all(ids.map(function (id) {
return load(mcmd.config.root + id);
})).then(function (list) {
if (typeof callback === "function") {
callback.apply(window, list);
}
}, function (errorInfo) {
throw errorInfo;
});
}

load.js

獲取一個模塊,並綁定事件,接收兩個參數,一個是模塊id,一個是回調函數,並返回一個 promise 對象。當模塊 complete(載入完成)時,執行回調,同時 resolve 返回的 promise 對象。

var Promise = require("./promise");
var Module = require("./module");
var util = require("./util");

module.exports = function (id, callback) {
return new Promise(function (resolve, reject) {
var mod = mcmd.modules[id] || Module.create(id);
mod.on("complete", function () {
var exp = util.getModuleExports(mod);
if (typeof callback === "function") {
callback(exp);
}
resolve(exp);
});
mod.on("error", reject);
});
}

promise.js

詳見: 如何實現一個ECMAScript 6 的promise補丁 |

安·記

module.js

模塊的構造函數,實現了模塊的創建,載入,事件傳遞,狀態維護等。

// 構造函數
function Module(id) {
mcmd.modules[id] = this; // 緩存模塊
this.id = id;
this.status = mcmd.MODULE_STATUS.PENDDING; // 狀態
this.factory = null; // 執行代碼
this.dependences = null; //依賴
this.callbacks = {}; // 綁定的事件回調函數
this.load();
}

// 靜態方法創建模塊
Module.create = function (id) {
return new Module(id);
}

// 通過創建 script 標籤非同步載入模塊
Module.prototype.load = function () {
var id = this.id;
var script = document.createElement("script");
script.src = id;
script.onerror = function (event) {
this.setStatus(mcmd.MODULE_STATUS.ERROR, {
id: id,
error: (this.error = new Error("module can not load."))
});
}.bind(this);
document.head.appendChild(script);
this.setStatus(mcmd.MODULE_STATUS.LOADING);
}

// 事件綁定方法
Module.prototype.on = function (event, callback) {
(this.callbacks[event] || (this.callbacks[event] = [])).push(callback);
if (
(this.status === mcmd.MODULE_STATUS.LOADING event === "load") ||
(this.status === mcmd.MODULE_STATUS.COMPLETED event === "complete")
) {
callback(this);
}
if (this.status === mcmd.MODULE_STATUS.ERROR event === "error") {
callback(this, this.error);
}
}

// 事件觸發方法
Module.prototype.fire = function (event, arg) {
(this.callbacks[event] || []).forEach(function (callback) {
callback(arg || this);
}.bind(this));
}

// 設置狀態方法,並拋出相應的事件
Module.prototype.setStatus = function (status, info) {
if (this.status !== status) {
this.status = status;
switch (status) {
case mcmd.MODULE_STATUS.LOADING:
this.fire("load");
break;
case mcmd.MODULE_STATUS.COMPLETED:
this.fire("complete");
break;
case mcmd.MODULE_STATUS.ERROR:
this.fire("error", info);
break;
default:
break;
}
}
}

module.exports = Module;

define.js

實現 window.define 方法。接收一個參數 factory(cmd規範中不止一個,為了保持簡單,我們只實現一個),即模塊的代碼包裹函數。通過 getCurrentScript 這個函數獲取到當前執行腳本的 script 節點 src ,提取出模塊 id ,找到模塊對象。然後提取出 factory 中的依賴子模塊,如果沒有依賴,則直接觸發模塊的 "complete" 事件, 如果有依賴,則創建依賴的模塊,綁定事件並載入,等依賴的模塊載入完成後,再觸發 "complete" 事件。

var util = require("./util");
var Promise = require("./promise");
var Module = require("./module");

module.exports = function (factory) {
var id = getCurrentScript().replace(location.origin, "");
var mod = mcmd.modules[id];
var dependences = mod.dependences = getDenpendence(factory.toString());
mod.factory = factory;
if (dependences) {
Promise.all(dependences.map(function (id) {
return new Promise(function (resolve, reject) {
id = mcmd.config.root + id;
var depMode = mcmd.modules[id] || Module.create(id);
depMode.on("complete", resolve);
depMode.on("error", reject);
});
})).then(function () {
mod.setStatus(mcmd.MODULE_STATUS.COMPLETED);
}, function (error) {
mod.setStatus(mcmd.MODULE_STATUS.ERROR, error);
});
}
else {
mod.setStatus(mcmd.MODULE_STATUS.COMPLETED);
}
}

// 獲取當前執行的script節點
// 參考 getCurrentScript的改進
function getCurrentScript() {
var doc = document;
if(doc.currentScript) {
return doc.currentScript.src;
}
var stack;
try {
a.b.c();
} catch(e) {
stack = e.stack;
if(!stack window.opera){
stack = (String(e).match(/of linked script S+/g) || []).join(" ");
}
}
if(stack) {
stack = stack.split( /[@ ]/g).pop();
stack = stack[0] == "(" ? stack.slice(1,-1) : stack;
return stack.replace(/(:d+)?:d+$/i, "");
}
var nodes = head.getElementsByTagName("script");
for(var i = 0, node; node = nodes[i++];) {
if(node.readyState === "interactive") {
return node.className = node.src;
}
}
}

// 解析依賴,這裡只做簡單的提取,實際需要考慮更多情況,參考seajs
function getDenpendence(factory) {
var list = factory.match(/require(.+?)/g);
if (list) {
list = list.map(function (dep) {
return dep.replace(/(^require([""])|([""])$)/g, "");
});
}
return list;
}

require.js

返回模塊的 exports 屬性, 這裡通過封裝的 util.getModuleExports 方法獲取並返回。

var util = require("./util");

module.exports = function (id) {
id = mcmd.config.root + id;
var mod = mcmd.modules[id];
if (mod) {
return util.getModuleExports(mod);
}
else {
throw "can not get module by from:" + id;
}
}

module.exports.async = function (ids, callback) {
mcmd.use(ids, callback);
}

util.js

這裡只有一個 getModuleExports 方法, 接收一個模塊,返回模塊的介面。當模塊的 exports 屬性不存在時,說明模塊的 factory 沒有被執行過。這時我們需要執行下 factory,傳入 require, 創建的exports,以及 module 本身作為參數。最後獲取模塊的暴露的數據並返回。

module.exports = {
getModuleExports: function (mod) {
if (!mod.exports) {
mod.exports = {};
mod.factory(mcmd.require, mod.exports, mod);
}
return mod.exports;
}
};

這樣,整個 cmd loader 就基本完成了。這只是一個非常基礎的模塊載入器,主要是為了理解 cmd 的原理和實現方式,對於生產環境,推薦使用成熟的 seajs。

整個 mcmd 項目我都放在了 github 上,大家可以去看:https://github.com/hanan198501/mcmd。

原文地址:如何實現一個 CMD 模塊載入器 |

安·記


剛起床,醒醒腦,分別簡約的回答下三個問題:

Require、Seajs模塊器載入的原理是怎麼樣的?

1. 定義一套依賴規則, 如AMD CMD CommonJS Modules規範,規範即規則

2. 載入入口文件及其依賴,原理即按依賴關係遞歸執行 document.createElement("script")

3. 維護模塊從初始到銷毀的生命周期

模塊載入都有什麼方式?

1. 手動模式 - 人肉管理

2. 自動模式- 模塊載入器管理

3. 混合模式 - 1,2結合

不同方式各有什麼利弊?

1. 首先千萬別拿著鎚子看啥都是釘子,依場景使用

2. 基於約定的模塊依賴管理相比人肉是更好實踐,但謹記1

3. 使用了模塊管理器後,必然會引入複雜度,這是複雜度的轉移,如何駕馭這些也是成為優秀工程師的修鍊之路

4. 使用模塊載入器後最頭疼的是構建發布問題,使用seajs的童鞋應該深有體會吧,(廣告時間)推薦 ModJS自動零配置構建seajs項目:mod/example/buildseajs at master · modjs/mod · GitHub

零配置構建requrejs項目:mod/example/buildrequirejs at master · modjs/mod · GitHub


就沒一個人貼點簡單易懂的代碼的嗎?我來好了。

function require(path) {
if (require.cache[path]) {
return require.cache[path].exports
}
var src = fs.readFileSync(path)
var code = new Function("exports, module", src)
var module = {exports:{}}
code(module.exports, module)
require.cache[path] = module
return module.exports
}
require.cache = Object.create(null)

本以為回答會沒人看呢,沒想到還有人點贊,那就解釋一下上面的代碼吧:

想要在JS中實現模塊系統,當然是像其它語言里一樣把一個模塊放入一個文件了,這個文件最終當然是被當做代碼運行的,但如何知道這個文件為外界提供了什麼呢?

方法有很多,一種方法是,讓這個文件里的代碼在一個函數內運行,然後返回想要提供給外界的介面。

另一種方法是,讓這個文件去修改由外部提供給它的一個對象以藉由這個對象來給外界提供介面。

而幾大模塊系統(Node的CommonJS,requirejs的AMD,seajs的CMD)都選擇了第二種方法,即讓模塊代碼去修改一個已存在的對象,不過seajs也支持通過模塊的代碼返回一個對象來導出模塊。

上面代碼的意義很明顯,把模塊對應的文件讀出來,通過文件的內容動態構建一個函數,構建出來的這個函數是有兩個參數的(上面幾個模塊系統的實現實際上把require函數也做為參數傳進去了),一個名為exports,一個名為module,而且exports是module對象的一個屬性,它本身也是個空對象。

然後將exports與module做為參數調用剛剛通過模塊文件的內容構建出來的函數,在模塊內部,實際上就是在修改exports對象或者是module.exports對象(如果你用過任意一個模塊系統這應該是很熟悉的)這個函數運行完成後,module這個對象應該就已經被修改為想要的樣子了,把得到的內容緩存以便下次直接返回,同時本次也返回得到的module就完事了。

至於文件的內容是同步獲取還是非同步獲取,那就得看具體環境和需求了,不是模塊載入器的原理,requires跟seajs都在瀏覽器端運行,當然只能選擇非同步載入了,但seajs的用法看起來地像是同步的,實現方式是載入完後不運行而是分析它的依賴然後遞歸載入它的依賴,直到所有的依賴都載入完成。


先上廣告,lithe.js是我11年寫的一個東東,現在業務線都在用的一個cmd格式載入器,參考seajs和requirejs的某一版本的幾個地方,有經過半年的優化和重寫(加了點自己的料)。

litheModule/lithe · GitHub

然後,我來回答問題,最近在寫一個開源的類medium編輯器,組織形式打算用模塊化開發,簡單么,而且本地調試也方便,雖然lithe已經足夠的小,只有2.7,500多行,但是覺得依然很重。。

so,寫了個簡單的只支持一種調用方式的amd格式的loader,通過必須定義標準路徑和id的方法,繞過了幾個代碼量較多的實現。題主可以來看看,沒壓縮只有80行。我相信你能看得懂……

iWo/iwo.loader.js at master · liulyliu/iWo · GitHub

原理我用大白話告訴你,通過一個入口,載入所有依賴,每次完成一次onload,查看是否還有未完成和未開始的腳本,一直到最後一個載入完畢(載入過程是不阻塞的,當然也還是會分級)之後,按照依賴關係依次執行,結果保存到cache,下次再跑,就不執行了,確保factory只執行一次。

大概就是這樣。。ps:這代碼有點漏洞和缺陷或者叫特性?看題主能不能發現了……


建議先看普通載入器,

LABjs Script Loader :: Project Description,

再看文藝載入器,

yepnope.js | A Conditional Loader For Your Polyfills!

再看模塊載入器。


本質上模塊就是一個具有標識(uri)和對外介面的包機制,理解nodejs就理解了頁面的載入器。不同的是頁面有個load script的過程,偵聽onload回調。另外頁面還有combo邏輯,這些nodejs是沒有的。另外nodejs可以做到懶載入,頁面只能做到懶初始化,不過新的模塊規範更加突出強靜態化,懶載入應該不再會有。


先上廣告:

island205/HelloSea.js · GitHub,載入 CommonJS 到瀏覽器:island205/browserify-loader · GitHub。

有時間再來填坑。


模塊和載入是兩個部分

模塊是說你怎麼組織你的代碼 針對的是開發者

載入是說代碼怎麼跑到瀏覽器去 針對的是機器 是運行時

一開始這兩個部分是合在一起的 也就是所謂的seajs requirejs 優點是按需載入 具體沒實現過 不過就是類似於添加script標籤載入js文件 同時也要對依賴等問題進行管理

後來出現了一些工具如browserify gulp等打包的工具 真正實現了把這個過程分開 首先把代碼按照那些模塊系統(決定生態環境)打包生成為一個源碼包 然後直接通過瀏覽器原生支持的script link等直接引用就好 這方案的優勢在於可以在運行代碼前 把代碼優化 壓縮以及依賴等問題都給解決掉 更適合工程化

於是 兩種方案各有優劣 所以 我預測下一個解決方案是能把以上組合在一起使用的 那就是es2015 module


yanhaijing/lodjs · GitHub 前段時間寫了個AMD載入器,只有500行代碼,如果研究源碼建議看這個吧,requirejs太複雜了


優點就不用說啦吧

主要原理:其實可以簡單的想想成 非同步js載入 減輕網路請求 通過回調函數來進行分段載入 解析

缺點就是: 不容易找bug


主流的「前端」JS載入器原理都是往DOM里動態插入一個

&