JavaScript模塊化開發的演進歷程

Brendan Eich用了10天就創造了JavaScript,由於當時的需求定位,致使了在設計之初,在語言層就不包含不少高級語言的特性,其中就包括模塊這個特性,可是通過了這麼多年的發展,現在對JavaScript的需求已經遠遠超出了Brendan Eich的預期,其中模塊化開發更是其中最大的需求之一。javascript

尤爲是2009年Node.js出現之後,CommonJS規範的落地極大的推進了整個社區的模塊化開發氛圍,而且隨之出現了AMD、CMD、UMD等等一系列能夠在瀏覽器等終端實現的異步加載的模塊化方案。html

此前,雖然本身也一直在推動模塊化開發,可是沒有深刻了解過模塊化演進的歷史,直到最近看到了一篇文章《精讀JS模塊化發展》,文章總結了History of JavaScript這個開源項目中關於JavaScript模塊化演進的部分,細讀幾回以後,對於一些之前模棱兩可的東西,頓時清晰了很多,下面就以時間線總結一下本身的理解:前端

1999年的時候,絕大部分工程師作JS開發的時候就直接將變量定義在全局,作的好一些的或許會作一些文件目錄規劃,將資源歸類整理,這種方式被稱爲直接定義依賴,舉個例子:java

// 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>

可是,即便有規範的目錄結構,也不能避免由此而產生的大量全局變量,這就致使了一不當心就會有變量衝突的問題,就比如上面這個例子中的writeHellonode

因而在2002年左右,有人提出了命名空間模式的思路,用於解決遍地的全局變量,將須要定義的部分歸屬到一個對象的屬性上,簡單修改上面的例子,就能實現這種模式:git

// 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');
}

不過這種方式,毫無隱私可言,本質上就是全局對象,誰均可以來訪問而且操做,一點都不安全。es6

因此在2003年左右就有人提出利用IIFE結合Closures特性,以此解決私有變量的問題,這種模式被稱爲閉包模塊化模式github

// 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外是不能直接訪問和操做的,能夠經過暴露一些方法來訪問和操做,好比說上面例子裏面的getHellowriteHello2個方法,這就是所謂的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個好處:

  1. 提升性能:經過IIFE的參數傳遞經常使用全局對象window、document,在做用域內引用這些全局對象。JavaScript解釋器首先在做用域內查找屬性,而後一直沿着鏈向上查找,直到全局範圍,所以將全局對象放在IIFE做用域內能夠提高js解釋器的查找速度和性能;
  2. 壓縮空間:經過參數傳遞全局對象,壓縮時能夠將這些全局對象匿名爲一個更加精簡的變量名;

在那個年代,除了這種解決思路之外,還有經過其它語言的協助來完成模塊化的解決思路,好比說模版依賴定義註釋依賴定義外部依賴定義等等,不過不常見,因此就不細說了,究其本源,它們想最終實現的方式都差很少。

不過,這些方案,雖然解決了依賴關係的問題,可是沒有解決如何管理這些模塊,或者說在使用時清晰描述出依賴關係,這點仍是沒有被解決,能夠說是少了一個管理者。

沒有管理者的時候,在實際項目中,得手動管理第三方的庫和項目封裝的模塊,就像下面這樣把全部須要的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個:

  • 基於 AMD 的RequireJS
  • 基於 CMD 的SeaJS

它們分別在瀏覽器實現了definerequiremodule的核心功能,雖然二者的目標是一致的,可是實現的方式或者說是思路,仍是有些區別的,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 的語法》。


參考

本文先發佈於個人我的博客《 JavaScript模塊化開發的演進歷程》,後續若有更新,能夠查看原文。
相關文章
相關標籤/搜索