博客 / 詳情

返回

JavaScript模塊化發展

模塊化是我們日常開發都要用到的基本技能,使用簡單且方便,但是很少人能説出來但是的原因及發展過程。現在通過對比不同時期的js的發展,將JavaScript模塊化串聯起來整理學習記憶。

如何理解模塊化

面臨的問題

技術的誕生是為了解決某個問題,模塊化也是。在js模塊化誕生之前,開發者面臨很多問題:隨着前端的發展,web技術日趨成熟,js功能越來越多,代碼量也越來越大。之前一個項目通常各個頁面公用一個js,但是js逐漸拆分,項目中引入的js越來越多:

<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. 文件依賴順序:多個文件之間存在依賴關係,需要保證一定加載順序問題嚴重。

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

什麼是模塊化

clipboard.png

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

模塊化其實是一種規範,一種約束,這種約束會大大提升開發效率。將每個js文件看作是一個模塊,每個模塊通過固定的方式引入,並且通過固定的方式向外暴露指定的內容。

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

模塊化解決方案

模塊化這種規範提出之後,得到社區和廣大開發者的響應,不同時間點有多種實現方式。我們舉個例子:a.js

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

b.js

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

c.js

// 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 從入門到原理
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.