關於 JS 模組化的最佳實踐總結

石橋碼農發表於2019-01-21

 

模組化開發是 JS 專案開發中的必備技能,它如同物件導向、設計模式一樣,可以兼顧提升軟體專案的可維護性和開發效率。

 

模組之間通常以全域性物件維繫通訊。在小遊戲中,GameGlobal 是全域性物件。在小程式中,App 是全域性物件,任何頁面都可以使用 getApp() 獲取這個全域性物件。在 NodeJS 中,global 是全域性物件。在傳統瀏覽器宿主中,window 是全域性物件。

 

以下是作者總結的模組化實踐經驗。簡言之,除了在瀏覽器專案中使用 sea.js,其它型別專案均建議直接使用原生的 ES6 模組規範。

 

目錄

  1. CommonJS 規範
  2. AMD 規範
  3. CMD 規範
  4. ES6 模組規範
  5. 結論

 

CommonJS 規範

 

CommonJS 規範最早在 NodeJS 中實踐並被推廣開來。它使用 module.exports 輸出模組,一個模組寫在一個獨立的檔案內,一個檔案即是一個模組。在另一個JS檔案中,使用 require 匯入模組。各個模組相互隔離,模組之間的通訊,通過全域性物件 global 完成。

 

值得特別注意的是,CommonJS 這種規範天生是為 NodeJS 服務的。NodeJS 是一種伺服器端程式語言,原始碼檔案都在硬碟上,讀起來很方便。CommonJS 規範作為一種同步方案,後續程式碼必須等待前面的require指令載入模組完成。

 

使用 CommonJS 規範的程式碼示例如下:

 

// 定義模組math.js
var basicNum = 0;
function add(a, b) {
  return a + b;
}
module.exports = { //在這裡寫上需要向外暴露的函式、變數
  add: add,
  basicNum: basicNum
}
// 在另一個檔案中,引用自定義的模組時,引數包含路徑,可省略字尾.js
var math = require('./math');
math.add(2, 5);

 

在小程式與小遊戲的官方文件中,提到模組化時官方建議的規範即是 CommonJS 規範。但其實在作者看來,更適合小遊戲/小程式開發的規範是 ES6 模組規範,原因稍後便會講到。

 

AMD 規範

 

CommonJS 規範主要是為伺服器端的 NodeJS 服務,伺服器端載入模組檔案無延時,但是在瀏覽器上就大不相同了。AMD 即是為了在瀏覽器宿主環境中實現模組化方案的規範之一。

 

AMD是一種使用JS語言自實現的模組化規範方案,主要由require.config()、define()、require 三個函式實現。require.config() 用於宣告基本路徑和模組名稱;define() 用於定義模組物件;require() 則用於載入模組並使用。

 

與 CommonJS 規範不同,AMD 規範身處瀏覽器環境之中,是一種非同步模組載入規範。在使用時,首先要載入模組化規範實現檔案 require.js 及 JS 主檔案,示例如下:

 

/** 網頁中引入require.js及main.js **/
<script src="js/require.js" data-main="js/main"></script>

 

在上面的 Html 程式碼中,"js/require.js" 是實現 AMD 規範的類庫檔案,是任何使用 AMD 規範的網頁都需要載入的;"js/main" 是開發者的程式碼主檔案,在這個檔案中載入並使用自定義模組,示例程式碼如下:

 

/** main.js 入口檔案/主模組 **/
// 首先用config()指定各模組路徑和引用名
require.config({
  baseUrl: "js/lib",
  paths: {
    "jquery": "jquery.min",  //實際路徑為js/lib/jquery.min.js
    "underscore": "underscore.min",
  }
});
// 執行基本操作
require(["jquery","underscore","math"],function($,_,math){//在這裡$代表jqurey、_代表underscore
  var sum = math.add(10,20);
  $("#sum").html(sum);
});

 

而用於模組的定義,在其它 JS 檔案中是這樣宣告的:

 

// 定義math.js模組
define(function () {
    var basicNum = 0;
    var add = function (x, y) {
        return x + y;
    };
    return {
        add: add,
        basicNum :basicNum
    };
});

 

如果在一個模組定義中依賴另一個模組物件,可以這樣宣告:

 

// 定義一個依賴underscore模組的模組
define(['underscore'],function(_){
  var classify = function(list){
    _.countBy(list,function(num){
      return num > 30 ? 'old' : 'young';
    })
  };
  return {
    classify :classify
  };
})

 

AMD 規範看起來完美解決了瀏覽器模組化開發的難題。但是它有一個天生的缺陷,對於依賴的模組無論實際需要與否,都會先載入並執行。如下所示:

 

define(["a", "b", "c", "d", "e", "f"], function(a, b, c, d, e, f) { 
    // 等於在最前面宣告並初始化了要用到的所有模組
    if (false) {
      // 即便沒用到某個模組 b,但 b 還是提前執行了
      b.foo()
    } 
});

 

在上面的程式碼中,模組 a、b、c、d、e、f 都會載入並執行,即使它們在實際的模組程式碼中沒有被用到。為了解決這個“浪費”的問題,CMD 規範應運而生。

 

CMD 規範

 

CMD 規範單從名字來看,它也與 AMD 規範很像。CMD 與 AMD 規範一樣,同樣是一種 JS 語言自實現的模組化方案。不同之處在於,AMD 規範是依賴前置、模組提前載入並執行;CMD 是依賴後置、模組懶惰載入再執行。示例程式碼如下:

 

/** CMD寫法 **/
define(function(require, exports, module) {
    var a = require('./a'),
     b = require('./b'),
     c = require('./c'); //在需要時申明、載入和使用
    a.doSomething();
    if (false) {
        var b = require('./b');
        b.doSomething();
    }
});

 

在上面的程式碼中,模組 a 在使用時才被宣告並載入。sea.js 是一個模組載入器,是 AMD 規範的主要實現者之一。使用 sea.js 定義和使用模組的示例如下所示:

 

/** sea.js **/
// 定義模組 math.js
define(function(require, exports, module) {
    var $ = require('jquery.js');
    var add = function(a,b){
        return a+b;
    }
    exports.add = add;
});
// 載入模組
seajs.use(['math.js'], function(math){
    var sum = math.add(1+2);
});

 

與 AMD 相比,CMD 貌似確實節省了無謂的模組載入。但是 AMD 規範本身就是一種非同步模組載入方案,是隻有在執行時才被載入並執行的,用則載入,不用不載入,有何浪費可言?況且,比起在程式碼中分別以 require 函式載入模組,直接在 define 方法的第一個引數中宣告,似乎還更簡潔與瀟灑些。

 

sea.js 作為 AMD 規範的升級版,簡化了使用方法,在使用上更加方便,值得推崇。但是 sea.js 便是瀏覽器開發中最佳的模組化解決方案嗎?未必,還要看是什麼型別的專案,後面會講到。

 

ES6 模組規範

 

在講 ES6 模組規範之前,我們先看一下規範前驅 CommonJS 的一個缺陷。如下所示:

 

// 模組定義程式碼:lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};
// 模組使用程式碼:main.js
var mod = require('./lib');
console.log(mod.counter);  // 3
mod.incCounter();
console.log(mod.counter); // 3
var mod2 = require('./lib');
console.log(mod2.counter);  // 3

 

在上面的程式碼中,為什麼三個 mod.counter 的輸出均是3?

 

CommonJS 規範是一種動態載入、拷貝值物件執行的模組規範。每個模組在被使用時,都是在執行時被動態拉取並被拷貝使用的,模組定義是唯一的,但有幾處引用便有幾處拷貝。所以,對於不同的 require 呼叫,生成的是不同的執行時物件。

 

即使如此,在上面的程式碼中,mod 只有一個,為什麼 mod.incCounter() 對這個模組物件——即 mod 中的 counter 變數改變無效?相反,對於以下的程式碼:

 

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  get counter() {
    return counter
  },
  incCounter: incCounter,
};
// main.js
var mod = require('./lib');
console.log(mod.counter);  // 3
mod.incCounter();
console.log(mod.counter); // 4

 

第二個輸出是4。只是將 counter 宣告為一個 getter 存取器屬性,呼叫便正常了,為什麼?

 

這是由於 CommonJS 的拷貝機制造成的。由於 CommonJS 規範的拷貝執行機制,在 lib.js 中使用 module.exports 輸出的物件,是從 lib 模組內拷貝而得,當時 counter 的值是幾,便拷貝了幾。無論執行 incCounter 多少次,改變的都不是輸出物件的 counter 變數。

 

而當定義了 getter 屬性之後,該屬性指向了模組定義物件中的 counter 變數了嗎?不,是指向了被 incCounter 方法以閉包形式囊括的 counter 變數,這個變數是輸出的模組物件的一部分。

 

CommonJS 規範的這個缺陷,有時候讓程式很無奈,一不小心就寫出了錯誤的程式碼。這個缺陷在 ES6 中得到了很好的解決。

 

在 ES6 模組規範中,只有 export 與 import 兩個關鍵字。示例如下:

 

/** 定義模組 math.js **/
var basicNum = 0;
var add = function (a, b) {
    return a + b;
};
export { basicNum, add };
/** 引用模組 **/
import { basicNum, add } from './math';
function test(ele) {
    ele.textContent = add(99 + basicNum);
}

 

在上面的程式碼中,使用 export 關鍵字在 math.js 檔案中輸出模組,這裡使用了物件字面量的屬性名稱簡寫與方法名稱簡寫。在另一個檔案中引用模組,在 import 關鍵字後面,{basicNum, add} 這是物件變數析構的寫法。

 

如果在 export 模組時,使用了 default 限定詞,如下所示:

 

//定義輸出
export default { basicNum, add };
//引入
import math from './math';
function test(ele) {
    ele.textContent = math.add(99 + math.basicNum);
}

 

在 import 引入時,便可以省去花括號。這樣看起來程式碼更清爽簡潔。

 

ES6 模組規範與 CommonJS 規範相比,有以下不同:

 

(1)ES6 模組規範是解析(是解析不是編譯)時靜態載入、執行時動態引用,所有引用出去的模組物件均指向同一個模組物件。在上面使用 CommonJS 規範宣告的 lib 模組,如果使用 ES6 模組規範宣告,根本不會出現 counter 變數含糊不清的問題。

 

(2)CommonJS 規範是執行時動態載入、拷貝值物件使用。每一個引用出去的模組物件,都是一個獨立的物件。

 

結論

 

所以綜上所述,在模組化方案上最佳選擇是什麼?

 

在小程式(包括小遊戲)開發專案中,由於支援 ES6,所以小程式最好的模組化方案便是使用ES6模組規範。雖然官方文件中提到的模組化規範是 CommonJS,但最佳方案作者認為卻應該是 ES6。

 

小程式在手機端(無論 iOS 還是 Android)的底層渲染核心都是類 Chrome v8 引擎。v8 引擎在執行JS程式碼時,是將程式碼先以 MacroAssembler 彙編庫在記憶體中先編譯成機器碼再送往 CPU 執行的,並不是像其它 JS 引擎那樣解析一行執行一行。所以,靜態載入的 ES6 模組規範,更有助於 v8 引擎發揮價值。而執行時載入的 CommonJS 規範、AMD 規範、CMD 規範等,均不利於 v8 引擎施展拳腳。遇到 CommonJS 程式碼,v8 可能會怒罵:“有什麼話能不能一次講完,你這樣貓拉屎式的做法只能讓我更慢!”

 

在 NodeJS 開發專案中,Node9 已經支援 ES6 語法,完全可以使用 ES6 模組規範。NodeJS 的誕生,本身就基於 Google 的 v8 引擎,沒有理由不考慮發揮 v8 的最大潛能。

 

在瀏覽器 JS 開發專案中,因為從伺服器載入檔案需要時間,使用 CommonJS 規範肯定是不合適了。至於是使用原生的 ES 模組規範,還是使用sea.js,要看具體場景。如果想頁面儘快載入,sea.js 適合;如果是單頁面網站,適合使用原生的 ES6 模組規範。還有一點,瀏覽器並非只有 Chrome 一家,對於沒有使用 v8 引擎的瀏覽器,使用 ES6 原生規範的優勢就又減少了一點。

 

2019年1月21日於北京

 


 

參考資料

  • 瀏覽器已原生支援 ES 模組,這對前端開發來說意味著什麼?
  • Node 9下import/export的絲般順滑使用
  • Sea.js 是什麼?
  • 前端模組化:CommonJS,AMD,CMD,ES6
  • Module 的載入實現

 

本文首先於微信公眾號「藝述思維」:關於 JS 模組化的最佳實踐總結

 

相關文章