淺談前端模組化

波比小金剛發表於2018-12-16

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

何謂模組?且看 webpack 中定義:

在模組化程式設計中,開發者將程式分解成離散功能塊(discrete chunks of functionality),並稱之為模組。 每個模組具有比完整程式更小的接觸面,使得校驗、除錯、測試輕而易舉。 精心編寫的模組提供了可靠的抽象和封裝界限,使得應用程式中每個模組都具有條理清楚的設計和明確的目的。

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

何謂模組化?

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

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

為什麼需要模組化

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

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

為什麼需要模組化,當然最主要還是我們們有需求但是我們確實沒有。JavaScript 本身由於歷史或者定位的問題,並沒有提供該類解決方案,與之頗有淵源的 Java 卻有一套 package 的機制,通過包、類來組織程式碼結構。

當然,我們現在也已經有了自己的且多種多樣的模組化實現,本文主要還是基於 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 模組系統

相關文章