JavaScript模塊化發展

模塊化是咱們平常開發都要用到的基本技能,使用簡單且方便,可是不多人能說出來可是的緣由及發展過程。如今經過對比不一樣時期的js的發展,將JavaScript模塊化串聯起來整理學習記憶。javascript

如何理解模塊化

面臨的問題

技術的誕生是爲了解決某個問題,模塊化也是。在js模塊化誕生以前,開發者面臨不少問題:隨着前端的發展,web技術日趨成熟,js功能愈來愈多,代碼量也愈來愈大。以前一個項目一般各個頁面公用一個js,可是js逐漸拆分,項目中引入的js愈來愈多:html

<script src="zepto.js"></script>
<script src="jhash.js"></script>
<script src="fastClick.js"></script>
<script src="iScroll.js"></script>
<script src="underscore.js"></script>
<script src="handlebar.js"></script>
<script src="datacenter.js"></script>
<script src="util/wxbridge.js"></script>
<script src="util/login.js"></script>
<script src="util/base.js"></script>

當年我剛剛實習的時候,項目中的js就是相似這樣,這樣的js引入形成了問題:前端

  1. 全局變量污染:各個文件的變量都是掛載到window對象上,污染全局變量。
  2. 變量重名:不一樣文件中的變量若是重名,後面的會覆蓋前面的,形成程序運行錯誤。
  3. 文件依賴順序:多個文件之間存在依賴關係,須要保證必定加載順序問題嚴重。

這些問題嚴重干擾開發,也是平常開發中常常遇到的問題。java

什麼是模塊化

clipboard.png

我以爲用樂高積木來比喻模塊化再好不過了。每一個積木都是固定的顏色形狀,想要組合積木必須使用積木凸起和凹陷的部分進行鏈接,最後多個積木累積成你想要的形狀。node

模塊化實際上是一種規範,一種約束,這種約束會大大提高開發效率。將每一個js文件看做是一個模塊,每一個模塊經過固定的方式引入,而且經過固定的方式向外暴露指定的內容。git

按照js模塊化的設想,一個個模塊按照其依賴關係組合,最終插入到主程序中。es6

模塊化解決方案

模塊化這種規範提出以後,獲得社區和廣大開發者的響應,不一樣時間點有多種實現方式。咱們舉個例子:a.jsgithub

// a.js
var aStr = 'aa';
var aNum = cNum + 1;

b.jsweb

// b.js
var bStr = aStr + ' bb';

c.jsnpm

// c.js
var cNum = 0;

index.js

// index.js
console.log(aNum, bStr);

四份文件,不一樣的依賴關係(a依賴c,b依賴a,index依賴a b)在沒有模塊化的時候咱們須要頁面中這樣:

<script src="./c.js"></script>    
<script src="./a.js"></script>
<script src="./b.js"></script>
<script src="./index.js"></script>

嚴格保證加載順序,不然報錯。

1. 閉包與命名空間

這是最容易想到的也是最簡便的解決方式,早在模塊化概念提出以前不少人就已經使用閉包的方式來解決變量重名和污染問題。

這樣每一個js文件都是使用IIFE包裹的,各個js文件分別在不一樣的詞法做用域中,相互隔離,最後經過閉包的方式暴露變量。每一個閉包都是單獨一個文件,每一個文件仍然經過script標籤的方式下載,標籤的順序就是模塊的依賴關係。

上面的例子咱們用該方法修改下寫法:

a.js

// a.js
var a = (function(cNum){
   var aStr = 'aa';
   var aNum = cNum + 1; 
    
    return {
       aStr: aStr,
       aNum: aNum
    };
})(cNum);

b.js

// b.js
var bStr = (function(a){
   var bStr = a.aStr + ' bb';
    
   return bStr;
})(a);

c.js

// c.js
var cNum = (function(){
   var cNum = 0;
    
   return cNum;
})();

index.js

;(function(a, bStr){
    console.log(a.aNum, bStr);
})(a, bStr)

這種方法下仍然須要在入口處嚴格保證加載順序:

<script src="./c.js"></script>    
<script src="./a.js"></script>
<script src="./b.js"></script>
<script src="./index.js"></script>

這種方式最簡單有效,也是後續其餘解決方案的基礎。這樣作的意義:

  1. 各個js文件之間避免了變量重名干擾,而且最少的暴露變量,避免全局污染。
  2. 模塊外部不能輕易的修改閉包內部的變量,程序的穩定性增長。
  3. 模塊與外部的鏈接經過IIFE傳參,語義化更好,清晰地知道有哪些依賴。

不過各個模塊的依賴關係仍然要經過加裝script的順序來保證。

2. 面向對象開發

一開始一些人在閉包的解決方案上作出了規範約束:每一個js文件始終返回一個object,將內容做爲object的屬性。

好比上面的例子中b.js

// b.js
var b = (function(a){
   var bStr = a.aStr + ' bb';
    
   return {
       bStr: bStr
   };
})(a);

及時返回的是個值,也要用object包裹。後來不少人開始使用面向對象的方式開發插件:

;(function($){
    var LightBox = function(){
        // ...
    };
    
    LightBox.prototype = {
        // ....
    };
    
    window['LightBox'] = LightBox;
})($);

使用的時候:

var lightbox = new LightBox();

當年不少人都喜歡這樣開發插件,而且認爲能寫出這種插件的水平至少不低。這種方法只是閉包方式的小改進,約束js文件返回必須是對象,對象其實就是一些個方法和屬性的集合。這樣的優勢:

  1. 規範化輸出,更加統一的便於相互依賴和引用。
  2. 使用‘類’的方式開發,便於後面的依賴進行擴展。

本質上這種方法只是對閉包方法的規範約束,並無作什麼根本改動。

3. YUI

早期雅虎出品的一個工具,模塊化管理只是一部分,其還具備JS壓縮、混淆、請求合併(合併資源須要server端配合)等性能優化的工具,說其是現有JS模塊化的鼻祖一點都不過度。

// YUI - 編寫模塊
YUI.add('dom', function(Y) {
  Y.DOM = { ... }
})

// YUI - 使用模塊
YUI().use('dom', function(Y) {
  Y.DOM.doSomeThing();
  // use some methods DOM attach to Y
})

// hello.js
YUI.add('hello', function(Y){
    Y.sayHello = function(msg){
        Y.DOM.set(el, 'innerHTML', 'Hello!');
    }
},'3.0.0',{
    requires:['dom']
})

// main.js
YUI().use('hello', function(Y){
    Y.sayHello("hey yui loader");
})

YUI的出現使人眼前一新,他提供了一種模塊管理方式:經過YUI全局對象去管理不一樣模塊,全部模塊都只是對象上的不一樣屬性,至關因而不一樣程序運行在操做系統上。YUI的核心實現就是閉包,不過好景不長,具備里程碑式意義的模塊化工具誕生了。

4. CommonJs

2009年Nodejs發佈,其中Commonjs是做爲Node中模塊化規範以及原生模塊面世的。Node中提出的Commonjs規範具備如下特色:

  1. 原生Module對象,每一個文件都是一個Module實例
  2. 文件內經過require對象引入指定模塊
  3. 全部文件加載均是同步完成
  4. 經過module關鍵字暴露內容
  5. 每一個模塊加載一次以後就會被緩存
  6. 模塊編譯本質上是沙箱編譯
  7. 因爲使用了Node的api,只能在服務端環境上運行

基本上Commonjs發佈以後,就成了Node裏面標準的模塊化管理工具。同時Node還推出了npm包管理工具,npm平臺上的包均知足Commonjs規範,隨着Node與npm的發展,Commonjs影響力也愈來愈大,而且促進了後面模塊化工具的發展,具備里程碑意義的模塊化工具。以前的例子咱們這樣改寫:

a.js

// a.js
var c = require('./c');

module.exports = {
    aStr: 'aa',
    aNum: c.cNum + 1
};

b.js

// b.js
var a = require('./a');

exports.bStr = a.aStr + ' bb';

c.js

// c.js
exports.cNum = 0;

入口文件就是 index.js

var a = require('./a');
var b = require('./b');

console.log(a.aNum, b.bStr);

能夠直觀的看到,使用Commonjs管理模塊,十分方便。Commonjs優勢在於:

  1. 強大的查找模塊功能,開發十分方便
  2. 標準化的輸入輸出,很是統一
  3. 每一個文件引入本身的依賴,最終造成文件依賴樹
  4. 模塊緩存機制,提升編譯效率
  5. 利用node實現文件同步讀取
  6. 依靠注入變量的沙箱編譯實現模塊化

這裏補充一點沙箱編譯:require進來的js模塊會被Module模塊注入一些變量,使用當即執行函數編譯,看起來就好像:

(function (exports, require, module, __filename, __dirname) {
    //原始文件內容
})();

看起來require和module好像是全局對象,其實只是閉包中的入參,並非真正的全局對象。以前專門整理探究過 Node中的Module源碼分析,也能夠看看阮一峯老師的require()源碼解讀,或者廖雪峯老師的CommonJS規範

5. AMD和RequireJS

Commonjs的誕生給js模塊化發展有了重要的啓發,Commonjs很是受歡迎,可是侷限性很明顯:Commonjs基於Node原生api在服務端能夠實現模塊同步加載,可是僅僅侷限於服務端,客戶端若是同步加載依賴的話時間消耗很是大,因此須要一個在客戶端上基於Commonjs可是對於加載模塊作改進的方案,因而AMD規範誕生了。

AMD是"Asynchronous Module Definition"的縮寫,意思就是"異步模塊定義"。它採用異步方式加載模塊,模塊的加載不影響它後面語句的運行。全部依賴這個模塊的語句,都定義在一個回調函數中,等到全部依賴加載完成以後(前置依賴),這個回調函數纔會運行。

AMD規範

AMD與Commonjs同樣都是js模塊化規範,是一套抽象的約束,與2009年誕生。文檔這裏。該約束規定採用require語句加載模塊,可是不一樣於CommonJS,它要求兩個參數:

require([module], callback);

第一個參數[module],是一個數組,裏面的成員就是要加載的模塊;第二個參數callback,則是加載成功以後的回調函數。若是將前面的代碼改寫成AMD形式,就是下面這樣:

require(['math'], function (math) {

    math.add(2, 3);

});

定義了一個文件,該文件依賴math模塊,當math模塊加載完畢以後執行回調函數,這裏並無暴露任何變量。不一樣於Commonjs,在定義模塊的時候須要使用define函數定義:

define(id?, dependencies?, factory);

define方法與require相似,id是定義模塊的名字,仍然會在全部依賴加載完畢以後執行factory。

RequireJs

RequireJs是js模塊化的工具框架,是AMD規範的具體實現。可是有意思的是,RequireJs誕生以後,推廣過程當中產生的AMD規範。文檔這裏

RequireJs有兩個最鮮明的特色:

  1. 依賴前置:動態建立<script>引入依賴,在<script>標籤的onload事件監聽文件加載完畢;一個模塊的回調函數必須得等到全部依賴都加載完畢以後,纔可執行,相似Promise.all。
  2. 配置文件:有一個main文件,配置不一樣模塊的路徑,以及shim不知足AMD規範的js文件。

仍是上面那個例子:

配置文件main.js

requirejs.config({
    shim: {
        // ...
    },
    paths: {
        a: '/a.js',
        b: '/b.js',
        c: '/c.js',
        index: '/index.js'
    }
});

require(['index'], function(index){
    index();
});

a.js

define('a', ['c'], function(c){
    return {
        aStr: 'aa',
        aNum: c.cNum + 1
    }
});

b.js

define('b', ['a'], function(a){
    return {
        bStr = a.aStr + ' bb';
    }
});

c.js

define('c', function(){
    return {
        cNum: 0
    }
});

index.js

define('index', ['a', 'b'], function(a, b){
    return function(){
        console.log(a.aNum, b.bStr);
    }
});

頁面中嵌入

<script src="/require.js" data-main="/main" async="async" defer></script>

RequireJs當年在國內很是受歡迎,主要是如下優勢:

  1. 動態並行加載js,依賴前置,無需再考慮js加載順序問題。
  2. 核心仍是注入變量的沙箱編譯,解決模塊化問題。
  3. 規範化輸入輸出,使用起來方便。
  4. 對於不知足AMD規範的文件能夠很好地兼容。

不過我的以爲RequireJs配置仍是挺麻煩的,可是當年已經很是方便了。

6. CMD和SeaJs

CMD規範

一樣是受到Commonjs的啓發,國內(阿里)誕生了一個CMD(Common Module Definition)規範。該規範借鑑了Commonjs的規範與AMD規範,在二者基礎上作了改進。

define(id?, dependencies?, factory);

與AMD相比很是相似,CMD規範(2011)具備如下特色:

  1. define定義模塊,require加載模塊,exports暴露變量。
  2. 不一樣於AMD的依賴前置,CMD推崇依賴就近(須要的時候再加載)
  3. 推崇api功能單一,一個模塊幹一件事。

SeaJs

SeaJs是CMD規範的實現,跟RequireJs相似,CMD也是SeaJs推廣過程當中誕生的規範。CMD借鑑了不少AMD和Commonjs優勢,一樣SeaJs也對AMD和Commonjs作出了不少兼容。

SeaJs核心特色:

  1. 須要配置模塊對應的url。
  2. 入口文件執行以後,根據文件內的依賴關係整理出依賴樹,而後經過插入<script>標籤加載依賴。
  3. 依賴加載完畢以後,執行根factory。
  4. 在factory中遇到require,則去執行對應模塊的factory,實現就近依賴。
  5. 相似Commonjs,對全部模塊進行緩存(模塊的url就是id)。
  6. 相似Commonjs,可使用相對路徑加載模塊。
  7. 能夠向RequireJs同樣前置依賴,可是推崇就近依賴。
  8. exports和return均可以暴露變量。

修改下上面那個例子:

a.js

console.log('a1');
define(function(require,exports,module){
    console.log('inner a1');
    require('./c.js')
});
console.log('a2')

b.js

console.log('b1');
define(function(require,exports,module){
    console.log('inner b1');
});
console.log('b2')

c.js

console.log('c1');
define(function(require,exports,module){
    console.log('inner c1');
});
console.log('c2')

頁面引入

<body>
    <script src="/sea.js"></script>
    <script>
    seajs.use(['./a.js','./b.js'],function(a,b){
        console.log('index1');
    })    
    </script>
</body>

對於seaJs中的就近依賴,有必要單獨說一下。來看一下上面例子中的log順序:

  1. seaJs執行入口文件,入口文件依賴a和b,a內部則依賴c。
  2. 依賴關係梳理完畢,開始動態script標籤下載依賴,控制檯輸出:

    a1
    a2
    b1
    b2
    c1
    c2
  3. 依賴加載以後,按照依賴順序開始解析模塊內部的define:inner a1
  4. 在a模塊中遇到了require('./c'),就近依賴這時候纔去執行c模塊的factory:inner c1
  5. 而後解析b模塊:inner b1
  6. 所有依賴加載完畢,執行最後的factory:index

完整的順序就是:

a1
a2
b1
b2
c1
c2
inner a1
inner c1 
inner b1
index

這是一個能夠很好理解SeaJs的例子。

7. ES6中的模塊化

以前的各類方法和框架,都出自於各個大公司或者社區,都是民間出臺的結局方法。到了2015年,ES6規範中,終於將模塊化歸入JavaScript標準,今後js模塊化被官方扶正,也是將來js的標準。

以前那個例子再用ES6的方式實現一次:

a.js

import {cNum} from './c';

export default {
    aStr: 'aa',
    aNum: cNum + 1
};

b.js

import {aStr} from './a';

export const bStr = aStr + ' bb';

c.js

export const bNum = 0;

index.js

import {aNum} from './a';
import {bStr} from './b';

console.log(aNum, bStr);

能夠看到,ES6中的模塊化在Commonjs的基礎上有所不一樣,增長了關鍵字import,export,default,as,from,而不是全局對象。另外深刻理解的話,有兩點主要的區別:

  1. CommonJS 模塊輸出的是一個值的拷貝,ES6 模塊輸出的是值的引用。
  2. CommonJS 模塊是運行時加載,ES6 模塊是編譯時輸出接口。

一個經典的例子:

// counter.js
exports.count = 0
setTimeout(function () {
  console.log('increase count to', ++exports.count, 'in counter.js after 500ms')
}, 500)

// commonjs.js
const {count} = require('./counter')
setTimeout(function () {
  console.log('read count after 1000ms in commonjs is', count)
}, 1000)

//es6.js
import {count} from './counter'
setTimeout(function () {
  console.log('read count after 1000ms in es6 is', count)
}, 1000)

分別運行 commonjs.js 和 es6.js:

➜  test node commonjs.js
increase count to 1 in counter.js after 500ms
read count after 1000ms in commonjs is 0
➜  test babel-node es6.js
increase count to 1 in counter.js after 500ms
read count after 1000ms in es6 is 1

這個例子解釋了CommonJS 模塊輸出的是值的拷貝,也就是說,一旦輸出一個值,模塊內部的變化就影響不到這個值。ES6 模塊的運行機制與 CommonJS 不同。JS 引擎對腳本靜態分析的時候,遇到模塊加載命令import,就會生成一個只讀引用。等到腳本真正執行時,再根據這個只讀引用,到被加載的那個模塊裏面去取值。換句話說,ES6 的import有點像 Unix 系統的「符號鏈接」,原始值變了,import加載的值也會跟着變。所以,ES6 模塊是動態引用,而且不會緩存值,模塊裏面的變量綁定其所在的模塊。

更多ES6模塊化特色,參照阮一峯老師的ECMAScript 6 入門

總結思考

寫了這麼多,其實都是走馬觀花地從使用方式和運行原理分析了不一樣方法的實現。如今從新看一下當時模塊化的痛點:

  1. 全局變量污染:各個文件的變量都是掛載到window對象上,污染全局變量。
  2. 變量重名:不一樣文件中的變量若是重名,後面的會覆蓋前面的,形成程序運行錯誤。
  3. 文件依賴順序:多個文件之間存在依賴關係,須要保證必定加載順序問題嚴重。

不一樣的模塊化手段都在致力於解決這些問題。前兩個問題其實很好解決,使用閉包配合當即執行函數,高級一點使用沙箱編譯,緩存輸出等等。

我以爲真正的難點在於依賴關係梳理以及加載。Commonjs在服務端使用fs能夠接近同步的讀取文件,可是在瀏覽器中,無論是RequireJs仍是SeaJs,都是使用動態建立script標籤方式加載,依賴所有加載完畢以後執行,省去了開發手動書寫加載順序這一煩惱。

到了ES6,官方出臺設定標準,不在須要出框架或者hack的方式解決該問題,該項已經做爲標準要求各瀏覽器實現,雖然如今瀏覽器所有實現該標準尚無時日,可是確定是將來趨勢。

參考

  1. JavaScript模塊化開發的演進歷程
  2. 精讀 js 模塊化發展
  3. 淺談模塊化開發
  4. 深刻理解 ES6 模塊機制
  5. Module 的加載實現
  6. SeaJS 從入門到原理
相關文章
相關標籤/搜索