JS模組化程式設計

南波發表於2019-03-10

前言

什麼是模組化?

模組就是實現特定功能的一組方法,而模組化是將模組的程式碼創造自己的作用域,只向外部暴露公開的方法和變數,而這些方法之間高度解耦。

寫 JS 為什麼需要模組化程式設計?
當寫前端還只是處理網頁的一些表單提交,點選互動的時候,還沒有強化 JS 模組化的概念,當前端邏輯開始複雜,互動變得更多,資料量越來越龐大時,前端對 JS 模組化程式設計的需求就越加強烈。

在很多場景中,我們需要考慮模組化:

  1. 團隊多人協作,需要引用別人的程式碼
  2. 專案交接,我們在閱讀和重構別人的程式碼
  3. 程式碼審查時,檢驗你的程式碼是否規範,是否存在問題
  4. 寫完程式碼,回顧自己寫的程式碼是否美觀:)
  5. 不同的環境,環境變數不同

基於以上場景,所以,當前 JS 模組化主要是這幾個目的:

  1. 程式碼複用性
  2. 功能程式碼鬆耦合
  3. 解決命名衝突
  4. 程式碼可維護性
  5. 程式碼可閱讀性

先給結論:JS 的模組化程式設計經歷了幾個階段:

  1. 名稱空間形式的程式碼封裝
  2. 通過立即執行函式(IIFE)建立的名稱空間
  3. 伺服器端執行時 Nodejs 的 CommonJS 規範
  4. 將模組化執行在瀏覽器端的 AMD/CMD 規範
  5. 相容 CMD 和 AMD 的 UMD 規範
  6. 通過語言標準支援的 ES Module

先給結論圖:

JS模組化程式設計

一、名稱空間

我們知道,在 ES6 之前,JS 是沒有塊作用域的,私有變數和方法的隔離主要靠函式作用域,公開變數和方法的隔離主要靠物件的屬性引用。

封裝函式

在 JS 還沒有模組化規範的時候,將一些通用的、底層的功能抽象出來,獨立成一個個函式來實現模組化: 比方寫一個 utils.js 工具函式檔案

//  utils.js
function add(x, y) {
    if(typeof x !== "number" || typeof y !== "number") return;
    return x + y;
}

function square(x) {
    if(typeof x !== "number") return;
    return x * x;
}

<script src="./utils.js"></script>
<script>
    add(2, 3);
    square(4);
</script>
複製程式碼

通過 js 函式檔案劃分的方式,此時的公開函式其實是掛載到了全域性物件 window 下,當在別人也想定義一個叫 add 函式,或者多個 js 檔案合併壓縮的時候,會存在命名衝突的問題。

掛載到全域性變數下:

後來我們想到通過掛載函式到全域性物件字面量下的方式,利用 JAVA 包的概念,希望減輕命名衝突的嚴重性。

var mathUtils1 = {
    add: function(x, y) {
        return x + y;
    },
}

var mathUtils2 = {
    add: function(x, y, z) {
        return x + y + z;
    },
}

mathUtils.add();

mathUtils.square();
複製程式碼

這種方式仍然建立了全域性變數,但如果包的路徑很長,那麼到最後引用方法可能就會以module1.subModule.subSubModule.add 的方式引用程式碼了。

IIFE
考慮模組存在私有變數,於是我們利用IIFE(立即執行表示式)建立閉包來封裝私有變數:

var module = (function(){
    var count = 0;
    return {
        inc: function(){
            count += 1;
        },
        dec: function(){
            count += -1;
        }
    }
})()

module.inc();
module.dec();
複製程式碼

這樣私有變數對於外部來說就是不可訪問的,那如果模組需要引入其他依賴呢?

var utils = (function ($) {
    var $body = $("body"); 
    var _private = 0;
    var foo = function() {
        ...
    }
    var bar = function () {
        ...
    }
    
    return {
        foo: foo,
        bar: bar
    }
})(jQuery);
複製程式碼

以上封裝模組的方式叫作:模組模式,在 jQuery 時代,大量使用了模組模式:

<script src="jquery.js"></script>
<script src="underscore.js"></script>
<script src="utils.js"></script>
<script src="base.js"></script>
<script src="main.js"></script>
複製程式碼

jQuery 的外掛必須在 JQuery.js 檔案之後 ,檔案的載入順序被嚴格限制住,依賴越多,依賴關係越混亂,越容易出錯。

二、CommonJS

Nodejs 的出現,讓 JavaScript 能夠執行在服務端環境中,此時迫切需要建立一個標準來實現統一的模組系統,也就是後來的 CommonJS。

// math.js
exports.add = function(x, y) {
    return x + y;
}

// base.js
var math = require("./math.js");
math.add(2, 3);  // 5

// 引用核心模組
var http = require('http');
http.createServer(...).listen(3000);
複製程式碼

CommonJS 規定每個模組內部,module 代表當前模組,這個模組是一個物件,有 id,filename, loaded,parent, children, exports 等屬性,module.exports 屬性表示當前模組對外輸出的介面,其他檔案載入該模組,實際上就是讀取 module.exports 變數。

// utils.js
// 直接賦值給 module.exports 變數
module.exports = function () {
    console.log("I'm utils.js module");
}

// base.js
var util = require("./utils.js")
util();  // I'm utils.js module

或者掛載到 module.exports 物件下
module.exports.say = function () {
    console.log("I'm utils.js module");
}

// base.js
var util = require("./utils.js")
util.say();
複製程式碼

為了方便,Node 為每個模組提供一個 exports 自由變數,指向 module.exports。這等同在每個模組頭部,有一行這樣的命令。

var exports = module.exports;
複製程式碼

exports 和 module.exports 共享了同個引用地址,如果直接對 exports 賦值會導致兩者不再指向同一個記憶體地址,但最終不會對 module.exports 起效。

// module.exports 可以直接賦值
module.exports = 'Hello world';  

// exports 不能直接賦值
exports = 'Hello world';
複製程式碼

CommonJS 總結:
CommonJS 規範載入模組是同步的,用於服務端,由於 CommonJS 會在啟動時把內建模組載入到記憶體中,也會把載入過的模組放在記憶體中。所以在 Node 環境中用同步載入的方式不會有很大問題。

另,CommonJS模組載入的是輸出值的拷貝。也就是說,外部模組輸出值變了,當前模組的匯入值不會發生變化。

三、AMD

CommonJS 規範的出現,使得 JS 模組化在 NodeJS 環境中得到了施展機會。但 CommonJS 如果應用在瀏覽器端,同步載入的機制會使得 JS 阻塞 UI 執行緒,造成頁面卡頓。

利用模組載入後執行回撥的機制,有了後面的 RequireJS 模組載入器, 由於載入機制不同,我們稱這種模組規範為 AMD(Asynchromous Module Definition 非同步模組定義)規範, 非同步模組定義誕生於使用 XHR + eval 的開發經驗,是 RequireJS 模組載入器對模組定義的規範化產出。

AMD 的模組寫法:

// 模組名 utils
// 依賴 jQuery, underscore
// 模組匯出 foo, bar 屬性
<script data-main="scripts/main" src="scripts/require.js"></script>

// main.js
require.config({
  baseUrl: "script",
  paths: {
    "jquery": "jquery.min",
    "underscore": "underscore.min",
  }
});

// 定義 utils 模組,使用 jQuery 模組
define("utils", ["jQuery", "underscore"], function($, _) {
    var body = $("body");
    var deepClone = _.deepClone({...});
    return {
        foo: "hello",
        bar: "world"
    }
})
</script>
複製程式碼

AMD 的特點在於:

  1. 延遲載入
  2. 依賴前置

AMD 支援相容 CommonJS 寫法:

define(function (require, exports, module){
  var someModule = require("someModule");
  var anotherModule = require("anotherModule");

  someModule.sayHi();
  anotherModule.sayBye();

  exports.asplode = function (){
    someModule.eat();
    anotherModule.play();
  };
});
複製程式碼

四、CMD

SeaJS 是國內 JS 大神玉伯開發的模組載入器,基於 SeaJS 的模組機制,所有 JavaScript 模組都遵循 CMD(Common Module Definition) 模組定義規範.

CMD 模組的寫法:

<script src="scripts/sea.js"></script>
<script>
// seajs 的簡單配置
seajs.config({
  base: "./script/",
  alias: {
    "jquery": "script/jquery/3.3.1/jquery.js"
  }
})

// 載入入口模組
seajs.use("./main")
</script>

// 定義模組
// utils.js
define(function(require, exports, module) {
  exports.each = function (arr) {
    // 實現程式碼 
  };

  exports.log = function (str) {
    // 實現程式碼
  };
});

// 輸出模組
define(function(require, exports, module) {
  var util = require('./util.js');
  
  var a = require('./a'); //在需要時申明,依賴就近
  a.doSomething();
  
  exports.init = function() {
    // 實現程式碼
    util.log();
  };
});
複製程式碼

CMD 和 AMD 規範的區別:
AMD推崇依賴前置,CMD推崇依賴就近:
AMD 的依賴需要提前定義,載入完後就會執行。 CMD 依賴可以就近書寫,只有在用到某個模組的時候再去執行相應模組。 舉個例子:

// main.js
define(function(require, exports, module) {
  console.log("I'm main");
  var mod1 = require("./mod1");
  mod1.say();
  var mod2 = require("./mod2");
  mod2.say();

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

// mod1.js
define(function() {
  console.log("I'm mod1");
  return {
    say: function() {
      console.log("say: I'm mod1");
    }
  };
});

// mod2.js
define(function() {
  console.log("I'm mod2");
  return {
    say: function() {
      console.log("say: I'm mod2");
    }
  };
});

複製程式碼

以上程式碼分別用 Require.js 和 Sea.js 執行,列印結果如下:
Require.js:
先執行所有依賴中的程式碼

I'm mod1
I'm mod2
I'm main
say: I'm mod1
say: I'm mod2

複製程式碼

Sea.js:
用到依賴時,再執行依賴中的程式碼

I'm main

I'm mod1
say: I'm mod1
I'm mod2
say: I'm mod2
複製程式碼

五、UMD

umd(Universal Module Definition) 是 AMD 和 CommonJS 的相容性處理,提出了跨平臺的解決方案。

(function (root, factory) {
    if (typeof exports === 'object') {
        // commonJS
        module.exports = factory();
    } else if (typeof define === 'function' && define.amd) {
        // AMD
        define(factory);
    } else {
        // 掛載到全域性
        root.eventUtil = factory();
    }
})(this, function () {
    function myFunc(){};

    return {
        foo: myFunc
    };
});
複製程式碼

應用 UMD 規範的 JS 檔案其實就是一個立即執行函式,通過檢驗 JS 環境是否支援 CommonJS 或 AMD 再進行模組化定義。

六、ES6 Module

CommonJS 和 AMD 規範都只能在執行時確定依賴。而 ES6 在語言層面提出了模組化方案, ES6 module 模組編譯時就能確定模組的依賴關係,以及輸入和輸出的變數。ES6 模組化這種載入稱為“編譯時載入”或者靜態載入。

JS模組化程式設計
寫法:

// math.js
// 命名匯出
export function add(a, b){
    return a + b;
}
export function sub(a, b){
    return a - b;
}
// 命名匯入
import { add, sub } from "./math.js";
add(2, 3);
sub(7, 2);

// 預設匯出
export default function foo() {
  console.log('foo');
}
// 預設匯入
import someModule from "./utils.js";
複製程式碼

ES6 模組的執行機制與 CommonJS 不一樣。JS 引擎對指令碼靜態分析的時候,遇到模組載入命令import,就會生成一個只讀引用。等到指令碼真正執行時,再根據這個只讀引用,到被載入的那個模組裡面去取值。原始值變了,import載入的值也會跟著變。因此,ES6 模組是動態引用,並且不會快取值,模組裡面的變數繫結其所在的模組。

另,在 webpack 對 ES Module 打包, ES Module 會編譯成 require/exports 來執行的。

總結

JS 的模組化規範經過了模組模式、CommonJS、AMD/CMD、ES6 的演進,利用現在常用的 gulp、webpack 打包工具,非常方便我們編寫模組化程式碼。掌握這幾種模組化規範的區別和聯絡有助於提高程式碼的模組化質量,比如,CommonJS 輸出的是值拷貝,ES6 Module 在靜態程式碼解析時輸出只讀介面,AMD 是非同步載入,推崇依賴前置,CMD 是依賴就近,延遲執行,在使用到模組時才去載入相應的依賴。

@Starbucks 2019/03/10

相關文章