JavaScript 模組化解析

迅雷前端發表於2018-10-22

作者: zhijs from 迅雷前端

原文地址:JavaScript 模組化解析

隨著 JavasScript 語言逐漸發展,JavaScript 應用從簡單的表單驗證,到複雜的網站互動,再到服務端,移動端,PC 客戶端的語言支援。JavaScript 應用領域變的越來越廣泛,工程程式碼變得越來越龐大,程式碼的管理變得越來越困難,於是乎 JavaScript 模組化方案在社群中應聲而起,其中一些優秀的模組化方案,逐漸成為 JavaScript 的語言規範,下面我們就 JavaScript 模組化這個話題展開討論,本文的主要包含以幾部分內容。

  • 什麼是模組
  • 為什麼需要模組化
  • JavaScript 模組化之 CommonJS
  • JavaScript 模組化之 AMD
  • JavaScript 模組化之 CMD
  • JavaScript 模組化之 ES Module
  • 總結

什麼是模組

模組,又稱構件,是能夠單獨命名並獨立地完成一定功能的程式語句的集合 (即程式程式碼和資料結構的集合體)。它具有兩個基本的特徵:外部特徵和內部特徵。外部特徵是指模組跟外部環境聯絡的介面 (即其他模組或程式呼叫該模組的方式,包括有輸入輸出引數、引用的全域性變數) 和模組的功能,內部特徵是指模組的內部環境具有的特點 (即該模組的區域性資料和程式程式碼)。簡而言之,模組就是一個具有獨立作用域,對外暴露特定功能介面的程式碼集合。

為什麼需要模組化

首先讓我們回到過去,看看原始 JavaScript 模組檔案的寫法。

// add.js
function add(a, b) {
  return a + b;
}
// decrease.js
function decrease(a, b) {
  return a - b;
}

// formula.js
function square_difference(a, b) {
  return add(a, b) * decrease(a, b);
}
複製程式碼

上面我們在三個 JavaScript 檔案裡面,實現了幾個功能函式。其中,第三個功能函式需要依賴第一個和第二個 JavaScript 檔案的功能函式,所以我們在使用的時候,一般會這樣寫:

<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
    <script src="add.js"></script>
    <script src="decrease.js"></script>
    <script src="formula.js"></script>
    <!--使用-->
    <script>
       var result = square_difference(3, 4);
    </script>
</body>
</html>
複製程式碼

這樣的管理方式會造成以下幾個問題:

  • 模組的引入順序可能會出錯
  • 會汙染全域性變數
  • 模組之間的依賴關係不明顯

基於上述的原因,就有了對上述問題的解決方案,即是 JavaScript 模組化規範,目前主流的有 CommonJS,AMD,CMD,ES6 Module 這四種規範。

Javascript 模組化之 CommonJS

CommonJS 規範的主要內容有,一個單獨的檔案就是一個模組。每一個模組都是一個單獨的作用域,模組必須通過 module.exports 匯出對外的變數或介面,通過 require() 來匯入其他模組的輸出到當前模組作用域中,下面講述一下 NodeJs 中 CommonJS 的模組化機制。

使用方式

// 模組定義 add.js
module.eports.add = function(a, b) {
  return a + b;
};

// 模組定義 decrease.js
module.exports.decrease = function(a, b) {
  return a - b;
};

// formula.js,模組使用,利用 require() 方法載入模組,require 匯出的即是 module.exports 的內容
const add = require("./add.js").add;
const decrease = require("./decrease.js").decrease;
module.exports.square_difference = function(a, b) {
  return add(a, b) * decrease(a, b);
};
複製程式碼

exports 和 module.exports

exports 和 module.exports 是指向同一個東西的變數,即是 module.exports = exports = {},所以你也可以這樣匯出模組

//add.js
exports.add = function(a, b) {
  return a + b;
};
複製程式碼

但是如果直接修改 exports 的指向是無效的,例如:

// add.js
exports = function(a, b) {
  return a + b;
};
// main.js
var add = require("./add.js");
複製程式碼

此時得到的 add 是一個空物件,因為 require 匯入的是,對應模組的 module.exports 的內容,在上面的程式碼中,雖然一開始 exports = module.exports,但是當執行如下程式碼的時候,其實就將 exports 指向了 function,而 module.exports 的內容並沒有改變,所以這個模組的匯出為空物件。

exports = function(a, b) {
  return a + b;
};
複製程式碼

CommonJS 在 NodeJs 中的模組載入機制

以下根據 NodeJs 中 CommonJS 模組載入原始碼 來分析 NodeJS 中模組的載入機制。

在 NodeJs 中引入模組 (require),需要經歷如下 3 個步驟:

  1. 路徑分析
  2. 檔案定位
  3. 編譯執行

與前端瀏覽器會快取靜態指令碼檔案以提高效能一樣,NodeJs 對引入過的模組都會進行快取,以減少二次引入時的開銷。不同的是,瀏覽器僅快取檔案,而在 NodeJs 中快取的是編譯和執行後的物件。

路徑分析 + 檔案定位

其流程如下圖所示:

JavaScript 模組化解析

模組編譯

在定位到檔案後,首先會檢查該檔案是否有快取,有的話直接讀取快取,否則,會新建立一個 Module 物件,其定義如下:

function Module(id, parent) {
  this.id = id; // 模組的識別符,通常是帶有絕對路徑的模組檔名。
  this.exports = {}; // 表示模組對外輸出的值
  this.parent = parent; // 返回一個物件,表示呼叫該模組的模組。
  if (parent && parent.children) {
    this.parent.children.push(this);
  }
  this.filename = null;
  this.loaded = false; // 返回一個布林值,表示模組是否已經完成載入。
  this.childrent = []; // 返回一個陣列,表示該模組要用到的其他模組。
}
複製程式碼

require 操作程式碼如下所示:

Module.prototype.require = function(id) {
  // 檢查模組識別符號
  if (typeof id !== "string") {
    throw new ERR_INVALID_ARG_TYPE("id", "string", id);
  }
  if (id === "") {
    throw new ERR_INVALID_ARG_VALUE("id", id, "must be a non-empty string");
  }
  // 呼叫模組載入方法
  return Module._load(id, this, /* isMain */ false);
};
複製程式碼

接下來是解析模組路徑,判斷是否有快取,然後生成 Module 物件:

Module._load = function(request, parent, isMain) {
  if (parent) {
    debug("Module._load REQUEST %s parent: %s", request, parent.id);
  }

  // 解析檔名
  var filename = Module._resolveFilename(request, parent, isMain);

  var cachedModule = Module._cache[filename];

  // 判斷是否有快取,有的話返回快取物件的 exports
  if (cachedModule) {
    updateChildren(parent, cachedModule, true);
    return cachedModule.exports;
  }

  // 判斷是否為原生核心模組,是的話從記憶體載入
  if (NativeModule.nonInternalExists(filename)) {
    debug("load native module %s", request);
    return NativeModule.require(filename);
  }

  // 生成模組物件
  var module = new Module(filename, parent);

  if (isMain) {
    process.mainModule = module;
    module.id = ".";
  }

  // 快取模組物件
  Module._cache[filename] = module;

  // 載入模組
  tryModuleLoad(module, filename);

  return module.exports;
};
複製程式碼

tryModuleLoad 的程式碼如下所示:

function tryModuleLoad(module, filename) {
  var threw = true;
  try {
    // 呼叫模組例項load方法
    module.load(filename);
    threw = false;
  } finally {
    if (threw) {
      // 如果載入出錯,則刪除快取
      delete Module._cache[filename];
    }
  }
}
複製程式碼

模組物件執行載入操作 module.load 程式碼如下所示:

Module.prototype.load = function(filename) {
  debug("load %j for module %j", filename, this.id);

  assert(!this.loaded);
  this.filename = filename;

  // 解析路徑
  this.paths = Module._nodeModulePaths(path.dirname(filename));

  // 判斷副檔名,並且預設為 .js 擴充套件
  var extension = path.extname(filename) || ".js";

  // 判斷是否有對應格式檔案的處理函式, 沒有的話,副檔名改為 .js
  if (!Module._extensions[extension]) extension = ".js";

  // 呼叫相應的檔案處理方法,並傳入模組物件
  Module._extensions[extension](this, filename);
  this.loaded = true;

  // 處理 ES Module
  if (experimentalModules) {
    if (asyncESM === undefined) lazyLoadESM();
    const ESMLoader = asyncESM.ESMLoader;
    const url = pathToFileURL(filename);
    const urlString = `${url}`;
    const exports = this.exports;
    if (ESMLoader.moduleMap.has(urlString) !== true) {
      ESMLoader.moduleMap.set(
        urlString,
        new ModuleJob(ESMLoader, url, async () => {
          const ctx = createDynamicModule(["default"], url);
          ctx.reflect.exports.default.set(exports);
          return ctx;
        })
      );
    } else {
      const job = ESMLoader.moduleMap.get(urlString);
      if (job.reflect) job.reflect.exports.default.set(exports);
    }
  }
};
複製程式碼

在這裡同步讀取模組,再執行編譯操作:

Module._extensions[".js"] = function(module, filename) {
  // 同步讀取檔案
  var content = fs.readFileSync(filename, "utf8");

  // 編譯程式碼
  module._compile(stripBOM(content), filename);
};
複製程式碼

編譯過程主要做了以下的操作:

  1. 將 JavaScript 程式碼用函式體包裝,隔離作用域,例如:
exports.add = (function(a, b) {
  return a + b;
}
複製程式碼

會被轉換為

(
  function(exports, require, modules, __filename, __dirname) {
    exports.add = function(a, b) {
      return a + b;
    };
  }
);
複製程式碼
  1. 執行函式,注入模組物件的 exports 屬性,require 全域性方法,以及物件例項,__filename, __dirname,然後執行模組的原始碼。

  2. 返回模組物件 exports 屬性。

JavaScript 模組化之 AMD

AMD, Asynchronous Module Definition,即非同步模組載入機制,它採用非同步方式載入模組,模組的載入不影響它後面語句的執行。所有依賴這個模組的語句都定義在一個回撥函式中,等到依賴載入完成之後,這個回撥函式才會執行。

AMD 的誕生,就是為了解決這兩個問題:

  1. 實現 JavaScript 檔案的非同步載入,避免網頁失去響應
  2. 管理模組之間的依賴性,便於程式碼的編寫和維護
 // 模組定義
 define(id?: String, dependencies?: String[], factory: Function|Object);
複製程式碼

id 是模組的名字,它是可選的引數。

dependencies 指定了所要依賴的模組列表,它是一個陣列,也是可選的引數。每個依賴的模組的輸出都將作為引數一次傳入 factory 中。如果沒有指定 dependencies,那麼它的預設值是 ["require", "exports", "module"]。

factory 是最後一個引數,它包裹了模組的具體實現,它是一個函式或者物件。如果是函式,那麼它的返回值就是模組的輸出介面或值,如果是物件,此物件應該為模組的輸出值。

舉個例子:

// 模組定義,add.js
define(function() {
  let add = function(a, b) {
    return a + b;
  };
  return add;
});

// 模組定義,decrease.js
define(function() {
  let decrease = function(a, b) {
    return a - b;
  };
  return decrease;
});

// 模組定義,square.js
define(["./add", "./decrease"], function(add, decrease) {
  let square = function(a, b) {
    return add(a, b) * decrease(a, b);
  };
  return square;
});

// 模組使用,主入口檔案 main.js
require(["square"], function(math) {
  console.log(square(6, 3));
});
複製程式碼

這裡用實現了 AMD 規範的 RequireJS 來分析,RequireJS 原始碼較為複雜,這裡只對非同步模組載入原理做一個分析。在載入模組的過程中, RequireJS 會呼叫如下函式:

/**
 *
 * @param {Object} context the require context to find state.
 * @param {String} moduleName the name of the module.
 * @param {Object} url the URL to the module.
 */
req.load = function(context, moduleName, url) {
  var config = (context && context.config) || {},
    node;
  // 判斷是否為瀏覽器
  if (isBrowser) {
    // 根據模組名稱和 url 建立一個 Script 標籤
    node = req.createNode(config, moduleName, url);

    node.setAttribute("data-requirecontext", context.contextName);
    node.setAttribute("data-requiremodule", moduleName);

    // 對不同的瀏覽器 Script 標籤事件監聽做相容處理
    if (
      node.attachEvent &&
      !(
        node.attachEvent.toString &&
        node.attachEvent.toString().indexOf("[native code") < 0
      ) &&
      !isOpera
    ) {
      useInteractive = true;

      node.attachEvent("onreadystatechange", context.onScriptLoad);
    } else {
      node.addEventListener("load", context.onScriptLoad, false);
      node.addEventListener("error", context.onScriptError, false);
    }

    // 設定 Script 標籤的 src 屬性為模組路徑
    node.src = url;

    if (config.onNodeCreated) {
      config.onNodeCreated(node, config, moduleName, url);
    }

    currentlyAddingScript = node;

    // 將 Script 標籤插入到頁面中
    if (baseElement) {
      head.insertBefore(node, baseElement);
    } else {
      head.appendChild(node);
    }
    currentlyAddingScript = null;

    return node;
  } else if (isWebWorker) {
    try {
      //In a web worker, use importScripts. This is not a very
      //efficient use of importScripts, importScripts will block until
      //its script is downloaded and evaluated. However, if web workers
      //are in play, the expectation is that a build has been done so
      //that only one script needs to be loaded anyway. This may need
      //to be reevaluated if other use cases become common.

      // Post a task to the event loop to work around a bug in WebKit
      // where the worker gets garbage-collected after calling
      // importScripts(): https://webkit.org/b/153317
      setTimeout(function() {}, 0);
      importScripts(url);

      //Account for anonymous modules
      context.completeLoad(moduleName);
    } catch (e) {
      context.onError(
        makeError(
          "importscripts",
          "importScripts failed for " + moduleName + " at " + url,
          e,
          [moduleName]
        )
      );
    }
  }
};

// 建立非同步 Script 標籤
req.createNode = function(config, moduleName, url) {
  var node = config.xhtml
    ? document.createElementNS("http://www.w3.org/1999/xhtml", "html:script")
    : document.createElement("script");
  node.type = config.scriptType || "text/javascript";
  node.charset = "utf-8";
  node.async = true;
  return node;
};
複製程式碼

可以看出,這裡主要是根據模組的 Url,建立了一個非同步的 Script 標籤,並將模組 id 名稱新增到的標籤的 data-requiremodule 上,再將這個 Script 標籤新增到了 html 頁面中。同時為 Script 標籤的 load 事件新增了處理函式,當該模組檔案被載入完畢的時候,就會觸發 context.onScriptLoad。我們在 onScriptLoad 新增斷點,可以看到頁面結構如下圖所示:

JavaScript 模組化解析

由圖可以看到,Html 中新增了一個 Script 標籤,這也就是非同步載入模組的原理。

JavaScript 模組化之 CMD

CMD (Common Module Definition) 通用模組定義,CMD 在瀏覽器端的實現有 SeaJS, 和 RequireJS 一樣,SeaJS 載入原理也是動態建立非同步 Script 標籤。二者的區別主要是依賴寫法上不同,AMD 推崇一開始就載入所有的依賴,而 CMD 則推崇在需要用的地方才進行依賴載入。

// ADM 在執行以下程式碼的時候,RequireJS 會首先分析依賴陣列,然後依次載入,直到所有載入完畢再執行回到函式
define(["add", "decrease"], function(add, decrease) {
  let result1 = add(9, 7);
  let result2 = decrease(9, 7);
  console.log(result1 * result2);
});

// CMD 在執行以下程式碼的時候, SeaJS 會首先用正則匹配出程式碼裡面所有的 require 語句,拿到依賴,然後依次載入,載入完成再執行回撥函式
define(function(require) {
  let add = require("add");
  let result1 = add(9, 7);
  let add = require("decrease");
  let result2 = decrease(9, 7);
  console.log(result1 * result2);
});
複製程式碼

JavaScript 模組化之 ES Module

ES Module 是在 ECMAScript 6 中引入的模組化功能。模組功能主要由兩個命令構成,分別是 export 和 import。export 命令用於規定模組的對外介面,import 命令用於輸入其他模組提供的功能。

其使用方式如下:

// 模組定義 add.js
export function add(a, b) {
  return a + b;
}

// 模組使用 main.js
import { add } from "./add.js";
console.log(add(1, 2)); // 3
複製程式碼

下面講述幾個較為重要的點。

export 和 export default

在一個檔案或模組中,export 可以有多個,export default 僅有一個, export 類似於具名匯出,而 default 類似於匯出一個變數名為 default 的變數。同時在 import 的時候,對於 export 的變數,必須要用具名的物件去承接,而對於 default,則可以任意指定變數名,例如:

// a.js
 export var a = 2;
 export var b = 3 ;
// main.js 在匯出的時候必須要用具名變數 a, b 且以解構的方式得到匯出變數
import {a, b} from 'a.js' // √ a= 2, b = 3
import a from 'a.js' // x

// b.js export default 方式
const a = 3
export default a // 注意不能 export default const a = 3 ,因為這裡 default 就相當於一個變數名

// 匯出
import b form 'b.js' // √
import c form 'b.js' // √ 因為 b 模組匯出的是 default,對於匯出的default,可以用任意變數去承接
複製程式碼

ES Module 模組載入和匯出過程

以如下程式碼為例子:

 // counter.js
 export let count = 5

 // display.js
 export function render() {
   console.log('render')
 }
 // main.js
 import { counter } from './counter.js';
 import { render } from './display.js'
 ......// more code
複製程式碼

在模組載入模組的過程中,主要經歷以下幾個步驟:

構建 (Construction)

這個過程執行查詢,下載,並將檔案轉化為模組記錄 (Module record)。所謂的模組記錄是指一個記錄了對應模組的語法樹,依賴資訊,以及各種屬性和方法 (這裡不是很明白)。同樣也是在這個過程對模組記錄進行了快取的操作,下圖是一個模組記錄表:

JavaScript 模組化解析

下圖是快取記錄表:

JavaScript 模組化解析

例項化 (Instantiation)

這個過程會在記憶體中開闢一個儲存空間 (此時還沒有填充值),然後將該模組所有的 export 和 import 了該模組的變數指向這個記憶體,這個過程叫做連結。其寫入 export 示意圖如下所示:

JavaScript 模組化解析

然後是連結 import,其示意圖如下所示:

JavaScript 模組化解析

賦值(Evaluation)

這個過程會執行模組程式碼,並用真實的值填充上一階段開闢的記憶體空間,此過程後 import 連結到的值就是 export 匯出的真實值。

根據上面的過程我們可以知道。ES Module 模組 export 和 import 其實指向的是同一塊記憶體,但有一個點需要注意的是,import 處不能對這塊記憶體的值進行修改,而 export 可以,其示意圖如下:

JavaScript 模組化解析

總結

本文主要對目前主流的 JavaScript 模組化方案 CommonJs,AMD,CMD, ES Module 進行了學習和了解,並對其中最有代表性的模組化實現 (NodeJs,RequireJS,SeaJS,ES6) 做了一個簡單的分析。對於服務端的模組而言,由於其模組都是儲存在本地的,模組載入方便,所以通常是採用同步讀取檔案的方式進行模組載入。而對於瀏覽器而言,其模組一般是儲存在遠端網路上的,模組的下載是一個十分耗時的過程,所以通常是採用動態非同步指令碼載入的方式載入模組檔案。另外,無論是客戶端還是服務端的 JavaScript 模組化實現,都會對模組進行快取,以此減少二次載入的開銷。

參考文章: ES modules: A cartoon deep-dive

掃一掃關注迅雷前端公眾號

JavaScript 模組化解析

相關文章