對於JavaScript而言,經過長久的發展,從各式的模組模式、CommonJS、AMD等規範,一直到ECMAScript 6在語言中建立了模組標準,挾著語言本身內建的優勢,ES6模組未來有可能一統天下。

然而,有機會不妨自己來寫個模組管理程式,對於本身模組的需求與JavaScript語言,都能獲得更多的認識。

模組模式

JavaScript並不是以工程需求為出發點的語言,在名稱空間管理上,一開始並沒有太多設計,然而韶光荏苒,瀏覽器始終提供著支援之下,促成了歷史的必然性,將JavaScript一路推向了工程的領域,也迫使開發者為了解決名稱空間問題,而發展出了各式解決的模組模式或方案。

IIFE是這些模組模式的核心,實際上,是個生成後馬上執行的匿名函式,在函式中建立的區域變數可見範圍,限於函式之中,不會污染全域名稱空間,若想要使用某個全域名稱,或者全域物件本身,可以將全域名稱或全域物件當成IIFE的引數傳入,而IIFE中的參數可以自訂名稱,就可避免被其他程式庫名稱踩踏的疑慮。

基本上,IIFE會建立各式名稱,以便於實作模組功能,然而,最後僅需公開某些名稱或功能性時,許多程式庫過去使用_、$等名稱作為全域名稱,程式庫公開的功能性作為全域名稱上的特性,減少了名稱空間佔用;另一種方式則是在IIFE執行過後傳回物件,物件上的特性為公開之功能,開發者可自行建立名稱來參考傳回之物件。例如:

let openhome = (function(global, $) {
// 模組實作
return {
validate,
// 其他特性 ...
};
})((0, eval)('this')), jQuery);

這樣的模式就構成了模組的需求與基本方案,也就是私有名稱空間、名稱的匯入、功能的匯出,可以讓這樣的程式碼座落於openhome.js檔案之中,也就是,一個.js就是一個模組,主檔名成為模組名稱。在應用程式規模不大,而且不想要使用模組管理程式庫的情況下,這已經可以解決不少的名稱空間管理問題。

簡單的模組管理程式

現在的問題在於,如果連openhome這樣的名稱都不想佔用呢?引用模組的一方,若不想使用openhome這樣的名稱呢?那麼,需要有個模組管理程式,來負責管理這些名稱,若以RequireJS為模仿對象,最簡單的實現是:

let define, require;
(function() {
const modules = {};
define = function(name, callback) {
modules[name] = callback();
};
require = function(name, callback) {
callback(modules[name]);
};
})();

接下來,若要定義一個模組,例如,仍然是定義openhome模組,此時,就可以在openhome.js撰寫define('openhome', function() { /* 模組實作 ... 最後 return 一個物件公開模組特性 */ })。接下來,如果應用程式實作時,需要使用openhome模組,我們就可以撰寫require('openhome', function(op) { /* 應用程式 */ }),這麼一來,就不會在全域中佔有openhome這個名稱。

當然使用require時,相依的模組也許不只一個,而使用define時,也可能是依賴在某些模組之上定義新模組,這些都可以修改一下模組管理程式的實作,讓require、define可以指定多個相依模組,可以繼續依需求來重構下去,直到滿足需求為止。

自動獲取、執行模組檔案

在require、define時會相依多個模組的情況下,馬上就會發現大問題。被依賴的模組必須先載入、執行,為此需小心地安排<script>標籤引用.js檔案的順序,而且預設情況下,瀏覽器遇到<script>會停止頁面剖析,進行.js的下載與執行,之後再繼續剖析頁面、下載其他資源,這意謂著.js的下載與執行是同步的,使用者會因此察覺頁面停頓,而可能有不愉快的體驗。

若不想自行安排<script>標籤,須能有個方式自動依指定的模組,產生URI來下載.js檔案,這可以使用DOM API動態建立<script>標籤,指定其src屬性並附加至DOM樹,瀏覽器會像是HTML本身就撰寫有<script>那樣下載src指定的.js。

為了讓瀏覽器可以非同步地下載.js,<script>標籤可以指定async屬性,如此瀏覽器可以非同步(平行地)發出下載.js檔案的請求,在.js下載完成前,不會阻斷後續資源的下載與頁面剖析,但某個.js下載完成時,瀏覽器會暫停其他資源下載或頁面剖析,先執行該.js的內容後,再繼續處理其他資源下載或頁面剖析,如果有多個async屬性的.js,執行的順序是無法預期的。

<script>標籤的load事件,會在.js下載執行之後觸發,問題在於哪個.js先觸發load事件無法預測,因而單純只有load事件,無法知道相依的模組是否都先下載執行了,解決的方式意外地簡單,使用setTimeout持續檢查相依模組是否已下載執行,若是,才呼叫每個相依模組的回呼函式,取得模組功能物件,再執行父模組的回呼。

由於是運用DOM API動態地建立<script>標籤才附加到DOM樹,<script>標籤一開始並不存在於DOM樹之中,若嘗試註冊window.load或DOM樹剖析完成事件是行不通的,這也就是為何RequireJS預設無法註冊這類事件的原因,最簡單的解決方案,是按照最新的實踐,將<script>放在</body>之前,或者是在RequireJS上外掛plugin。

從方才簡單的模組管理程式中可以看到,每個.js執行時會傳入回呼函式,實際上,並不會馬上呼叫回呼函式,而是在相依的模組.js檔案都載入後,才呼叫以獲得模組物件,為此,必須建立模組圖,每個模組必須有特性來保存模組名稱、回呼、相依的模組、父模組,以及載入與否(方才提及的setTimeout就是檢查這個特性)等。

以開放原始碼為臨摹帖

若有興趣看看基於以上原理時,在一個簡單的require.js當中會如何實現,可以看看〈RequireJS-Toy〉(https://goo.gl/dRDrnG)。

在建立模組管理程式的過程中,實際上循序漸進地,逐一面對需求並解決,在適當的時侯停下來,看看下個需求需要什麼,是否需重構現有程式;朝著RequireJS的方式是特意的,因為在卡關的時候,就會知道在閱讀RequireJS原始碼的重點要放在哪邊,也會知道為什麼它會是這麼設計。

每隔一段時間,可以找個開放原始碼來臨摹一下,從解決自己的需求為出發點,想想不依賴程式庫的情況下,自己能否又會如何解決問題。這能讓本身對使用的語言或應用程式環境,有更多認識,也是個參與開放原始碼的方式,除了把握察看原始碼從中學習的機會之外,面對臨摹的對象在使用時的原理或者是限制,也能更進一步地認識。

作者簡介


Advertisement

更多 iThome相關內容