AMD 的 CommonJS wrapping

其實本文的標題應該是「為什么我不推薦使用 AMD 的 Simplified CommonJS wrapping」,但太長了不好看,為了美觀我只能砍掉一截。

它是什么?

為了復用已有的 CommonJS 模塊,AMD 規定了 Simplified CommonJS wrapping,然后 RequireJS 實現了它(先后順序不一定對)。它提供了類似于 CommonJS 的模塊定義方式,如下:


define(function(require, exports, module) {

        var A = require('a');

        return function () {};
    });

這樣,模塊的依賴可以像 CommonJS 一樣「就近定義」。但就是這個看上去兩全其美的做法,給大家帶來了很多困擾。

它做了什么?

由于 RequireJS 是最流行的 AMD 加載器,后續討論都基于 RequireJS 進行。

直接看 RequireJS 這部分邏輯:


//If no name, and callback is a function, then figure out if it a

    //CommonJS thing with dependencies.
    if (!deps && isFunction(callback)) {
        deps = [];
        if (callback.length) {
            callback
                .toString()
                .replace(commentRegExp, '')
                .replace(cjsRequireRegExp, function (match, dep) {
                    deps.push(dep);
                });

            deps = (callback.length === 1 ? ['require'] : ['require', 'exports', 'module']).concat(deps);
        }
    }

可以看到,為了支持 CommonJS Wrapper 這種寫法,define 函數里需要做這些事情:

  1. 通過 factory.toString() 拿到 factory 的源碼;
  2. 去掉源碼中的注釋(避免匹配到注釋掉的依賴模塊);
  3. 通過正則匹配 require 的方式得到依賴信息;

寫模塊時要把 require 當成保留字。模塊加載器和構建工具都要實現上述邏輯。

對于 RequireJS,本文最開始定義的模塊,最終會變成:


define(['a'], function(require, exports, module) {

        var A = require('a');

        return function () {};
    });

等價于:


define(['a'], function(A) {

        return function () {};
    });

結論是,CommonJS Wrapper 只是書寫上兼容了 CommonJS 的寫法,模塊運行邏輯并不會改變。

AMD 運行策略

AMD 運行時核心思想是「Early Executing」,也就是提前執行依賴。這個好理解:


//main.js

    define(['a', 'b'], function(A, B) {
        //運行至此,a.js 和 b.js 已下載完成(運行于瀏覽器的 Loader 必須如此);
        //A、B 兩個模塊已經執行完,直接可用(這是 AMD 的特性);

        return function () {};
    });

個人覺得,AMD 的這個特性有好有壞:

首先,盡早執行依賴可以盡早發現錯誤。上面的代碼中,假如 a 模塊中拋異常,那么 main.js 在調用 factory 方法之前一定會收到錯誤,factory 不會執行;如果按需執行依賴,結果是:1)沒有進入使用 a 模塊的分支時,不會發生錯誤;2)出錯時,main.js 的 factory 方法很可能執行了一半。

另外,盡早執行依賴通常可以帶來更好的用戶體驗,也容易產生浪費。例如模塊 a 依賴了另外一個需要異步加載數據的模塊 b,盡早執行 b 可以讓等待時間更短,同時如果 b 最后沒被用到,帶寬和內存開銷就浪費了;這種場景下,按需執行依賴可以避免浪費,但是帶來更長的等待時間。

我個人更傾向于 AMD 這種做法。舉一個不太恰當的例子:Chrome 和 Firefox 為了更好的體驗,對于某些類型的文件,點擊下載地址后會詢問是否保存, 這時候實際上已經開始了下載。有時候等了很久才點確認,會開心地發現文件已經下好;如果點取消,瀏覽器會取消下載,已下載的部分就浪費了。

了解到 AMD 這個特性后,再來看一段代碼:


//mod1.js

    define(function() {
        console.log('require module: mod1');

        return {
            hello: function() {
                console.log("hello mod1");
            }
        };
    });

//mod2.js

    define(function() {
        console.log('require module: mod2');

        return {
            hello: function() {
                console.log("hello mod2");
            }
        };
    });

//main.js

    define(['mod1', 'mod2'], function(mod1, mod2) {
        //運行至此,mod1.js 和 mod2.js 已經下載完成;
        //mod1、mod2 兩個模塊已經執行完,直接可用;

        console.log('require module: main');

        mod1.hello();
        mod2.hello();

        return {
            hello: function() {
                console.log('hello main');
            }
        };
    });

<!--index.html-->

    <script>
        require(['main'], function(main) {
            main.hello();
        });
    </script>

在本地測試,通常結果是這樣的:


require module: mod1

    require module: mod2
    require module: main
    hello mod1
    hello mod2
    hello main

這個結果符合預期。但是這就是全部嗎?用 Fiddler 把 mod1.js 請求 delay 200 再測試,這次輸出:


require module: mod2

    require module: mod1
    require module: main
    hello mod1
    hello mod2
    hello main

這是因為 main.js 中 mod1 和 mod2 兩個模塊并行加載,且加載完就執行,所以前兩行輸出順序取決于哪個 js 先加載完。如果一定要讓 mod2 在 mod1 之后執行,需要在 define 模塊時申明依賴,或者通過 require.config 配置依賴:


require.config({

        shim: {
            'mod2': {
                deps : ['mod1']
            }
        }
    });

嚴重問題!

我們再回過頭來看 CommonJS Wrapper 會帶來什么問題。前面說過,AMD 規范中,上面的 main.js 等價于這樣:


//main.js

    define(function(require, exports, module) {
        //運行至此,mod1.js 和 mod2.js 已經下載完成;

        console.log('require module: main');

        var mod1 = require('./mod1'); //這里才執行 mod1 ?
        mod1.hello();
        var mod2 = require('./mod2'); //這里才執行 mod2 ?
        mod2.hello();

        return {
            hello: function() {
                console.log('hello main');
            }
        };
    });

這種「就近」書寫的依賴,非常容易讓人認為 main.js 執行到對應 require 語句時才執行 mod1 或 mod2,但這是錯誤的,因為 CommonJS Wrapper 并不會改變 AMD「盡早執行」依賴的本質!

實際上,對于按需執行依賴的加載器,如 SeaJS,上述代碼結果一定是:


require module: main

    require module: mod1
    hello mod1
    require module: mod2
    hello mod2
    hello main

于是,了解過 CommonJS 或 CMD 模塊規范的同學,看到使用 CommonJS Wrapper 方式寫的 AMD 模塊,容易產生理解偏差,從而誤認為 RequireJS 有 bug。

我覺得「盡早執行」或「按需執行」兩種策略沒有明顯的優劣之分,但 AMD 這種「模仿別人寫法,卻提供不一樣的特性」這個做法十分愚蠢。這年頭,做自己最重要!

其他問題

還有一個小問題也順帶提下:默認情況下,定義 AMD 模塊時通過參數傳入依賴列表,簡單可依賴。而用了 CommonJS Wrapper 之后,RequireJS 需要通過正則從 factory.toString() 中提取依賴,復雜并容易出錯。如 RequireJS 下這段代碼會出錯:


define(function(require, exports, module) {

        '/*';
        var mod1 = require('mod1'),
            mod2 = require('mod2');
        '*/';

        mod1.hello();
    });

    //Uncaught Error: Module name "mod1" has not been loaded yet for context: _

當然,這個因為 RequireJS 的正則沒寫好,把正常語句當注釋給過濾了,SeaJS 用的正則處理上述代碼沒問題,同時復雜了許多。

雖然實際項目中很難出現上面這樣的代碼,但如果放棄對腦殘的 CommonJS Wrapper 支持后,再寫 AMD 加載器就更加簡單可靠。例如雨夜帶刀同學寫的 seed,代碼十分簡潔;構建工具通常基于字符串分析,仍然需要過濾注釋,但可以采用 uglifyjs 壓縮等取巧的方法。

考慮到不是每個 AMD Loader 都支持 CommonJS Wrapper,用參數定義依賴也能保證更好的模塊通用性。至于「就近」定義依賴,我一直覺得可有可無,我們寫 php 或 python 時,include 和 import 都會放在頂部,這樣看代碼時能一目了然地看到所有依賴,修改起來也方便。

本文部分示例來自于 SeaJS 與 RequireJS 最大的區別,致謝!

專題「JavaScript 漫談」的其他文章 ?

  • 改進 ThinkJS 的異步編程方式 (May 15, 2015)
  • BOM 和 JavaScript 中的 trim (Dec 07, 2013)
  • FileSystem API 實現文件下載器 2 (Oct 01, 2013)
  • 用 FileSystem API 實現文件下載器 (Oct 01, 2013)
  • ES6 中的 Set、Map 和 WeakMap (Sep 23, 2013)
  • ES6 中的生成器函數介紹 (Sep 20, 2013)
  • 嘗試 ES6 中的箭頭函數 (Sep 11, 2013)
  • 使用 Canvas 繪制背景圖 (Aug 18, 2013)
  • 異步編程:When.js快速上手 (Jun 23, 2013)
  • JavaScript動畫漫談 (Nov 15, 2012)

所屬標簽

無標簽

25选5玩法中奖