淺談前端模塊化

前端模塊化是前端工程化的基石。時下,大前端時代中對模塊的運用更是無處不在。javascript

何謂模塊?且看 webpack 中定義:html

在模塊化編程中,開發者將程序分解成離散功能塊(discrete chunks of functionality),並稱之爲模塊。 每一個模塊具備比完整程序更小的接觸面,使得校驗、調試、測試垂手可得。 精心編寫的模塊提供了可靠的抽象和封裝界限,使得應用程序中每一個模塊都具備條理清楚的設計和明確的目的。前端

模塊應該是職責單1、相互獨立、低耦合的、高度內聚且可替換的離散功能塊。java

何謂模塊化?node

模塊化是一種處理複雜系統分解成爲更好的可管理模塊的方式,它能夠把系統代碼劃分爲一系列職責單一,高度解耦且可替換的模塊,系統中某一部分的變化將如何影響其它部分就會變得顯而易見,系統的可維護性更加簡單易得。webpack

模塊化是一種分治的思想,經過分解複雜系統爲獨立的模塊實現細粒度的精細控制,對於複雜系統的維護和管理十分有益。模塊化也是組件化的基石,是構成如今色彩斑斕的前端世界的前提條件。git

爲何須要模塊化

前端開發和其餘開發工做的主要區別,首先是前端是基於多語言、多層次的編碼和組織工做,其次前端產品的交付是基於瀏覽器,這些資源是經過增量加載的方式運行到瀏覽器端,如何在開發環境組織好這些碎片化的代碼和資源,而且保證他們在瀏覽器端快速、優雅的加載和更新,就須要一個模塊化系統,這個理想中的模塊化系統是前端工程師多年來一直探索的難題。github

特別是時下的前端已經今非昔比,各類前端框架和技術層出不窮,由以往的網頁開發變成了系統、應用開發,代碼也愈加複雜,前端承擔着愈來愈多的責任。對於代碼的組織和維護,功能複用等問題,亟待一個基於工程化思考的解決方案。web

爲何須要模塊化,固然最主要仍是我們有需求可是咱確實沒有。JavaScript 自己因爲歷史或者定位的問題,並無提供該類解決方案,與之很有淵源的 Java 卻有一套 package 的機制,經過包、類來組織代碼結構。npm

固然,咱們如今也已經有了本身的且多種多樣的模塊化實現,本文主要仍是基於 Node 中的實現探究 CommonJS 機制。

模塊化簡史

  1. 最簡單粗暴的方式
function fn1(){
  // ...
}

function fn2(){
  // ...
}
複製代碼

經過 script 標籤引入文件,調用相關的函數。這樣須要手動去管理依賴順序,容易形成命名衝突,污染全局,隨着項目的複雜度增長維護成本也愈來愈高。

  1. 用對象來模擬命名空間
var output = {
  _count: 0,
  fn1: function(){
    // ...
  }
}
複製代碼

這樣能夠解決上面的全局污染的問題,有那麼點命名空間的意思,可是隨着項目複雜度增長鬚要愈來愈多的這樣的對象須要維護,不說別的,取名字都是個問題。最關鍵的仍是內部的屬性仍是能夠被直接訪問和修改。

  1. 閉包

最普遍使用的仍是 IIFE

var module = (function(){
  var _count = 0;
  var fn1 = function (){
    // ...
  }
  var fn2 = function fn2(){
    // ...
  }
  return {
    fn1: fn1,
    fn2: fn2
  }
})()

module.fn1();
module._count; // undefined
複製代碼

這樣就擁有獨立的詞法做用域,內存中只會存在一份 copy。這不只避免了外界訪問此 IIFE 中的變量,並且又不會污染全局做用域,經過 return 暴露出公共接口供外界調用。這其實就是現代模塊化實現的基礎。

  1. 更多

還有基於閉包實現的鬆耦合拓展、緊耦合拓展、繼承、子模塊、跨文件共享私有對象、基於 new 構造的各類方式,這種方式在如今看來都再也不優雅,請參考文末引文,就不一一贅述了。

// 鬆耦合拓展
// 這種方式使得能夠在不一樣的文件中以相同結構共同實現一個功能塊,且不用考慮在引入這些文件時候的順序問題。
// 缺點是沒辦法重寫你的一些屬性或者函數,也不能在初始化的時候就是用module的屬性。
var module = (function(my){
  // ...
  return my
})(module || {})

// 緊耦合拓展(沒有傳默認參數)
// 加載順序再也不自由,可是能夠重載
var module = (function(my){
  var old = my.someOldFunc
  
  my.someOldFunc = function(){
    // 重載方法,依然可經過old調用舊的方法...
  }

  return my
})(module)
複製代碼

CommonJS

CommonJS 是以在瀏覽器環境以外構建 JavaScript 生態系統爲目標而產生的項目,好比在服務器和桌面環境中。

出發點是爲了解決 JavaScript 的痛點:

  1. 無模塊系統(ES6 解決了這個問題)
  2. 包管理
  3. 標準庫太少
  4. ...

CommonJS 模塊的特色以下:

  1. 全部代碼都運行在模塊做用域,不會污染全局做用域。
  2. 模塊能夠屢次加載,可是隻會在第一次加載時運行一次,而後運行結果就被緩存了,之後再加載,就直接讀取緩存結果。要想讓模塊再次運行,必須清除緩存。
  3. 模塊加載的順序,按照其在代碼中出現的順序。
  4. 在 Node.js 模塊系統中,每一個文件都視爲獨立的模塊。

CommonJS 規範自己涵蓋了模塊、二進制、Buffer、文件系統、包管理等內容,而 Node 正是借鑑了 CommonJS 規範的模塊系統,自身實現了一套很是易用的模塊系統。 CommonJS 對模塊的定義可分爲三部分:模塊引用(require)、模塊定義(exportsmodule.exports)、模塊標識(require參數)。

CommonJS 的使用方式就不在此贅述了。

咱們既然經過 Node 來學習模塊化編程,首先咱們先要了解 Node 中的模塊。

Node 中的模塊類型

接下來的內容須要不斷的在源碼中找尋整個模塊加載流程執行的相關邏輯,請務必結合源碼閱讀。

  1. 核心模塊
  • built-in 模塊:src 目錄下的 C/CPP 模塊。
  • native 模塊:lib 目錄下的模塊,部分 native 模塊底層調用了 built-in 模塊,好比 buffer 模塊,其內存分配是在 C/CPP 模塊中實現的。
  1. 第三方模塊:保存在 node_modules 目錄下的非 Node 自帶模塊

  2. 文件模塊:好比 require('./utils'),特色就是有絕對或者相對路徑的文件路徑

盜圖一張:

module

執行 node index.js

大概執行流程是 /src/node_main.cc --> /src/node.cc --> 執行node::LoadEnvironment()

// Bootstrap internal loaders
loader_exports = ExecuteBootstrapper(env, "internal/bootstrap/loaders", &loaders_params, &loaders_args);
if (loader_exports.IsEmpty()) {
  return;
}

if (ExecuteBootstrapper(env, "internal/bootstrap/node", &node_params, &node_args).IsEmpty()) {
  return;
}
複製代碼

這裏出現了 internal/bootstrap/loaders。咱們看看該文件的頭部註釋內容:

// This file creates the internal module & binding loaders used by built-in
// modules. In contrast, user land modules are loaded using
// lib/internal/modules/cjs/loader.js (CommonJS Modules) or
// lib/internal/modules/esm/* (ES Modules).
//
// This file is compiled and run by node.cc before bootstrap/node.js
// was called, therefore the loaders are bootstraped before we start to
// actually bootstrap Node.js. It creates the following objects:
//
// C++ binding loaders:
// - process.binding(): the legacy C++ binding loader, accessible from user land
// because it is an object attached to the global process object.
// These C++ bindings are created using NODE_BUILTIN_MODULE_CONTEXT_AWARE()
// and have their nm_flags set to NM_F_BUILTIN. We do not make any guarantees
// about the stability of these bindings, but still have to take care of
// compatibility issues caused by them from time to time.
// - process._linkedBinding(): intended to be used by embedders to add
// additional C++ bindings in their applications. These C++ bindings
// can be created using NODE_MODULE_CONTEXT_AWARE_CPP() with the flag
// NM_F_LINKED.
// - internalBinding(): the private internal C++ binding loader, inaccessible
// from user land because they are only available from NativeModule.require().
// These C++ bindings are created using NODE_MODULE_CONTEXT_AWARE_INTERNAL()
// and have their nm_flags set to NM_F_INTERNAL.
//
// Internal JavaScript module loader:
// - NativeModule: a minimal module system used to load the JavaScript core
// modules found in lib/**/*.js and deps/**/*.js. All core modules are
// compiled into the node binary via node_javascript.cc generated by js2c.py,
// so they can be loaded faster without the cost of I/O. This class makes the
// lib/internal/*, deps/internal/* modules and internalBinding() available by
// default to core modules, and lets the core modules require itself via
// require('internal/bootstrap/loaders') even when this file is not written in
// CommonJS style.
//
// Other objects:
// - process.moduleLoadList: an array recording the bindings and the modules
// loaded in the process and the order in which they are loaded.
複製代碼

這個文件的註釋內容說明了文件是用於初始化的時候構建 process 綁定加載 C++ 模塊,以及 NativeModule 用來加載內建模塊( lib/**/*.jsdeps/**/*.js )。 內建模塊以二進制形式編譯進了 node 中,因此其加載速度很快,沒有 I/O 開銷。這裏的 NativeModule 就是一個迷你版的模塊系統(CommonJS)實現。

也提到了對於非內置模塊的加載文件定義在 lib/internal/modules/cjs/loader.js (CommonJS Modules) 或者 lib/internal/modules/esm/* (ES Modules)

由於 node 啓動的時候先執行環境加載,因此 internal/bootstrap/loaders 會先執行,建立 process 和 NativeModule,這也就是爲何在 lib/internal/modules/cjs/loader.js 文件頭部直接就能夠 直接使用 require() 的緣由,也就是這裏是使用的 NativeModule.require 去加載的內置模塊。

Module.runMain()

再回過頭看看 internal/bootstrap/node 中內容:

函數執行流程:startup() --> startExecution() --> executeUserCode() --> CJSModule.runMain();

這裏的 CJSModule 就是從 lib/internal/modules/cjs/loader.js 經過 NativeModule.require 導入的 Module 對象。咱們看看裏面定義的 runMain() 方法:

Module.runMain() -- 源碼點這裏

// internal/bootstrap/node.js
const CJSModule = NativeModule.require('internal/modules/cjs/loader');
// ...
CJSModule.runMain();


// internal/modules/cjs/loader
// bootstrap main module.
// 就是執行入口模塊(主模塊)
Module.runMain = function() {
  // 加載主模塊 - 命令行參數.
  if (experimentalModules) {
    // 懶加載 ESM
    if (asyncESM === undefined) lazyLoadESM(); 
    asyncESM.loaderPromise.then((loader) => {
      return loader.import(pathToFileURL(process.argv[1]).pathname);
    })
    .catch((e) => {
      decorateErrorStack(e);
      console.error(e);
      process.exit(1);
    });
  } else {
    Module._load(process.argv[1], null, true);
  }
  // 處理第一個 tick 中添加的任何 nextTicks
  process._tickCallback();
};
複製代碼

咱們關注這一句執行代碼:Module._load(process.argv[1], null, true);

這裏的 process.argv[1] 就是咱們標題的 index.js,也就是說執行 node index.js 文件的過程,其本質就是去 Module._load(index.js) 這個文件的過程。

那麼,咱們接着從 Module._load() 開始!

Module._load()

在接着順着這個執行線路梳理前,咱們先要知道是如何定義 Module 對象的:

Module -- 源碼點這裏

// Module 定義(類)
function Module(id, parent) {
  this.id = id; // 模塊的識別符,一般是帶有絕對路徑的模塊文件名
  this.exports = {}; // 表示模塊對外輸出的值。
  this.parent = parent; // 返回一個對象,表示調用該模塊的模塊。
  updateChildren(parent, this, false); // 更新函數
  this.filename = null; // 模塊的文件名,帶有絕對路徑。
  this.loaded = false; // 返回一個布爾值,表示模塊是否已經完成加載。
  this.children = []; // 返回一個數組,表示該模塊要用到的其餘模塊。
}
複製代碼

👌,接着繼續進入 _load 方法:

Module._load() -- 源碼點這裏

// 檢查對請求文件的緩存.
// 1. 若是緩存了該模塊: 直接返回 exports 對象.
// 2. 若是是 native 模塊: 調用並返回 `NativeModule.require()`.
// 3. 不然就建立一個新的 module,緩存起來,並返回其 exports. 
// 參數說明:分別是 *模塊名稱*, *父級模塊(調用這個模塊的模塊)*, *是否是主入口文件(node index.js 中的 index.js 就是主入口文件, require('./index.js') 就不是)*
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];
  if (cachedModule) {
    updateChildren(parent, cachedModule, true);
    return cachedModule.exports;
  }

  if (NativeModule.nonInternalExists(filename)) {
    debug('load native module %s', request);
    return NativeModule.require(filename);
  }

  // Don't call updateChildren(), Module constructor already does.
  var module = new Module(filename, parent);

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

  Module._cache[filename] = module;

  // * 嘗試加載該模塊
  tryModuleLoad(module, filename);

  return module.exports;
};
複製代碼

模塊的引入包含三個過程:

  1. 路徑解析
  2. 文件定位
  3. 編譯執行

因此,在 Module._load() 函數中咱們須要關注兩個重要的方法調用:Module._resolveFilename(request, parent, isMain)tryModuleLoad(module, filename)

Module._resolveFilename()

這個函數對應的就是上邊提到的文件路徑解析、定位的過程,咱們梳理一下:

Module._resolveFilename() -- 源碼

// 省略部分代碼
// 過程
// 1. 自帶模塊裏面有的話 返回文件名
// 2. 算出全部這個文件可能的路徑放進數組(_resolveLookupPaths)
// 3. 在可能路徑中找出真正的路徑並返回(_findPath)
Module._resolveFilename = function(request, parent, isMain, options) {
  if (NativeModule.nonInternalExists(request)) {
    return request;
  }

  var paths;

  if (typeof options === 'object' && options !== null &&
      Array.isArray(options.paths)) {
    const fakeParent = new Module('', null);

    paths = [];

    for (var i = 0; i < options.paths.length; i++) {
      const path = options.paths[i];
      fakeParent.paths = Module._nodeModulePaths(path);
      const lookupPaths = Module._resolveLookupPaths(request, fakeParent, true);

      for (var j = 0; j < lookupPaths.length; j++) {
        if (!paths.includes(lookupPaths[j]))
          paths.push(lookupPaths[j]);
      }
    }
  } else {
    paths = Module._resolveLookupPaths(request, parent, true);
  }

  // look up the filename first, since that's the cache key.
  var filename = Module._findPath(request, paths, isMain);
  if (!filename) {
    // eslint-disable-next-line no-restricted-syntax
    var err = new Error(`Cannot find module '${request}'`);
    err.code = 'MODULE_NOT_FOUND';
    throw err;
  }
  return filename;
};
複製代碼

這裏須要關注的是兩個函數:

  1. Module._resolveLookupPaths(request, parent, true) : 獲取文件全部可能路徑
  2. Module._findPath(request, paths, isMain) : 根據文件可能路徑定位文件絕對路徑,包括後綴補全(.js, .json, .node)等都在此方法中執行,最終返回文件絕對路徑

Module._resolveLookupPaths

找出全部可能的路徑,其實也就是分幾種狀況去推測,最終返回一個可能路徑的結果集。

  1. 路徑不是相對路徑, 多是 Node 自帶的模塊
  2. 路徑不是相對路徑, 多是全局安裝的包,就是 npm i webpack -g
  3. 沒有調用者的話,多是項目 node_module 中的包。
  4. 不然根據調用者(parent)的路徑算出絕對路徑。

Module._findPath

此分析過程其實就是每種狀況都試一次,整個過程以下(盜圖)所示:

process1

tryModuleLoad()

這個函數對應的就是上面提到的編譯執行的過程,咱們梳理一下:

// 經過 module.load 函數加載模塊,失敗就刪除該模塊的緩存。
function tryModuleLoad(module, filename) {
  var threw = true;
  try {
    module.load(filename);
    threw = false;
  } finally {
    if (threw) {
      delete Module._cache[filename];
    }
  }
}
複製代碼

這裏經過 Module.prototype.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));

  var extension = findLongestRegisteredExtension(filename);
  Module._extensions[extension](this, filename);
  this.loaded = true;

  // ...
};
複製代碼

這裏的 extension 其實就是文件後綴,native extension 包含 .js, .json, .node。其定義的順序也就意味着查找的時候也是 .js -> .json -> .node 的順序。 經過對象查找表的方式分發不一樣後綴文件的處理方式也利於後續的可拓展性。咱們接着看:

// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
  var content = fs.readFileSync(filename, 'utf8');
  module._compile(stripBOM(content), filename);
};


// Native extension for .json
Module._extensions['.json'] = function(module, filename) {
  var content = fs.readFileSync(filename, 'utf8');
  try {
    module.exports = JSON.parse(stripBOM(content));
  } catch (err) {
    err.message = filename + ': ' + err.message;
    throw err;
  }
};


// Native extension for .node
Module._extensions['.node'] = function(module, filename) {
  return process.dlopen(module, path.toNamespacedPath(filename));
};
複製代碼

其中 .json 類型的文件加載方法是最簡單的,直接讀取文件內容,而後 JSON.parse 以後返回對象便可。

再來看一下加載第三方 C/C++ 模塊(.node 後綴)。直觀上來看,很簡單,就是調用了 process.dlopen 方法。

咱們重點關注對 .js 文件的處理:

執行了 module._compile() 函數,咱們進入該函數:

Module.prototype._compile() -- 源碼

Module.wrap = function(script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};

Module.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

// 省略部分代碼
Module.prototype._compile = function(content, filename) {
  // ...

  // 把模塊的內容用一個 IIFE 包起來從而有獨立的詞法做用域,傳入了 exports, require, module 參數
  // 這也就是咱們在模塊中能夠直接使用 exports, require, module 的緣由。
  var wrapper = Module.wrap(content);

  // 生成 require 函數
  var require = makeRequireFunction(this);

  // V8 處理字符串源碼,至關於 eval
  var compiledWrapper = vm.runInThisContext(wrapper, {
    filename: filename,
    lineOffset: 0,
    displayErrors: true,
    importModuleDynamically: experimentalModules ? async (specifier) => {
      if (asyncESM === undefined) lazyLoadESM();
      const loader = await asyncESM.loaderPromise;
      return loader.import(specifier, normalizeReferrerURL(filename));
    } : undefined,
  });

  //...

  // 直接調用包裝好的函數,傳入須要的參數。
  result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);

  return result;
}

// makeRequireFunction 定義在 lib/internal/modules/cjs/helpers.js
function makeRequireFunction(mod) {
  const Module = mod.constructor;

  // 深度機制
  function require(path) {
    try {
      exports.requireDepth += 1;
      return mod.require(path);
    } finally {
      exports.requireDepth -= 1;
    }
  }

  function resolve(request, options) {
    validateString(request, 'request');
    return Module._resolveFilename(request, mod, false, options);
  }

  require.resolve = resolve;

  function paths(request) {
    validateString(request, 'request');
    return Module._resolveLookupPaths(request, mod, true);
  }

  resolve.paths = paths;

  require.main = process.mainModule;

  // 支持拓展.
  require.extensions = Module._extensions;

  require.cache = Module._cache;

  return require;
}
複製代碼

至此,編譯執行的過程結束,其實咱們上面展現的都屬於文件模塊的加載流程,對內置模塊的加載流程大致類似,可在 NativeModule 模塊定義的源碼看出一二。

require()

咱們經過上面的 require 的工廠函數能夠知道,在 require('./index') 的時候,其實調用的是 Module.prototype.require

Module.prototype.require = function(id) {
  validateString(id, 'id');
  if (id === '') {
    throw new ERR_INVALID_ARG_VALUE('id', id, 'must be a non-empty string');
  }
  return Module._load(id, this, /* isMain */ false);
};
複製代碼

因此,咱們每次執行 require 以後獲得的返回值其實就是執行完編譯加載後返回的 module.exports

整個過程當中咱們已經走了一遍 Node 對 CommonJS 實現,盜圖一張:

CommonJS

手寫 CommonJS

對上面的整個加載過程熟悉以後,咱們大概瞭解了 Node 對 CommonJS 的實現,因此能夠很容易的手寫一個簡易版的 CommonJS:

const path = require('path')
const fs = require('fs')
const vm = require('vm')

// 定義Module
function Module(id){
  this.id = id
  this.filename = id
  this.exports = {}
  this.loaded = false
}

// 定義拓展與解析規則
Module._extensions = Object.create(null)

Module._extensions['.json'] = function(module){
  return Module.exports = JSON.parse(fs.readFileSync(module.filename, 'utf8'))
}

Module._extensions['.js'] = function(module){
  Module._compile(moudle)
}

// 包裝函數
Module.wrap = function(script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};

Module.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

// 編譯執行
Module._compile = function(module){
  const content = fs.readFileSync(module.filename, 'utf8'), filename = module.filename;
  const wrapper = Module.wrap(content)

  const compiledWrapper = vm.runInThisContext(wrapper, {
    filename: filename,
    lineOffset: 0,
    displayErrors: true,
  })

  const result = compiledWrapper.call(module.exports, module.exports, require, module, filename, dirname);

  return result
}

// 緩存
Module._cache = Object.create(null)

Module.prototype.load = function(filename){
  let extname = path.extname(filename)
  Module._extensions[extname](this);
  this.loaded = true;
}

// 加載
Module._load = function(filename) {
  const cacheModule = Module._cache[filename]
  
  if(cacheModule){
    return cacheModule.exports
  }

  let module = new Module(filename)
  Module._cache[filename] = module

  module.load(filename)

  return module.exports
}

// 簡單的路徑解析
Module._resolveFilename = function(path) {
  let p = path.resolve(path)
  if(!/\.\w+$/.test(p)){
    let arr = Object.keys(Module._extensions)
    arr.forEach(item => {
      let file = `${p}${item}`
      try{
        fs.accessSync(file)
        return file
      }catch(e){
        // ...
      }
    })
  }else{
    return p
  }
}

// require 函數
function require(path){
  const filename = Module._resolveFilename(path)
  return Module._load(filename)
}
複製代碼

參考

1. 模塊

2. 模塊系統

3. JS 模塊化發展史

4. Web前端模塊化發展歷程

5. 模塊化簡史

6. 前端開發的模塊化和組件化的定義,以及二者的關係?

7. JavaScript模塊化編程簡史(2009-2016)

8. 湯姆大叔博客 -- 模塊

9. CommonJS規範

10. wiki - CommonJS

11. Node 文檔 -- 模塊

12. Node 全局變量 -- 寸志

13. JS 模塊加載

14. 圖說 ESM

15. 淺析當下的 Node.js CommonJS 模塊系統

相關文章
相關標籤/搜索