為何使用 Web 模組?

此頁面探討為何 Web 上的模組很有用,以及當今 Web 上可用的機制,以啟用這些模組。有一個單獨的頁面討論 設計力量,針對 RequireJS 所使用的特定函式包裝格式。

問題 § 1

  • 網站正在轉變為 Web 應用程式
  • 隨著網站變大,程式碼複雜度也會增加
  • 組建變得更困難
  • 開發人員需要離散的 JS 檔案/模組
  • 部署需要在一次或幾次 HTTP 呼叫中最佳化程式碼

解決方案§ 2

前端開發人員需要一個具備以下功能的解決方案

  • 某種形式的 #include/import/require
  • 載入巢狀相依性的能力
  • 對開發人員來說易於使用,但由最佳化工具支援,以協助部署

腳本載入 API§ 3

首先要解決的是腳本載入 API。以下是幾個候選者

  • Dojo:dojo.require("some.module")
  • LABjs:$LAB.script("some/module.js")
  • CommonJS:require("some/module")

所有這些都對應載入 some/path/some/module.js。理想情況下,我們可以選擇 CommonJS 語法,因為它可能會隨著時間變得更常見,而且我們想要重複使用程式碼。

我們還需要某種語法,允許載入當前存在的純 JavaScript 檔案——開發人員不應該必須重寫所有 JavaScript,才能獲得腳本載入的好處。

但是,我們需要在瀏覽器中運作良好的東西。CommonJS require() 是同步呼叫,預期會立即傳回模組。這在瀏覽器中運作不良。

非同步與同步§ 4

這個範例應該說明瀏覽器的基本問題。假設我們有一個 Employee 物件,而且我們要讓 Manager 物件從 Employee 物件衍生。 採用這個範例,我們可以使用腳本載入 API 這樣編寫程式碼

var Employee = require("types/Employee");

function Manager () {
    this.reports = [];
}

//Error if require call is async
Manager.prototype = new Employee();

如上方的註解所示,如果 require() 是非同步的,這個程式碼將無法運作。但是,在瀏覽器中同步載入腳本會降低效能。那麼該怎麼辦?

腳本載入:XHR§ 5

很誘人使用 XMLHttpRequest (XHR) 來載入腳本。如果使用 XHR,我們就可以整理上面的文字,我們可以使用正規表示法來尋找 require() 呼叫,確定我們載入那些腳本,然後使用 eval() 或將其主體文字設定為透過 XHR 載入的腳本文字的 script 元素。

使用 eval() 來評估模組很糟糕

  • 開發人員被教導 eval() 很糟糕。
  • 有些環境不允許 eval()。
  • 比較難除錯。Firebug 和 WebKit 的檢查工具有一個 //@ sourceURL= 慣例,這有助於為 evaled 文字命名,但這種支援並非在所有瀏覽器中都是通用的。
  • eval 環境在不同的瀏覽器中不同。你可能可以在 IE 中使用 execScript 來協助這件事,但這表示更多變動的部分。

使用主體文字設定為檔案文字的 script 標籤很糟糕

  • 在除錯時,你為錯誤取得的行號不會對應到原始的來源檔案。

XHR 也有跨網域要求的問題。現在有些瀏覽器有跨網域 XHR 支援,但這並不普遍,而 IE 決定為跨網域呼叫建立一個不同的 API 物件 XDomainRequest。更多變動的部分和更多錯誤的可能性。特別是,你需要確定不傳送任何非標準的 HTTP 標頭,否則可能會執行另一個「預先飛行」要求,以確定允許跨網域存取。

Dojo 使用了一個基於 XHR 的載入器與 eval(),雖然它有效,但它一直是開發人員沮喪的來源。Dojo 有 xdomain 載入器,但它需要透過建置步驟修改模組,以使用函式包裝器,這樣才能使用 script src="" 標籤來載入模組。有很多臨界狀況和變動的部分會為開發人員帶來負擔。

如果我們正在建立一個新的腳本載入器,我們可以做得更好。

腳本載入:Web Workers§ 6

Web Workers 可能是載入腳本的另一種方式,但

  • 它沒有強大的跨瀏覽器支援
  • 它是一個訊息傳遞 API,而腳本可能想要與 DOM 互動,因此這表示只使用工作人員擷取腳本文字,但將文字傳遞回主視窗,然後使用 eval/script 與文字主體來執行腳本。這有上面提到的所有 XHR 問題。

腳本載入:document.write()§ 7

document.write() 可以用來載入腳本,它可以從其他網域載入腳本,而且它對應到瀏覽器通常使用腳本的方式,因此它允許輕鬆除錯。

然而,在 非同步與同步範例 中,我們不能直接執行那個腳本。理想情況下,我們可以在執行腳本之前知道 require() 相依性,並確定那些相依性優先載入。但我們在執行腳本之前無法存取腳本。

此外,document.write() 在頁面載入後不起作用。讓你的網站獲得感知效能的絕佳方式是在使用者需要時依需求載入程式碼,因為這是他們的下一個動作。

最後,透過 document.write() 載入的腳本會阻擋頁面呈現。在尋求網站最佳效能時,這是不可取的。

腳本載入:head.appendChild(script)§ 8

我們可以依需求建立腳本並將它們新增至 head

var head = document.getElementsByTagName('head')[0],
    script = document.createElement('script');

script.src = url;
head.appendChild(script);

除了上述片段外,還需要做更多事,但這就是基本概念。此方法優於 document.write,因為它不會阻擋頁面呈現,且可以在頁面載入後執行。

不過,它仍然有 非同步與同步範例 的問題:理想情況下,我們可以在執行腳本之前得知 require() 相依性,並確保這些相依性會先載入。

函式包裝§ 9

因此,我們需要得知相依性,並確保在執行腳本之前載入它們。要做到這一點的最佳方法,就是使用函式包裝器來建構我們的模組載入 API。如下所示

define(
    //The name of this module
    "types/Manager",

    //The array of dependencies
    ["types/Employee"],

    //The function to execute when all dependencies have loaded. The
    //arguments to this function are the array of dependencies mentioned
    //above.
    function (Employee) {
        function Manager () {
            this.reports = [];
        }

        //This will now work
        Manager.prototype = new Employee();

        //return the Manager constructor function so it can be used by
        //other modules.
        return Manager;
    }
);

這是 RequireJS 使用的語法。如果你只想載入一些未定義模組的純 JavaScript 檔案,也可以使用簡化的語法

require(["some/script.js"], function() {
    //This function is called after some/script.js has loaded.
});

選擇這種語法是因為它簡潔,且允許載入器使用 head.appendChild(script) 類型的載入。

它與一般的 CommonJS 語法不同,這是為了能在瀏覽器中順利運作。有人建議,如果伺服器程序將模組轉換為具有函式包裝器的傳輸格式,則可以使用 head.appendChild(script) 類型的載入與一般的 CommonJS 語法。

我相信,不要強制使用執行時期伺服器程序來轉換程式碼很重要

  • 它會讓除錯變得奇怪,因為伺服器會注入函式包裝器,導致程式碼行數與原始檔案不符。
  • 它需要更多裝備。前端開發應該可以使用靜態檔案。

有關此函式包裝格式(稱為非同步模組定義 (AMD))的設計原理和使用案例的更多詳細資訊,請參閱 為何使用 AMD? 頁面。