詳談CommonJS模塊化

1、前言

模塊化其實很早就在不少高級語言中如JavaRubyPython出現了,甚至在C語言都有相似的模塊化,好比include語句引入頭文件,各類庫等等。而在前端中,JavaScript做爲主要語言,它設計之初並無實現模塊化。隨着Web的發展,JavaScript地位愈來愈高,同時也出現瞭如下主要問題:javascript

  • 代碼難以維護
  • 做用域污染
  • 沒法惟一標識變量
  • ...

這可謂是一大痛點吶!可是廣大的軟件工程師們也不是吃素的,因而解決方案如AMDCMDES ModuleCommonJS便雨後春筍般涌現出來了。html

現現在AMDCMD已經慢慢淡出咱們的視野了,咱們接觸最多就是兩種模塊規範:ES ModuleCommonJS,前者應用在ECMAScript中然後者在Node中。前端

CommonJS規範是一個超級大的概念,和ECMAScript規範同樣,它是整個語言層面的規範,模塊化只是偌大的規範中的一種,我相信不少人容易搞混淆,在此仍是說明一下。java

若是仍是不理解,我舉個例子吧:在CommonJS規範中實現瞭如下規範:node

  • ECMAScript(不一樣的版本支持有差別)
  • 模塊
  • 二進制
  • Buffer
  • I/O流
  • ...

我相信你應該能夠理解了,下面我會介紹一下我所學習的CommonJS模塊規範。c++

2、規範內容

主要分爲三部分:模塊引用模塊定義模塊表示web

2.1 模塊引用

Node模塊類型分爲兩種:核心模塊文件模塊,並經過require方法來引入模塊。前者是Node中內置的模塊,然後者通常是用戶本身定義的模塊。後面提到的自定義模塊也屬於文件模塊,只是爲了區分說明。npm

代碼以下:json

// 引入`http`內置模塊
const http = require('http')

// 引入文件模塊
const sum = require('./sum')

// 引入第三方包`koa`,這是一個自定義模塊
const koa = require('koa')
複製代碼

require命令的基本功能是,讀入並執行一個JavaScript文件,而後返回該模塊的exports對象。若是沒有發現指定模塊,會報錯。數組

2.2 模塊定義

在CommonJS模塊規範中,一個文件就是一個模塊,並經過module.exportsexports兩種方式來導出模塊中的變量或函數。

代碼以下:

// 經過exports導出一個`sum`函數
exports.sum = (x, y) => x + y;

// 經過module.exports導出一個`sum` 函數
module.exports = (a, b) => a - b;
複製代碼

爲了方便,Node爲每一個模塊提供一個exports變量,指向module.exports。等價於:

var exports = module.exports;
複製代碼

若是exports導出的變量類型是引用類型如函數,則會斷開與module.exports的地址指向,致使變量導出失敗。由於最終仍是要靠module.exports來導出變量的。

exports = function() {...};
複製代碼

用圖來表示大概就是這個樣子:

在這裏插入圖片描述

同理,若是你要使用module.exports直接導出一個對象或者函數也會從新指向新地址,而你還使用exports導出原來地址中的變量或函數是沒有用的。

// 在原來的空對象中存儲一個a變量
exports.a = function() {}

// 經過module.exports 直接導出一個引用類型變量
// 前面導出的變量失效了

module.exports = {...}
複製代碼

2.3 模塊標識

模塊標識是require方法中的參數,該參數就是引入的模塊文件的路徑,能夠沒有後綴,可是必須符合小駝峯命名規範。

在上面的模塊引用中,http./sumkoa就是模塊標識。具體有如下幾類:

  • 核心模塊
  • ...開始的相對路徑模塊
  • /開始的絕對路徑模塊
  • 自定義模塊,常見的如第三方包

3、模塊加載過程

從Node中引入模塊,主要經歷了四個過程:

  • 緩存加載
  • 路徑分析
  • 文件定位
  • 編譯執行

下面來具體看看它們的過程。

3.1 緩存加載

無論是內置模塊仍是文件模塊,在第一次加載模塊後,會把模塊編譯執行並放在緩存中。從而之後再次加載模塊的時候,會直接去緩存中找相應的模塊。

內置模塊跟文件模塊不一樣的是,它在Node源代碼編譯過程當中直接編譯成了二進制可執行文件,在啓動Node進程的同時就從內存中加載了核心模塊,並緩存起來。因此內置模塊的加載跳過了文件定位編譯執行的步驟,而且優先於文件模塊加載。

緩存通常放在了require.cache,若是想刪除模塊的緩存,能夠像下面這樣寫。

// 刪除指定模塊的緩存
delete require.cache[moduleName];

// 刪除全部模塊的緩存
Object.keys(require.cache).forEach(function(key) {
  delete require.cache[key];
})
複製代碼

注意,緩存是根據絕對路徑識別模塊的,若是一樣的模塊名,可是保存在不一樣的路徑,require命令仍是會從新加載該模塊。

3.2 路徑分析

路徑分析主要是對模塊標識符分析,根據不一樣類型的模塊標識符使用不一樣規則分析路徑。

下面是模塊加載速度比較:

核心模塊 > 文件模塊 > 自定義模塊

核心模塊在Node啓動的時候就已經編譯成了二進制文件了,因此加載速度最快。 文件模塊由於帶有.../路徑標識,具體標識了文件的位置,因此模塊加載速度僅次於核心模塊。自定義模塊是三者最慢的了,具體緣由咱們在下面會有說明。

值得注意的是,若是自定義模塊和核心模塊重名了,則不會加載自定義模塊,由於核心模塊優先於自定義模塊

Node是如何去尋找文件模塊和自定義模塊路徑並加載的呢?下面我要先介紹一個很特殊的對象module

3.2.1 module 對象介紹

Node內部提供一個Module構建函數。全部模塊都是Module的實例。每一個模塊內部,都有一個module對象,表明當前模塊。

Module

function Module(id, parent) {
  this.id = id;
  this.exports = {};
  this.parent = parent;
  // ...
複製代碼

爲了測試,我創建了一個項目結構:

在這裏插入圖片描述
sum.js

module.exports = (x, y) => x + y;
複製代碼

main.js

const sum = require('./sum')
const result = sum(1, 2);

module.exports = result;
console.log(module);
複製代碼

咱們在main.js中打印module,它存放了當前main.js模塊的全部信息:

Module {
  id: '.',
  path: 'e:\\web\\font-end-code\\Node\\01-Commonjs',
  exports: 3,
  parent: null,
  filename: 'e:\\web\\font-end-code\\Node\\01-Commonjs\\main.js',
  loaded: false,
  children: [
    Module {
      id: 'e:\\web\\font-end-code\\Node\\01-Commonjs\\sum.js',
      path: 'e:\\web\\font-end-code\\Node\\01-Commonjs',
      exports: [Function],
      parent: [Circular],
      filename: 'e:\\web\\font-end-code\\Node\\01-Commonjs\\sum.js',
      loaded: true,
      children: [],
      paths: [Array]
    }
  ],
  paths: [
    'e:\\web\\font-end-code\\Node\\01-Commonjs\\node_modules',
    'e:\\web\\font-end-code\\Node\\node_modules',
    'e:\\web\\font-end-code\\node_modules',
    'e:\\web\\node_modules',
    'e:\\node_modules'
  ]
}
複製代碼

簡單介紹一下它其中的每一個屬性。

  • id:模塊的識別符,一般是帶有絕對路徑的模塊文件名。

  • path:當前模塊的絕對路徑。

  • export:表示模塊對外輸出的值。我這裏導出了一個3

  • parent:返回一個對象,表示調用該模塊的模塊。沒有就返回null

  • filename:模塊的文件名,帶有絕對路徑。

  • loaded:返回一個布爾值,表示模塊是否已經完成加載。

  • children:返回一個數組,表示該模塊要用到的其餘模塊。

  • paths:當前模塊查找的絕對路徑數組。它遵循必定的模塊路徑查詢規則。

咱們能夠利用parent屬性來判斷當前文件是否是一個入口文件:

if(!module.parent) {
	// do something
} else {
	// export something
}
複製代碼

咱們瞭解了module對象後,對接下來分析模塊路徑查詢規則頗有幫助了。

3.2.2 模塊路徑查詢規則

上面咱們已經看到了在module對象中有個很重要的屬性paths,裏面存放了一個路徑數組。如今換成自定義模塊的寫法來引入sum模塊:

main.js

const sum = require('sum');
const result = sum(1, 2);

module.exports = result;

console.log('---------------main.js-----------')
console.log(module.paths);
複製代碼

而後在main.js同級目錄下建立一個node_modules目錄,建立一個sum.js模塊:

node_modules/sum.js

module.exports = (x, y) => x + y;

console.log('---------------node_module/sum.js-----------')
console.log(module.paths);

複製代碼

在這裏插入圖片描述

執行main.js

在這裏插入圖片描述

咱們發現,經過自定義模塊方式引入的sum.js和文件模塊中的paths是同樣的結果。因此咱們能夠得出一個規則:

  • 查詢當前文件目錄下的node_modules路徑
  • 查詢父級目錄下的node_modules路徑
  • 查詢父級的父級目錄下的node_modules路徑
  • 一直遞歸,直到查詢到根目錄下的node_modules路徑

奇怪的是,在main.js中咱們發現node_modules目錄也被打印了出來,但是,咱們看到的是main.js並不在該目錄下面啊,這是怎麼回事呢? 這裏留個思考題。

3.3 文件定位

路徑分析好了後,下面要具體定位文件的位置了,主要分爲兩個步驟:文件拓展名分析目錄分析

3.3.1 文件拓展名分析

咱們使用require引入模塊的時候,能夠不加文件的後綴名。好比:

const sum = require('./sum')
複製代碼

這個時候Node就會進行文件拓展名分析,會依次分析下面三個拓展名:

  • .js
  • .node
  • .json

在分析的過程當中,Node會同步阻塞式調用fs模塊來判斷文件是否存在。若是查找不到這個文件,而獲得了一個目錄,那麼將會進行目錄(包)分析。

3.3.2 目錄(包)分析

爲了測試,咱們來整理下目錄結構:

在這裏插入圖片描述

有同窗確定仍是要問,爲啥要放在node_modules下面呢,這個還真很差回答,請翻到上面再看一下路徑分析,但願對你有幫助哦。

package.json

{
  "main": "sum.js"
}
複製代碼

其他代碼不變。執行main.js後發現可以正常打印結果就能說明模塊加載成功了。上面其實就是一個目錄分析的過程了。

  • 找到sum這個目錄(或者叫包)
  • 判斷有沒有package.json文件,若是有,使用JSON.parse解析JSON對象,找到main屬性名對應的文件名,我這裏的屬性名就是sum.js,若是文件名沒有拓展名就先進行拓展名分析,而後定位到這個模塊就能夠了。
  • 若是沒有package.json文件,就會在當前目錄下依次尋找index.jsindex.nodeindex.json
  • 若是都不符合條件就拋出異常

若是你理解了這個過程,你也就理解了npm install 的時候爲何會自動生成node_modules文件夾,而且文件夾下有好多包。而後咱們引入的方式就是自定義模塊引入的方式。

3.4 模塊編譯

上面的步驟完成後,也就是說如今已經找到了模塊了,咱們須要對模塊進行編譯,並執行模塊裏面的代碼,把須要暴露出來的變量都暴露出來。不一樣的文件拓展名,載入的編譯方法是不同的。

  • .js:經過fs模塊同步載入後編譯執行。
  • .node:這是c/c++編寫的拓展文件,須要調用dlopen()方法來編譯。
  • .json:經過fs模塊同步載入後使用JSON.parse解析結果。
  • 其他拓展名都被當作js文件來處理。

每個編譯成功的模塊都會將文件的絕對路徑看成索引緩存在Module._cache對象上,來提高二次引入的性能。

3.4.1 JSON文件編譯

這一塊的編譯主要是經過Node同步調用fs模塊讀取JSON文件內容後,使用JSON.parse方法解析,而後將解析後的結果放到exports對象暴露出去。它通常都是做爲一個配置文件說明,並且通常都是node本身加載pacakage.json。處理代碼以下:

Module._extensions['.json'] = function(module, filename) {
	var content = NativeModule.require('fs).readFileSync(filename, 'utf-8');
	try {
		module.exports = JSON.parse(stripBOM(content));
	} catch(err) {
		err.message = filename + ':' + err.message;
		thow err;
	}
}
複製代碼

3.4.2 node文件編譯

node文件主要是C/C++ 的拓展,其實C/C++ 已經編譯好了封裝在了libuv層,只須要在node曾調用procee.dlopen方法就能夠加載執行。這一塊難度較大,就很少說了。

3.4.3 JavaScript文件編譯

編譯JavaScript文件過程當中,給當前模塊包上一層函數,採用閉包來解決全局變量污染問題。下面是一個簡單的實現。

(function(exports, require, module, __dirname, __filename){
	var load = function (exports, module) {
	    // 讀取的main.js代碼:
		const sum = require('./sum');
		const result = sum(1,2);
		module.exports.result = result;
	    // main.js代碼結束
	    return module.exports;
	};
	var exported = load(module.exports, module);
	// 保存module:
	save(module, exported);
})
複製代碼

包裝完後,將須要導出的變量經過module.exports導出去了。其餘模塊就只能訪問導出來的變量,其他變量是訪問不到的

4、和ES Module的區別

CommonJS模塊的加載機制是,輸入的是被輸出的值的拷貝。也就是說,一旦輸出一個值,模塊內部的變化就影響不到這個值。 咱們將main.js代碼改變一下:

let sum = require('./sum');
sum = { a: 1 };
console.log(require('./sum'))
console.log(sum)
複製代碼

能夠看到,兩個模塊是不會影響的。

在這裏插入圖片描述

可是在ES Module中是不同的,它是靜態加載。也就是在代碼靜態解析階段就已經確認好模塊依賴關係了。一句話總結就是:

CommonJS 模塊輸出的是一個值的拷貝,ES6 模塊輸出的是值的引用。

因此在ES Module模塊與模塊是互相影響的。

5、總結

以前對CommonJS模塊化的理解模模糊糊的,這篇文章算是個人一個筆記吧,畢竟大部份內容是從前輩們的文章或書籍裏面借鑑的,跟着他們的腳步來走的。即使如此,我發現仍是收穫很多,再次感謝它們的文章和書籍。雖然還有不少不完善的地方,但我對本身仍是有信心的,爭取之後有更多本身的理解。

6、參考

【1】《深刻淺出Node.js》樸靈編著

【2】阮一峯 CommonJS規範

【3】廖雪峯 模塊

相關文章
相關標籤/搜索