JavaScript模組化開發的演進歷程

tonyc726發表於2017-09-08

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外是不能直接訪問和操作的,可以通過暴露一些方法來訪問和操作,比如說上面例子裡面的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模組化開發的演進歷程》,後續如有更新,可以檢視原文。

相關文章