1. 程式人生 > >CommonJS模組規範與NodeJS的模組系統底層原理

CommonJS模組規範與NodeJS的模組系統底層原理

原諒我標題黨
其實也沒有非常深入底層

在瞭解NodeJS模組之前
首先來科普一下什麼是CommonJS

CommonJS規範

它為JavaScript制定一套規範——希望JavaScript能在任何地方執行
使其具備開發大型應用的能力

出發點便是為了彌補當時JavaScript語言自身的缺點:

  • 無模組系統
    • 現在ES6彌補了這個缺點
  • 沒有包管理胸痛
    • 導致js應用沒有自載入和安裝依賴能力
  • 無標準介面
    • 沒有定義過像Web伺服器一類的標準統一介面
  • 標準庫太少
    • 僅有部分核心庫,檔案系統等常見需求沒有標準API;H5推進了這個過程,但也只是瀏覽器端

CommonJS-API寫出的應用可以跨宿主環境
這樣JavaScript就不僅僅只是停留在客戶端,他還可以開發:

  • 伺服器端JS應用
  • 命令列工具
  • 桌面圖形介面應用程式
  • 混合應用(原生應用內嵌瀏覽器)

CommonJS涵蓋一下內容

  • 模組
  • I/O流
  • 二進位制
  • 緩衝區
  • 套接字
  • 程序環境
  • 檔案系統
  • 單元測試
  • 字符集編碼
  • Web伺服器閘道器介面
  • 包管理
  • ……

CommonJS模組

為什麼要介紹CommonJS
因為Node簡單易用的模組系統就是借鑑了CommonJS的Modules規範
CommonJS模組分為三部分:模組引用、模組定義、模組標識

模組匯出

Node中,一個檔案就是一個模組
模組中使用exports匯出當前模組的變數或函式
下面我就建立一個tool.js的工具模組
並且通過exports匯出

//tool.js
var add = function add(a, b){
    return a + b;
}
exprots.add = add;

匯出給外部的物件
就擁有一個add方法

不過要特別注意
如果直接給exports賦值為一個基本型別值不會成功
比如我們只是想單純的匯出一個數字(雖然不會這麼用)

exports = 123;

是完全不能夠匯出的
至於為什麼下面再說

我們一般不會直接通過exports這麼用
在我們模組的上下文中,還有一個module物件,引用我們模組自身
而這個exports物件便是module上的屬性
我們通常的做法就是通過 module.exprots

匯出

//tool.js
var tool = {
    add: function (a, b){
        return a + b;
    }
}
module.exprots = tool;

模組引用

模組引用很簡單
只需呼叫require()方法,接收一個模組標識字串作為引數
如此引入一個模組到我們當前的環境中

var tool = require('./tool');

我們呼叫起來倒是輕鬆愉快
其實內部發生了日異月殊的變化(下面再說)

引入之後,我們就能呼叫內部的API了

tool.add(1, 2); //3

模組標識

模組標識就是我們傳遞給require()的那個字串引數
這個字串是符合小駝峰命名的字串
或者是 . / .. 開頭的相對路徑,再或者絕對路徑
如果要引入的模組字尾為 .js / .json / .node 可以省略

這種模組機制匯入容易,匯出也容易
把類聚的方法和變數限定在私有作用域內
模組之間空間獨立、互不干擾,好處不言而喻
媽媽再也不用擔心我們變數汙染了

NodeJS模組原理

NodeJS在CommonJS模組規範基礎上作出了改動
在瞭解NodeJS模組原理之前
先來了解一下NodeJS的模組快取機制

模組快取機制

NodeJS為了提高效能,我們引入模組後,它都會進行快取
這和我們在瀏覽器端的很像
但是瀏覽器快取的是檔案
而Node快取編譯執行後的物件
我們可以做一個實驗

//increase.js
var a = 0;
var increase = function(){
    ++a;
}
//index.js
var increase = require('./increase');
console.log(increase());
console.log(increase());
var add = require('./tool');
console.log(increase());
console.log(increase());

實驗的結果返回了 1 2 3 4
而不是 1 2 1 2
這就證明二次引用時實際引用了快取的物件(編譯執行後的模組)

所以當我們呼叫require( )方法時
Node會優先檢視快取(第一優先順序),沒有快取再進行一系列過程
這一系列過程就是:

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

瞭解這些過程前
我們還要知道模組分類

模組種類

模組大體上分兩種,它們還可以細分

  • 核心模組:Node提供的模組
    • JavaScript核心模組
    • C/C++核心模組
  • 檔案模組:使用者編寫的模組
    • 本地模組:本地編寫模組
    • 第三方模組:從第三方下載的模組

核心模組在Node原始碼編譯過程中,編譯進二進位制執行檔案
Node啟動,部分核心模組被直接載入進記憶體
所以檔案定位和編譯執行階段可省略,並且優先判斷路徑分析(載入最快)

檔案模組執行時動態載入,速度稍慢

模組引入原理

路徑分析

路徑分析的優先順序如下:

  • 快取載入
  • 核心模組載入
  • 檔案模組載入

如果引入的是核心模組,就直接填寫模組名字串就可以了

var http = require('http');

如果引入的是檔案模組,就會根據填入的路徑來定位檔案

var tool = require('./tool');

我們下載的第三方模組會存在於node_modules的資料夾
在分析它的時候
就會查詢當前目錄下的node_modules中有沒有該檔案
如果沒找到,就會查詢父級目錄下有沒有node_modules並查詢
以此類推
引用第三方模組同樣不必輸入路徑

var react = require('react');

檔案定位

require()分析識別符號的時候,可能會出現省略副檔名的情況
此時,Node會按照 .js / .json / .node 的順序依次嘗試
很顯然這有一點兒效能問題,嘗試也需要時間
所以我們最好給 .json.node 形式的檔案新增副檔名

如果我們定位到的是一個資料夾
Node會把它當做一個包來處理
根據包內部的package.json檔案的main屬性繼續定位入口檔案

關於包的概念,這裡不講
可以暫時把它理解為擁有package.json配置檔案的一個資料夾

模組編譯

檔案格式不同,載入方法也不同

  • .js檔案:通過fs核心模組同步讀取後編譯執行
  • .node檔案:C/C++擴充套件檔案,通過dlopen()載入最後編譯生成的檔案
  • .json檔案:通過fs核心模組同步讀取後利用JSON.parse()解析
  • 其他:均當做.js檔案處理

這裡我只說一下JavaScript檔案模組的編譯
大家一定很奇怪一個問題
我們的檔案中根本沒有什麼exports,沒有什麼require,它們從哪兒來的?
答案就在這裡
就拿我們上面的模組為例

//increase.js
var a = 0;
var increase = function(){
    ++a;
}

在這個編譯過程中,Node實際上對JS檔案進行了包裝
加上了“龍頭鳳尾”(致敬兒時玩的四驅車)
龍頭:(function(exports, require, module, __filename, __dirname){\n
鳳尾:\n});
封裝後的檔案變成了這樣

(function(exports, require, module, __filename, __dirname){
    var a = 0;
    var increase = function(){
        ++a;
    }
});

這回我們就可以理解為什麼直接給exports賦基本型別值不可以
因為exports實際上作為形參傳入
賦值僅僅只是改變了形參

包裝後的程式碼通過原生vm模組的runInThisContext()執行(類似eval)
返回一個函式
最後將當前模組的exports屬性、require方法等等傳入這個函式執行