你真的瞭解CommonJS嗎?

斯贝斯 2024-09-26 03:44 5次浏览 0 条评论 taohigo.com

這篇文章主要從歷史角度介紹一下Commonjs模塊機制

1. Comminjs規范

1.1 Comminjs出發點

在js發展前期,它主要是在瀏覽器環境發光發熱,由於ES規范規范化的時間比較早,所以涵蓋的范疇比較小,但是在實際應用中,js的表現取決於宿主環境對ES規范的支持程度,隨著web2.0的推進,HTML5嶄露頭角,它將web從網頁時代帶進瞭應用時代,並且在ES標準中出現瞭更多、更強大的api,在瀏覽器中也出現瞭更多、更強大的api供js調用,這需要感謝各大瀏覽器廠商對規范的大力支持,然而,瀏覽器的更新迭代和api的升級隻出現在前端,後端的js規范卻遠遠落後,對於js自身而言,它的規范依然是十分薄弱的,還存在一些嚴重的缺陷,比如:沒有模塊標準。

Commonjs規范的提出,主要是為瞭彌補當初js沒有模塊標準的缺點,以達到像其它語言(例如Java、Python)那樣具備開發大型應用的基礎能力,而不是停留在腳本程序的階段。他們期望用commonjs規范寫出的應用具備跨宿主環境(瀏覽器環境)執行的能力,這樣不僅可以利用js編寫web程序,而且也可以編寫服務器、命令行工具、甚至桌面應用程序。

理論和實踐總是相互影響和促進的,Node能以一種比較成熟的姿態出現,離不開Commonjs規范的影響,同樣,在服務端,Commonjs能以一種尋常的姿態寫進各個公司的項目中,也離不開Node優異的表現,下圖是Node與W3C、還有瀏覽器,Commonjs組件、ES規范之間的關系:

Node借鑒瞭Commonjs的模塊化規范實現瞭一套非常易用的模塊。

1.2 Comminjs模塊規范

commonjs對模塊的定義十分簡單,主要分為模塊引用模塊定義模塊標識三個部分。

1.2.1 模塊引用

模塊引用的示例代碼:

const fs = require('fs'); 復制代碼

在規范中,存在require()方法,這個方法接收模塊標識,以此入一個模塊的API到當前上下文中。

1.2.2 模塊定義

出瞭引入的功能之外,上下文還提供瞭exports對象,用於導出當前模塊的方法或者變量,並且它是唯一導出的出口,在模塊中,還存在一個module對象,代表模塊自身,而exports是module的屬性,在Node中,一個文件就是一個模塊,將方法掛載在exports對象上作為屬性即可定義導出的方式:

exports.add = function () { // …… }; 復制代碼

在另一個文件中,我們通過require()方法引入模塊後,就能調用方法或者屬性瞭:

const math = require('math'); const result = math.add(10, 20); 復制代碼

1.2.3 模塊標識

模塊標識其實就是傳遞給require()函數的參數,它必須是符合小駝峰命名的字符串,或者是 以 ... 開頭的相對路徑或者絕對路徑,它可以沒有文件名後綴.js

模塊的定義十分簡單,接口也十分簡潔,它的意義在於將累聚的方法或者變量限定在私有的作用域用,同時支持引入和導出功能以順暢的銜接不同的模塊(文件),每個模塊具有獨立的空間,它們互不幹擾,在引用的時候也顯得幹凈利落。

2. Node的模塊實現

盡管規范中exports、require֖和module聽起來十分簡單,但是Node在實現它的過程中究竟經歷瞭什麼,這個過程需要知曉:

在Node中引入模塊,需要經歷如下三個步驟:路徑分析文件定位編譯執行

需要註意的是,在Node中,模塊分為兩類,一類是Node內置的模塊,稱為核心模塊;另一類是用戶編寫的模塊,稱為文件模塊

  • 核心模塊在Node源碼的編譯過程中,編譯進瞭二進制文件,在進程啟動時,部分核心模塊就直接被加載進內存,這部分核心模塊引入時,文件定位和編譯執行這兩個步驟可以省略掉,並且在路徑分析的過程中優先判斷,所以這部分的加載速度是最快的。
  • 文件模塊是在運行時動態加載,需要完整的路徑分析、文件定位、編譯執行過程,速度比核心模塊慢。

接下來,我們詳細分析一下模塊加載的過程:

2.1 優先從緩存加載

在此之前,我們需要知曉的一點是,與瀏覽器會緩存靜態文件從而提高性能一樣,Node也會對引入過的模塊進行緩存,以減少二次引入時的開銷。不同的地方在於,瀏覽器隻緩存文件,而Node緩存的是編譯的對象。

不論是核心模塊還是文件模塊, require()方法對相同模塊的二次加載都一律采用緩存優先的方式,這是第一優先級的。並且核心模塊的緩存檢查優先於文件模塊的緩存檢查。

2.2 路徑分析和文件定位

因為模塊標識有幾種形式,對於不同的標識符,模塊查找和定位都有不同程度的差異。

2.2.1 模塊標識符分析

前面提到過,require()方法接收一個標識符作為參數,標識符在Node中主要分為以下幾類:

  • 核心模塊(內置模塊),比如http、fs、path等
  • 以 / 開頭的絕對路徑或者相對路徑的文件模塊
  • 非路徑形式的文件模塊,如自定義的模塊

2.2.1.1 核心模塊

核心模塊的優先級僅次於緩存加載,它在Node的源代碼編譯過程中編譯為二進制代碼,加載過程最快。

如果試圖加載一個與核心模塊標識符相同的自定義模塊,那是不會成功的。如果自己編寫瞭一個http用戶模塊,想要加載成功,必須選擇一個不同的標識符或者換用路徑的方式。

2.2.1.2 文件模塊

以 . 和 / 開頭的標識符,都被當做文件模塊來處理。在分析文件模塊時,require()方法會將路徑轉為真實路徑,並以真實路徑作為索引,將編譯執行後的結果存放到緩存中,以使二次加載時更快。

由於文件模塊給Node指明瞭確切的文件位置,所以在查找過程中可以節約大量時間,其加載速度慢於核心模塊。

2.2.1.3 自定義模塊

自定義模塊指的是非核心模塊,也不是路徑形式的標識符。它是一種特殊的文件模塊,可能是一個文件或者包的形式。這類模塊的查找是最費時的,也是所有方式中最慢的一種。

在介紹自定義模塊的查找方式之前,需要先介紹一下模塊路徑這個概念,關於這個路徑的生成規則,我們可以手動嘗試一番:在任意一個目錄下創建一個js文件,然後打印出module.paths:

console.log(module.paths); 復制代碼

然後執行代碼,可以得到如下結果:

可以看到,模塊路徑的內容具體表現為一個路徑組成的數組,數組的生成規則如下:

  • 當前文件目錄下的node_modules目錄。
  • 父目錄下的node_modules目錄。
  • 父目錄的父目錄下的node_modules目錄。
  • 父目錄的父目錄的父目錄下的node_modules目錄。
  • 沿路徑向上逐級遞歸,直到根目錄下的node_modules目錄。

它的生成方式與js的原型鏈或作用域鏈的查找方式十分類似。在加載的過程中,Node會逐個嘗試模塊路徑中的路徑,直到找到目標文件為止。可以看出,當前文件的路徑越深,模塊查找耗時會越多,這也是自定義模塊的加載速度是最慢的原因。

2.2.2 文件定位

從緩存加載的優化策略使得二次引人時不需要路徑分析、文件定位和編譯執行的過程,大大提高瞭再次加載模塊時的效率。但在文件的定位過程中,還有一些細節需要註意,這主要包括文件擴展名的分析、目錄的處理:

2.2.2.1 後綴分析:

  • require()在分析標識符的過程中,會出現標識符中不包含文件擴展名的情況。CommonJS模塊規范也允許在標識符中不包含文件擴展名,這種情況下,Node會按.js、.json、.node的次序補足擴展名,依次嘗試。
  • 在嘗試的過程中,需要調用fs模塊同步阻塞式地判斷文件是否存在。因為Node是單線程的,所以這裡是一個會引起性能問題的地方。小訣竅是:如果是.node和.json文件,在傳遞給require()的標識符中帶上文件後綴,會加快一點速度。另一個訣竅是:同步配合緩存,可以大幅度緩解Node單線程中阻塞式調用的缺陷。

2.2.2.2 目錄分析:

  • 在分析標識符的過程中,require()通過分析文件擴展名之後,可能沒有查找到對應文件,但卻得到一個目錄,這在引入自定義模塊和逐個模塊路徑進行查找時經常會出現,此時Node會將目錄當做一個包來處理。
  • 在這個過程中,Node對CommonJS包規范進行瞭一定程度的支持。首先,Node在當前目錄下查找package.json,通過JSON.parse()解析出包描述對象,從中取出main屬性指定的文件名進行定位。如果文件名缺少擴展名,將會進行後綴分析的步驟。
  • 如果main屬性指定的文件名錯誤,或者壓根沒有package.json文件,Node會將index當做默認文件名,然後依次查找index.js、index.json、index.node。
  • 如果在目錄分析的過程中沒有定位成功任何文件,則自定義模塊進入下一個模塊路徑進行查找。如果模塊路徑數組都被遍歷完畢,依然沒有查找到目標文件,則會拋出查找失敗的異常。