JavaScript 模塊化解析

做者: zhijs from 迅雷前端javascript

原文地址:JavaScript 模塊化解析 html

隨着 JavasScript 語言逐漸發展,JavaScript 應用從簡單的表單驗證,到複雜的網站交互,再到服務端,移動端,PC 客戶端的語言支持。JavaScript 應用領域變的愈來愈普遍,工程代碼變得愈來愈龐大,代碼的管理變得愈來愈困難,因而乎 JavaScript 模塊化方案在社區中應聲而起,其中一些優秀的模塊化方案,逐漸成爲 JavaScript 的語言規範,下面咱們就 JavaScript 模塊化這個話題展開討論,本文的主要包含以幾部份內容。前端

  • 什麼是模塊
  • 爲何須要模塊化
  • JavaScript 模塊化之 CommonJS
  • JavaScript 模塊化之 AMD
  • JavaScript 模塊化之 CMD
  • JavaScript 模塊化之 ES Module
  • 總結

什麼是模塊

模塊,又稱構件,是可以單獨命名並獨立地完成必定功能的程序語句的集合 (即程序代碼和數據結構的集合體)。它具備兩個基本的特徵:外部特徵和內部特徵。外部特徵是指模塊跟外部環境聯繫的接口 (即其餘模塊或程序調用該模塊的方式,包括有輸入輸出參數、引用的全局變量) 和模塊的功能,內部特徵是指模塊的內部環境具備的特色 (即該模塊的局部數據和程序代碼)。簡而言之,模塊就是一個具備獨立做用域,對外暴露特定功能接口的代碼集合。java

爲何須要模塊化

首先讓咱們回到過去,看看原始 JavaScript 模塊文件的寫法。node

// 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 文件的功能函數,因此咱們在使用的時候,通常會這樣寫:git

<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>
複製代碼

這樣的管理方式會形成如下幾個問題:github

  • 模塊的引入順序可能會出錯
  • 會污染全局變量
  • 模塊之間的依賴關係不明顯

基於上述的緣由,就有了對上述問題的解決方案,便是 JavaScript 模塊化規範,目前主流的有 CommonJS,AMD,CMD,ES6 Module 這四種規範。web

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 中緩存的是編譯和執行後的對象。

路徑分析 + 文件定位

其流程以下圖所示:

模塊編譯

在定位到文件後,首先會檢查該文件是否有緩存,有的話直接讀取緩存,不然,會新建立一個 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 添加斷點,能夠看到頁面結構以下圖所示:

由圖能夠看到,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)。所謂的模塊記錄是指一個記錄了對應模塊的語法樹,依賴信息,以及各類屬性和方法 (這裏不是很明白)。一樣也是在這個過程對模塊記錄進行了緩存的操做,下圖是一個模塊記錄表:

下圖是緩存記錄表:

實例化 (Instantiation)

這個過程會在內存中開闢一個存儲空間 (此時尚未填充值),而後將該模塊全部的 export 和 import 了該模塊的變量指向這個內存,這個過程叫作連接。其寫入 export 示意圖以下所示:

而後是連接 import,其示意圖以下所示:

賦值(Evaluation)

這個過程會執行模塊代碼,並用真實的值填充上一階段開闢的內存空間,此過程後 import 連接到的值就是 export 導出的真實值。

根據上面的過程咱們能夠知道。ES Module 模塊 export 和 import 其實指向的是同一塊內存,但有一個點須要注意的是,import 處不能對這塊內存的值進行修改,而 export 能夠,其示意圖以下:

總結

本文主要對目前主流的 JavaScript 模塊化方案 CommonJs,AMD,CMD, ES Module 進行了學習和了解,並對其中最有表明性的模塊化實現 (NodeJs,RequireJS,SeaJS,ES6) 作了一個簡單的分析。對於服務端的模塊而言,因爲其模塊都是存儲在本地的,模塊加載方便,因此一般是採用同步讀取文件的方式進行模塊加載。而對於瀏覽器而言,其模塊通常是存儲在遠程網絡上的,模塊的下載是一個十分耗時的過程,因此一般是採用動態異步腳本加載的方式加載模塊文件。另外,不管是客戶端仍是服務端的 JavaScript 模塊化實現,都會對模塊進行緩存,以此減小二次加載的開銷。

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

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

相關文章
相關標籤/搜索