模組化的一些小研究

lhyt發表於2018-04-27

本文來自我的github

0.前言

我們知道最常見的模組化方案有CommonJS、AMD、CMD、ES6,AMD規範一般用於瀏覽器,非同步的,因為模組載入是非同步的,js解釋是同步的,所以有時候導致依賴還沒載入完畢,同步的程式碼執行結束;CommonJS規範一般用於服務端,同步的,因為在伺服器端所有檔案都儲存在本地的硬碟上,傳輸速率快而且穩定。

1.script標籤引入

最開始的時候,多個script標籤引入js檔案。但是,這種弊端也很明顯,很多個js檔案合併起來,也是相當於一個script,造成變數汙染。專案大了,不想變數汙染也是很難或者不容易做到,開發和維護成本高。 而且對於標籤的順序,也是需要考慮一陣,還有載入的時候同步,更加是一種災難,幸好後來有了渲染完執行的defer和下載完執行的async,進入新的時代了。

接著,就有各種各樣的動態建立script標籤的方法,最終發展到了上面的幾種方案。

2.AMD與CMD

2.1AMD

非同步模組定義,提供定義模組及非同步載入該模組依賴的機制。AMD遵循依賴前置,程式碼在一旦執行到需要依賴的地方,就馬上知道依賴是什麼。而無需遍歷整個函式體找到它的依賴,因此效能有所提升。但是開發者必須先前知道依賴具體有什麼,並且顯式指明依賴,使得開發工作量變大。而且,不能保證模組載入的時候的順序。 典型代表requirejs。require.js在宣告依賴的模組時會立刻載入並執行模組內的程式碼。require函式讓你能夠隨時去依賴一個模組,即取得模組的引用,從而即使模組沒有作為引數定義,也能夠被使用。他的風格是依賴注入,比如:

/api.js
define('myMoudle',['foo','bar'],function(foo,bar){
    //引入了foo和bar,利用foo、bar來做一些事情
    return {
        baz:function(){return 'api'}
               }
});
require(['api'],function(api) {
	console.log(api.baz())
})
複製程式碼

然後你可以在中間隨時引用模組,但是模組第一次初始化的時間比較長。這就像開始的時候很拼搏很辛苦,到最後是美滋滋。

2.2CMD

通用模組定義,提供模組定義及按需執行模組。遵循依賴就近,程式碼在執行時,最開始的時候是不知道依賴的,需要遍歷所有的require關鍵字,找出後面的依賴。一個常見的做法是將function toString後,用正則匹配出require關鍵字後面的依賴。CMD 裡,每個 API 都簡單純粹。可以讓瀏覽器的模組程式碼像node一樣,因為同步所以引入的順序是能控制的。 對於典型代表seajs,一般是這樣子:

define(function(require,exports,module){
    //...很多程式碼略過
     var a = require('./a');
    //要用到a,於是引入了a
    //做一些和模組a有關的事情

});
複製程式碼

對於b.js依賴a.js

//a.js
define(function(require, exports) {
    exports.a = function(){//也可以把他暴露出去
    // 很多程式碼	
    };
});

//b.js
define(function(require,exports){
      //前面幹了很多事情,突然想要引用a了
        var fun = require('./a');
    console.log(fun.a()); // 就可以呼叫到及執行a函式了。
   })

//或者可以use
seajs.use(['a.js'], function(a){
    //做一些事情
});
複製程式碼

AMD和CMD對比: AMD 推崇依賴前置、提前執行,CMD推崇依賴就近、延遲執行。

AMD需要先列出清單,後面使用的時候隨便使用(依賴前置),非同步,特別適合瀏覽器環境下使用(底層其實就是動態建立script標籤)。而且API 預設是一個當多個用。

CMD不需要知道依賴是什麼,到了改需要的時候才引入,而且是同步的,就像臨時抱佛腳一樣。

對於客戶端的瀏覽器,一說到下載、載入,肯定就是和非同步脫不了關係了,註定瀏覽器一般用AMD更好了。但是,CMD的api都是有區分的,區域性的require和全域性的require不一樣。

3.CommonJS與ES6

3.1 ES6

ES6模組的script標籤有點不同,需要加上type='module'

<script src='./a.js' type='module'>...</script>
複製程式碼

對於這種標籤都是非同步載入,而且是相當於帶上defer屬性的script標籤,不會阻塞頁面,渲染完執行。但是你也可以手動加上defer或者async,實現期望的效果。 ES6模組的檔案字尾是mjs,通過import引入和export匯出。我們一般是這樣子:

//a.mjs
import b from 'b.js'
//b.mjs
export default b
複製程式碼

ES6畢竟是ES6,模組內自帶嚴格模式,而且只在自身作用域內執行。在ES6模組內引入其他模組就要用import引入,暴露也要用export暴露。另外,一個模組只會被執行一次。 import是ES6新語法,可靜態分析,提前編譯。他最終會被js引擎編譯,也就是可以實現編譯後就引入了模組,所以ES6模組載入是靜態化的,可以在編譯的時候確定模組的依賴關係以及輸入輸出的變數。ES6可以做到編譯前分析,而CMD和AMD都只能在執行時確定具體依賴是什麼。

3.2CommonJS

一般服務端的檔案都在本地的硬碟上面。對於客戶,他們用的瀏覽器是要從這裡下載檔案的,在服務端一般讀取檔案非常快,所以同步是不會有太大的問題。require的時候,馬上將require的檔案程式碼執行

代表就是nodejs了。用得最多的,大概就是:

//app.js
var route = require('./route.js')//讀取控制路由的js檔案

//route.js
var route  = {......}
module.exports = route
複製程式碼

require 第一次載入指令碼就會馬上執行指令碼,生成一個物件

區別: CommonJS執行時載入,輸出的是值的拷貝,是一個物件(都是由module.export暴露出去的),可以直接拿去用了,不用再回頭找。所以,當module.export的原始檔裡面一些原始型別值發生變化,require這邊不會隨著這個變化而變化的,因為被快取了。但是有一種常規的操作,寫一個返回那個值的函式。就像angular裡面$watch陣列裡面的每一個物件,舊值是直接寫死,新值是寫一個返回新值的函式,這樣子就不會寫死。module.export輸出一個取值的函式,呼叫的時候就可以拿到變化的值。

ES6是編譯時輸出介面,輸出的是值的引用,對外的介面只是一種靜態的概念,在靜態解釋後已經形成。當指令碼執行時,根據這個引用去原本的模組內取值。所以不存在快取的情況,import的檔案變了,誰發出import的也是拿到這個變的值。模組裡面的變數繫結著他所在的模組。另外,通過import引入的這個變數是隻讀的,試圖進行對他賦值將會報錯。

4.迴圈依賴

就是a依賴b,b依賴a,對於不同的規範也有不同的結果。

4.1CommonJS

對於node,每一個模組的exports={done:false}表示一個模組有沒有載入完畢,經過一系列的載入最後全部都會變為true。 同步,從上到下,只輸出已經執行的那部分程式碼 首先,我們寫兩個js用node跑一下:

//a.js
console.log('a.js')
var b = require('./b.js')
console.log(1)
//b.js
console.log('b.js')
var a = require('./a.js')
console.log(2)

//根據他的特點,require一個檔案的時候,馬上執行內部的程式碼,所以相當於
console.log('a.js')
console.log('b.js')
console.log(2)
console.log(1)
//輸出是a.js、b.js、2、1
複製程式碼

加上export的時候:

//a.js
module.exports = {val:1}
var b = require('./b.js')
console.log(b.val)
module.exports = {val:2}
b.val = 3
console.log(b)

//b.js
module.exports = {val:1}
var a = require('./a.js')
console.log(a.val)
module.exports = {val:2}
a.val = 3
console.log(a)

//1.在a.js暴露出去一個物件module.exports = {val:1}
//2.require了b,來到b,執行b指令碼
//3.b的第一行,把{val:1}暴露出去,引入剛剛a暴露的{val:1},列印a.val的結果肯定是1
//4.重新暴露一次,是{val:2},然後做了一件多餘的事情,改a.val為3(反正是拷貝過的了怎麼改都不會影響a.js),毫無疑問列印出{ val: 3 }
//5.回到a,繼續第三行,列印b.val,因為b暴露的值是2,列印2
//6.繼續再做一件無意義的事情,列印{ val: 3 }
複製程式碼

解決辦法:程式碼合理拆分

4.2ES6模組

ES6模組是輸出值的引用,是動態引用,等到要用的時候才用,因此可以完美實現相互依賴,在相互依賴的a.mjs和b.mjs,執行a的時候,當發現import馬上進入b並執行b的程式碼。當在b發現了a的時候,已經知道從a輸入了介面來到b的,不會回到a。但是在使用的過程中需要注意,變數的順序。

如果是單純的暴露一個基本資料型別,當然會報錯not defined。 因為函式宣告會變數提升,所以我們可以改成函式宣告(不能用函式表示式)

//a.mjs
import b from './b'
console.log(b())
function a(){return 'a'}
export default a
//b.mjs
import a from './a'
console.log(a())
function b(){return 'b'}
export default b
複製程式碼

4.3 require

我們一般使用的時候,都是依賴注入,如果是有迴圈依賴,那麼可以直接利用require解決

define('a',['b'],function(b){
    //dosomething
});
define('b',['a'],function(a){
    //dosomething
});
//為了解決迴圈依賴,在迴圈依賴發生的時候,引入require:
define('a',['b','require'],function(b,require){
    //dosomething
    require('b')
});
複製程式碼

4.4 sea

迴圈依賴,一般就是這樣

//a.js
define(function(require, exports, module){
    var b = require('./b.js');
    //......
});
//b.js
define(function(require, exports, module){
    var a = require('./a.js');
    //......
});
複製程式碼

而實際上,並沒有問題,因為sea自己解決了這個問題: 一個模組有幾種狀態:

'FETCHING': 模組正在下載中 'FETCHED': 模組已下載 'SAVED': 模組資訊已儲存 'READY': 模組的依賴項都已下載,等待編譯 'COMPILING':模組正在編譯中 'COMPILED': 模組已編譯

步驟:

  • 1.模組a下載並且下載完成FETCHED
  • 2.編譯a模組(執行回撥函式)
  • 3.遇到了依賴b,b和自身沒有迴圈依賴,a變成SAVED
  • 4.模組b下載並且下載完成FETCHED
  • 5.b遇到了依賴a,a是SAVED,和自身有迴圈依賴,b變成READY,編譯完成後變成COMPILED
  • 6.繼續回到a,執行剩下的程式碼,如果有其他依賴繼續重複上面步驟,如果所有的依賴都是READY,a變成READY
  • 7.繼續編譯,當a回撥函式部分所有的程式碼執行完畢,a變成COMPILED

對於所有的模組相互依賴的通用的辦法,將相互依賴的部分抽取出來,放在一箇中介軟體,利用釋出訂閱模式解決

5.webpack是如何處理模組化的

假設我們定義兩個js:app.js是主入口檔案,a.js、b.js是app依賴檔案,用的是COMMONJS規範 webpack首先會從入口模組app.js開始,根據引入方法require把所有的模組都讀取,然後寫在一個列表上:

var modules = {
  './b.js': generated_b,
  './a.js': generated_a,
  './app.js': generated_app
}
複製程式碼

'generated_'+name是一個IIFE,每個模組的原始碼都在裡面,不會暴露內部的變數。比如對於沒有依賴其他模組的a.js一般是這樣,沒有變化:

function generated_a(module, exports, webpack_require) {
   // ...a的全部程式碼
}
複製程式碼

對於app.js則不一樣了:

function generated_app(module, exports, webpack_require) {
  var a_imported_module = __webpack_require__('./a.js');
  var b_imported_module = __webpack_require__('./b.js');
  a_imported_module['inc']();
  b_imported_module['inc']();
}
複製程式碼

__webpack_require__就是require、exports、import這些的具體實現,夠動態地載入模組a、b,並且將結果返回給app

對於webpack_require,大概是這樣的流程

var installedModules = {};//儲存已經載入完成的模組
function webpack_require(moduleId) {
  if (installedModules[moduleId]) {//如果已經載入完成直接返回
    return installedModules[moduleId].exports;
  }
  var module = installedModules[moduleId] = {//如果是第一次載入,則記錄在表上
            i: moduleId,
            l: false,//沒有下載完成
            exports: {}
  };
//在模組清單上面讀取對應的路徑所對應的檔案,將模組函式的呼叫物件繫結為module.exports,並返回
  modules[moduleId].call(module.exports, module, module.exports,__webpack_require__);
  module.l = true;//下載完成
  return module.exports;
}
複製程式碼

對於webpack打包後的檔案,是一個龐大的IIFE,他的內容大概是這樣子:

(function(modules) { 
    var installedModules = {};
    function __webpack_require__(moduleId) { /*...*/}
    __webpack_require__.m = modules;//所有的檔案依賴列表
    __webpack_require__.c = installedModules;//已經下載完成的列表
    __webpack_require__.d = function(exports, name, getter) {//定義模組物件的getter函式
        if(!__webpack_require__.o(exports, name)) {
            Object.defineProperty(exports, name, {
                configurable: false,
                enumerable: true,
                get: getter
            });
        }
    };
    __webpack_require__.n = function(module) {//當和ES6模組混用的時候的處理
        var getter = module && module.__esModule ?//如果是ES6模組用module.default
            function getDefault() { return module['default']; } :
            function getModuleExports() { return module; };//是COMMONJS則繼續用module
        __webpack_require__.d(getter, 'a', getter);
        return getter;
    };
    __webpack_require__.o = function(object, property) { //判斷是否有某種屬性(如exports)
return Object.prototype.hasOwnProperty.call(object, property); 
};
    __webpack_require__.p = "";//預設路徑為當前
    return __webpack_require__(__webpack_require__.s = 0);//讀取第一個模組
})
/************************************************************************/
//IIFE第二個括號部分
([
(function(module, exports, __webpack_require__) {
var a = __webpack_require__(1);
var b = __webpack_require__(2);
//模組app程式碼
}),

(function(module, exports, __webpack_require__) {
//模組a程式碼
module.exports = ...
}),
(function(module, exports, __webpack_require__) {
//模組b程式碼
module.exports = ...
})
]);
複製程式碼

如果是ES6模組,處理的方法也不一樣。還是假設我們定義兩個js:app.js是主入口檔案,a.js、b.js是app依賴檔案。

(function(modules) {
//前面這段是一樣的
})
([
(function(module, __webpack_exports__, __webpack_require__) {//入口模組
    Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
    var __WEBPACK_IMPORTED_MODULE_0__m__ = __webpack_require__(1);
    var __WEBPACK_IMPORTED_MODULE_1__m__ = __webpack_require__(2);
    Object(__WEBPACK_IMPORTED_MODULE_0__m__["a"])();//用object包裹著,使得其他模組export的內容即使是基本資料型別,也要讓他變成一個引用型別
    Object(__WEBPACK_IMPORTED_MODULE_1__m__["b"])();

}),
(function(module, __webpack_exports__, __webpack_require__) {
__webpack_exports__["a"] = a;//也就是export xxx
//....
}),
(function(module, __webpack_exports__, __webpack_require__) {
__webpack_exports__["b"] = b;
//....
})
]);

複製程式碼

相關文章