Brendan Eich用了10天就創造了JavaScript,因為當時的需求定位,導致了在設計之初,在語言層就不包含很多高階語言的特性,其中就包括模組這個特性,但是經過了這麼多年的發展,如今對JavaScript的需求已經遠遠超出了Brendan Eich的預期,其中模組化開發更是其中最大的需求之一。
尤其是2009年Node.js出現以後,CommonJS規範的落地極大的推動了整個社群的模組化開發氛圍,並且隨之出現了AMD、CMD、UMD等等一系列可以在瀏覽器等終端實現的非同步載入的模組化方案。
此前,雖然自己也一直在推進模組化開發,但是沒有深入瞭解過模組化演進的歷史,直到最近看到了一篇文章《精讀JS模組化發展》,文章總結了History of JavaScript這個開源專案中關於JavaScript模組化演進的部分,細讀幾次之後,對於一些以前模稜兩可的東西,頓時清晰了不少,下面就以時間線總結一下自己的理解:
在1999年的時候,絕大部分工程師做JS開發的時候就直接將變數定義在全域性,做的好一些的或許會做一些檔案目錄規劃,將資源歸類整理,這種方式被稱為直接定義依賴,舉個例子:
// greeting.js
var helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};
function writeHello(lang) {
document.write(helloInLang[lang]);
}
// third_party_script.js
function writeHello() {
document.write('The script is broken');
}
// index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Basic example</title>
<script src="./greeting.js"></script>
<script src="./third_party_script.js"></script>
</head>
<body onLoad="writeHello('ru')">
</body>
</html>
但是,即使有規範的目錄結構,也不能避免由此而產生的大量全域性變數,這就導致了一不小心就會有變數衝突的問題,就好比上面這個例子中的writeHello
。
於是在2002年左右,有人提出了名稱空間模式的思路,用於解決遍地的全域性變數,將需要定義的部分歸屬到一個物件的屬性上,簡單修改上面的例子,就能實現這種模式:
// greeting.js
var app = {};
app.helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};
app.writeHello = function (lang) {
document.write(helloInLang[lang]);
}
// third_party_script.js
function writeHello() {
document.write('The script is broken');
}
不過這種方式,毫無隱私可言,本質上就是全域性物件,誰都可以來訪問並且操作,一點都不安全。
所以在2003年左右就有人提出利用IIFE結合Closures特性,以此解決私有變數的問題,這種模式被稱為閉包模組化模式:
// greeting.js
var greeting = (function() {
var module = {};
var helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!',
};
module.getHello = function(lang) {
return helloInLang[lang];
};
module.writeHello = function(lang) {
document.write(module.getHello(lang));
};
return module;
})();
IIFE可以形成一個獨立的作用域,其中宣告的變數,僅在該作用域下,從而達到實現私有變數的目的,就如上面例子中的helloInLang
,在該IIFE外是不能直接訪問和操作的,可以通過暴露一些方法來訪問和操作,比如說上面例子裡面的getHello
和writeHello
2個方法,這就是所謂的Closures。
同時,不同模組之間的引用也可以通過引數的形式來傳遞:
// x.js
// @require greeting.js
var x = (function(greeting) {
var module = {};
module.writeHello = function(lang) {
document.write(greeting.getHello(lang));
};
return module;
})(greeting);
此外使用IIFE,還有2個好處:
- 提高效能:通過IIFE的引數傳遞常用全域性物件window、document,在作用域內引用這些全域性物件。JavaScript直譯器首先在作用域內查詢屬性,然後一直沿著鏈向上查詢,直到全域性範圍,因此將全域性物件放在IIFE作用域內可以提升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="deferred.js"></script>
<script src="util/wxbridge.js"></script>
<script src="util/login.js"></script>
<script src="util/base.js"></script>
<script src="util/city.js"></script>
如果頁面中使用的模組數量越來越多,恐怕再有經驗的工程師也很難維護好它們之間的依賴關係了。
於是如LABjs之類的載入工具就橫空出世了,通過使用它的API,動態建立<script>
,從而達到控制JS檔案載入以及執行順序的目的,在一定的程度上解決了依賴關係,例如:
$LAB.script("greeting.js").wait()
.script("x.js")
.script("y.js").wait()
.script("run.js");
不過LABjs之類的載入工具是建立在以檔案為單位的基礎之上的,但是JS中的模組又不一定必須是檔案,同一個檔案中可以宣告多個模組,YUI作為昔日前端領域的佼佼者,很好的糅合了名稱空間模式及沙箱模式,下面來一睹它的風采:
// 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團隊還提供的一系列用於JS壓縮、混淆、請求合併(合併資源需要server端配合)等效能優化的工具,說其是現有JS模組化的鼻祖一點都不過分。
不過,隨著Node.js的到來,CommonJS規範的落地以及各種前端工具、解決方案的出現,很快,YUI3就被湮沒在了歷史的長流裡面,這樣成為了JS模組化開發的一個分水嶺,引用一段描述:
從 1999 年開始,模組化探索都是基於語言層面的優化,真正的革命從 2009 年 CommonJS 的引入開始,前端開始大量使用預編譯。
CommonJS是一套同步的方案,它考慮的是在服務端執行的Node.js,主要是通過require
來載入依賴項,通過exports
或者module.exports
來暴露介面或者資料的方式,想了解更多,可以看一下《CommonJS規範》,下面舉個簡單的例子:
var math = require('math');
esports.result = math.add(2,3); // 5
由於伺服器上通過require
載入資源是直接讀取檔案的,因此中間所需的時間可以忽略不計,但是在瀏覽器這種需要依賴HTTP獲取資源的就不行了,資源的獲取所需的時間不確定,這就導致必須使用非同步機制,代表主要有2個:
它們分別在瀏覽器實現了define
、require
及module
的核心功能,雖然兩者的目標是一致的,但是實現的方式或者說是思路,還是有些區別的,AMD偏向於依賴前置,CMD偏向於用到時才執行的思路,從而導致了依賴項的載入和執行時間點會不同,關於這2者的比較,網上有很多了,這裡推薦幾篇僅供參考:
本人就先接觸了SeaJS後轉到RequireJS,雖然感覺AMD的模式寫確實沒有CMD這麼符合一慣的語義邏輯,但是寫了幾個模組以後就習慣了,而且社群資源比較豐富的AMD陣營更加符合當時的專案需求(扯多了),下面分別寫個例子做下直觀的對比:
// CMD
define(function (require) {
var a = require('./a'); // <- 執行到此處才開始載入並執行模組a
var b = require('./b'); // <- 執行到此處才開始載入並執行模組b
// more code ..
})
// AMD
define(
['./a', './b'], // <- 前置宣告,也就是在主體執行前就已經載入並執行了模組a和模組b
function (a, b) {
// more code ..
}
)
通過例子,你可以看到除了語法上面的區別,這2者主要的差異還是在於:
何時載入和執行依賴項?
這也是CommonJS社群中質疑AMD最主要原因之一,不少人認為它破壞了規範,反觀CMD模式,簡單的去除define
的外包裝,這就是標準的CommonJS實現,所以說CMD是最貼近CommonJS的非同步模組化方案,不過孰優孰劣,這裡就不扯了,需求決定一切。
此外同一時期還出現了一個UMD的方案,其實它就是AMD與CommonJS的集合體,通過IIFE的前置條件判斷,使一個模組既可以在瀏覽器執行,也可以在Node.JS中執行,舉個例子:
// UMD
(function(define) {
define(function () {
var helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};
return {
sayHello: function (lang) {
return helloInLang[lang];
}
};
});
}(
typeof module === 'object' && module.exports && typeof define !== 'function' ?
function (factory) { module.exports = factory(); } :
define
));
個人覺得最少用到的就是這個UMD模式了。
2015年6月,ECMAScript2015也就是ES6釋出了,JavaScript終於在語言標準的層面上,實現了模組功能,使得在編譯時就能確定模組的依賴關係,以及其輸入和輸出的變數,不像 CommonJS、AMD之類的需要在執行時才能確定(例如FIS這樣的工具只能預處理依賴關係,本質上還是執行時解析),成為瀏覽器和伺服器通用的模組解決方案。
// lib/greeting.js
const helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};
export const getHello = (lang) => (
helloInLang[lang];
);
export const sayHello = (lang) => {
console.log(getHello(lang));
};
// hello.js
import { sayHello } from './lib/greeting';
sayHello('ru');
與CommonJS用require()
方法載入模組不同,在ES6中,import
命令可以具體指定載入模組中用export
命令暴露的介面(不指定具體的介面,預設載入export default
),沒有指定的是不會載入的,因此會在編譯時就完成模組的載入,這種載入方式稱為編譯時載入或者靜態載入。
而CommonJS的require()
方法是在執行時才載入的:
// lib/greeting.js
const helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};
const getHello = function (lang) {
return helloInLang[lang];
};
exports.getHello = getHello;
exports.sayHello = function (lang) {
console.log(getHello(lang))
};
// hello.js
const sayHello = require('./lib/greeting').sayHello;
sayHello('ru');
可以看出,CommonJS中是將整個模組作為一個物件引入,然後再獲取這個物件上的某個屬性。
因此ES6的編譯時載入,在效率上面會提高不少,此外,還會帶來一些其它的好處,比如引入巨集(macro)和型別檢驗(type system)這些只能靠靜態分析實現的功能。
可惜的是,目前瀏覽器和Node.js的支援程度都並不理想,截止發稿,也就只有 Chrome61+ 與 Safari10.1+ 才做到了部分支援。
不過可以通過Babel這類工具配合相關的plugin(可以參考《Babel筆記》),轉換為ES5的語法,這樣就可以在Node.js執行起來了,如果想在瀏覽器上執行,可以新增Babel配置,為模組檔案添上AMD的define
函式作為外層,再並配合RequireJS之類的載入器即可。
更多關於ES6 Modules的資料,可以看一下《ECMAScript 6 入門 - Module 的語法》。
參考
- 精讀 js 模組化發展
- History of JavaScript
- JavaScript 模組化七日談
- JavaScript Module Pattern: In-Depth
- 前端模組化開發那點歷史
- JavaScript Modules: A Beginner’s Guide
本文先釋出於我的個人部落格《JavaScript模組化開發的演進歷程》,後續如有更新,可以檢視原文。