您好,登錄后才能下訂單哦!
本篇內容介紹了“node中的模塊系統原理是什么”的有關知識,在實際案例的操作過程中,不少人都會遇到這樣的困境,接下來就讓小編帶領大家學習一下如何處理這些情況吧!希望大家仔細閱讀,能夠學有所成!
并不是所有編程語言都有內置的模塊系統,JavaScript誕生之后的很長一段時間里都沒有模塊系統。
在瀏覽器環境中只能使用<script>
標簽來引入不用的代碼文件,這種方法共享一個全局作用域,可謂是問題多多;加上前端日新月異的發展,這種方法已經不滿足當下的需求了。在沒官方的模塊系統出現前,前端社區自己創建第三方模塊系統,用的較多的有:異步模塊定義AMD、通用模塊定義UMD等,當然最著名還得是CommonJS。
由于Node.js它是一個JavaScript的運行環境,可以直接訪問底層的文件系統。所以開發者通過它,并按照CommonJS規范實現了一套模塊系統。
最開始,CommonJS只能用于Node.js平臺,隨著Browserify和Webpack之類的模塊打包工具的出現,CommonJS也終于能在瀏覽器端運行了。
到2015年發布了ECMAScript6規范,才有了模塊系統的正式標準,按照該標準打造出來的模塊系統稱為ECMAScript module簡稱【ESM】,由此ESM就開始統一了Node.js環境與瀏覽器環境。當然ECMAScript6只是提供了語法和語義,至于實現部分得由各瀏覽器服務廠商和Node開發者去努力。所以才有了令其他編程語言羨慕不已的babel神器,實現模塊系統并不是一件容易的事,Node.js也是到了13.2版本才算是比較穩定的支持ESM。
但不管怎么樣,ESM才是JavaScript的“親兒子”,學習它一定不會有錯!
在刀耕火種的年代中使用JavaScript開發應用,腳本文件只能通過script標簽引入。其中遇到比較嚴重的問題就是缺乏命名空間機制,這意味著每個腳本都共享同一作用域。這個問題在社區中有一個比較好的解決方法:Revevaling module
const myModule = (() => {
const _privateFn = () => {}
const _privateAttr = 1
return {
publicFn: () => {},
publicAttr: 2
}
})()
console.log(myModule)
console.log(myModule.publicFn, myModule._privateFn)
運行結果如下:
這個模式很簡單,利用IIFE創建一個私有的作用域,同時使用return需要暴露的變量。而屬于內部的變量(比如_privateFn、_privateAttr)是不能從外面的作用域訪問的。
【revealing module】正是利用了這些特性,來隱藏私有的信息,同時把應該公布給外界的API導出。后面的模塊系統也正是基于這樣的思路開發的。
基于上面思路,來開發一個模塊加載器。
首先編寫一個加載模塊內容的函數,并把這個函數包裹在私有作用域里面,然后通過eval()求值,以運行該函數:
function loadModule (filename, module, require) {
const wrappedSrc =
`(function (module, exports, require) {
${fs.readFileSync(filename, 'utf8)}
}(module, module.exports, require)`
eval(wrappedSrc)
}
和【revealing module】一樣,把模塊的源代碼包裹在函數里面,區別在于,還把一系列變量(module, module.exports, require)傳給該函數。
值得注意的是,通過【readFileSync】讀取模塊內容。一般來說,在調用涉及文件系統的API時,不應該使用同步版本。但此時不同,因為通過CommonJs系統來加載模塊,本身就應該實現成同步操作,以確保多個模塊能夠按照正確的依賴順序得到引入。
接著模擬require()函數,主要功能是加載模塊。
function require(moduleName) {
const id = require.resolve(moduleName)
if (require.cache[id]) {
return require.cache[id].exports
}
// 模塊的元數據
const module = {
exports: {},
id
}
// 更新緩存
require.cache[id] = module
// 載入模塊
loadModule(id, module, require)
// 返回導出的變量
return module.exports
}
require.cache = {}
require.resolve = (moduleName) => {
// 根據moduleName解析出完整的模塊id
}
(1)函數接收到moduleName后,首先解析出模塊的完整路徑,賦值給id。
(2)如果cache[id]
為true,說明該模塊已經被加載過了,直接返回緩存結果
(3)否則,就配置一套環境,用于首次加載。具體來說,創建module對象,包含exports(也就是導出內容),id(作用如上)
(4)將首次加載的module緩存起來
(5)通過loadModule從模塊的源文件中讀取源代碼
(6)最后return module.exports
返回想要導出的內容。
在模擬require函數的時候,有一個很重要的細節:require函數必須是同步的。它的作用僅僅是直接將模塊內容返回而已,并沒有用到回調機制。Node.js中的require也是如此。所以針對module.exports的賦值操作,也必須是同步的,如果用異步就會出問題:
// 出問題
setTimeout(() => {
module.exports = function () {}
}, 1000)
require是同步函數這一點對定義模塊的方式有著非常重要的影響,因為它迫使我們在定義模塊時只能使用同步的代碼,以至于Node.js都為此,提供了大多數異步API的同步版本。
早期的Node.js有異步版本的require函數,但很快就移除了,因為這會讓函數的功能變得十分復雜。
ESM是ECMAScript2015規范的一部分,該規范給JavaScript語言指定了一套官方的模塊系統,以適應各種執行環境。
Node.js默認會把.js后綴的文件,都當成是采用CommonJS語法所寫的。如果直接在.js文件中采用ESM語法,解釋器會報錯。
有三種方法可以在讓Node.js解釋器轉為ESM語法:
1、把文件后綴名改為.mjs;
2、給最近的package.json文件添加type字段,值為“module”;
3、字符串作為參數傳入--eval
,或通過STDIN管道傳輸到node,帶有標志--input-type=module
比如:
node --input-type=module --eval "import { sep } from 'node:path';
console.log(sep);"
ESM可以被解析并緩存為URL(這也意味著特殊字符必須是百分比編碼)。支持file:
、node:
和data:
等的URL協議
file:URL
如果用于解析模塊的import說明符具有不同的查詢或片段,則會多次加載模塊
// 被認為是兩個不同的模塊
import './foo.mjs?query=1';
import './foo.mjs?query=2';
data:URL
支持使用MIME類型導入:
text/javascript
用于ES模塊
application/json
用于JSON
application/wasm
用于Wasm
import 'data:text/javascript,console.log("hello!");';
import _ from 'data:application/json,"world!"' assert { type: 'json' };
data:URL
僅解析內置模塊的裸說明符和絕對說明符。解析相對說明符不起作用,因為data:
不是特殊協議,沒有相對解析的概念。
導入斷言
這個屬性為模塊導入語句添加了內聯語法,以便在模塊說明符旁邊傳入更多信息。
import fooData from './foo.json' assert { type: 'json' };
const { default: barData } = await import('./bar.json', { assert: { type: 'json' } });
目前只支持JSON模塊,而且assert { type: 'json' }
語法是具有強制性的。
導入Wash模塊
在--experimental-wasm-modules
標志下支持導入WebAssembly模塊,允許將任何.wasm文件作為普通模塊導入,同時也支持它們的模塊導入。
// index.mjs
import * as M from './module.wasm';
console.log(M)
使用如下命令執行:
node --experimental-wasm-modules index.mjs
await關鍵字可以用在ESM中的頂層。
// a.mjs
export const five = await Promise.resolve(5)
// b.mjs
import { five } from './a.mjs'
console.log(five) // 5
前面說過,import語句對模塊依賴的解決是靜態的,因此有兩項著名的限制:
模塊標識符不能等到運行的時候再去構造;
模塊引入語句,必須寫在文件的頂端,而且不能套在控制流語句里;
然而,對于某些情況來說,這兩項限制無疑是過于嚴格。就比如說有一個還算是比較常見的需求:延遲加載:
在遇到一個體積很大的模塊時,只想在真正需要用到模塊里的某個功能時,再去加載這個龐大的模塊。
為此,ESM提供了異步引入機制。這種引入操作,可以在程序運行的時候,通過import()
運算符實現。從語法上看,相當于一個函數,接收模塊標識符作為參數,并返回一個Promise,待Promise resolve后就能得到解析后的模塊對象。
用一個循環依賴的例子來說明ESM的加載過程:
// index.js
import * as foo from './foo.js';
import * as bar from './bar.js';
console.log(foo);
console.log(bar);
// foo.js
import * as Bar from './bar.js'
export let loaded = false;
export const bar = Bar;
loaded = true;
// bar.js
import * as Foo from './foo.js';
export let loaded = false;
export const foo = Foo;
loaded = true
先看看運行結果:
通過loaded可以觀察到,foo和bar這兩個模塊都能log出加載完整的模塊信息。而CommonJS卻不一樣,一定會有一個模塊無法打印出完整加載后的樣子。
我們深入加載過程,看看為什么會出現這樣的結果。
加載過程可以分為三個階段:
第一個階段:解析
第二個階段:聲明
第三個階段:執行
解析階段:
解釋器從入口文件出發(也就是index.js),解析模塊之間的依賴關系,以圖的形式展示出來,這張圖也被稱為依賴關系圖。
在這個階段只關注與import語句,并把這些語句想要引入的模塊所對應的源碼,給加載進來。并以深度解析的方式得到最后的依賴關系圖。以上面例子說明:
1、從index.js開始,發現import * as foo from './foo.js'
語句,從而去到foo.js文件中。
2、從foo.js文件繼續解析,發現import * as Bar from './bar.js'
語句,從而去到bar.js中。
3、從bar.js繼續解析,發現import * as Foo from './foo.js'
語句,形式循環依賴,但由于解釋器已經在處理foo.js模塊了,所以不會再進入其中,然后繼續解析bar模塊。
4、解析完bar模塊后,發現沒有import語句了,所以返回foo.js,并繼續往下解析。一路都沒有再次發現import語句,返回index.js。
5、在index.js中發現import * as bar from './bar.js'
,但由于bar.js已經解析過了,所以略過,繼續往下執行。
最后通過深度優先的方式把依賴圖完整的展示出來:
聲明階段:
解釋器從得到的依賴圖出發,從底到上的順序對每個模塊進行聲明。具體來說,每到達一個模塊,就尋找該模塊所要導出的全部屬性,并在內存中聲明導出值的標識符。請注意,該階段只作聲明,不會進行賦值操作。
1、解釋器從bar.js模塊出發,聲明loaded和foo的標識符。
2、向上回溯,到了foo.js模塊,聲明loaded和bar標識符。
3、到了index.js模塊,但這個模塊沒有導出語句,所以沒有聲明任何標識符。
聲明完所有導出標識符后,再走一遍依賴圖,把import引入和export導出的關系連接起來。
可以看到,由import引進來的模塊與export所導出值之間,建立了一種類似于const的綁定關系,引入方這一端是只能讀而不能寫。而且在index.js讀取的bar模塊,與在foo.js讀取的bar模塊實質是同一個實例。
所以這就是為什么在這個例子的結果中都能輸出完整的解析結果的原因。
這跟CommonJS系統所用的方法有根本的區別。如果有某個模塊要引入CommonJS模塊,那么系統會對后者的整個exports對象做拷貝,從而將其中的內容復制到當前模塊里面,這樣的話,如果受引入的那個模塊修改了自身的那一份變量,那么用戶這邊是看不到新值的。
執行階段:
在這個階段中,引擎才會去執行模塊的代碼。依然采用從底向上的順序訪問依賴圖,并逐個執行訪問到的文件。從bar.js文件開始執行,到foo.js,最后才是index.js。在這個過程中,逐步完善export表中標識符的值。
這套流程與CommonJS看似沒有太大區別,但實際有著重大差異。由于CommonJS是動態的,因此它一邊解析依賴圖,一邊執行相關的文件。所以只要看到一條require語句,就可以肯定的說,當程序來到這條語句時,已經把前面的代碼都執行完了。因此,require語句不一定非要出現在文件的開頭,而是可以出現在任意地方,而且,模塊標識符也可以通過變量來構造。
但ESM不同,在ESM里,上述這三個階段是彼此分離的,它必須先把依賴圖完整地構造出來,然后才能執行代碼,因此,引入模塊與導出模塊的操作,都必須是靜態的,而不能等到執行代碼的時候再去做。
除了前面提到的幾個區別之外,還有一些區別是值得注意的:
在ESM中使用import關鍵字解析相對或絕對的說明符時,必須提供文件擴展名,還必須完全指定目錄索引('./path/index.js')。而CommonJS的require函數則允許省略這個擴展名。
ESM是默認運行于嚴格模式之下,而且該嚴格模式是不能禁用。所以不能使用未聲明的變量,也不能使用那些僅僅在非嚴格模式下才能使用的特性(例如with)。
CommonJS中提供了一些全局變量,這些變量不能在ESM下使用,如果試圖使用這些變量會導致ReferenceError錯誤。包括
require
exports
module.exports
__filename
__dirname
其中__filename
指的是當前這個模塊文件的絕對路徑,__dirname
則是該文件所在文件夾的絕對路徑。這連個變量在構建當前文件的相對路徑時很有幫助,所以ESM提供了一些方法去實現兩個變量的功能。
在ESM中,可以使用import.meta
對象來獲取一個引用,這個引用指的是當前文件的URL。具體來說,就是通過import.meta.url
來獲取當前模塊的文件路徑,這個路徑的格式類似file:///path/to/current_module.js
。根據這條路徑,構造出__filename
和__dirname
所表達的絕對路徑:
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
而且還能模擬CommonJS中require()函數
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
在ESM的全局作用域中,this是未定義(undefined),但是在CommonJS模塊系統中,它是一個指向exports的引用:
// ESM
console.log(this) // undefined
// CommonJS
console.log(this === exports) // true
上面提到過在ESM中可以模擬CommonJS的require()函數,以此來加載CommonJS的模塊。除此之外,還可以使用標準的import語法引入CommonJS模塊,不過這種引入方式只能把默認導出的東西給引進來:
import packageMain from 'commonjs-package' // 完全可以
import { method } from 'commonjs-package' // 出錯
而CommonJS模塊的require總是將它引用的文件視為CommonJS。不支持使用require加載ES模塊,因為ES模塊具有異步執行。但可以使用import()
從CommonJS模塊中加載ES模塊。
雖然ESM已經推出了7年,node.js也已經穩定支持了,我們開發組件庫的時候可以只支持ESM。但為了兼容舊項目,對CommonJS的支持也是必不可少的。有兩種廣泛使用的方法可以使得組件庫同時支持兩個模塊系統的導出。
在CommonJS中編寫包或將ES模塊源代碼轉換為CommonJS,并創建定義命名導出的ES模塊封裝文件。使用條件導出,import使用ES模塊封裝器,require使用CommonJS入口點。舉個例子,example模塊中
// package.json
{
"type": "module",
"exports": {
"import": "./wrapper.mjs",
"require": "./index.cjs"
}
}
使用顯示擴展名.cjs
和.mjs
,因為只用.js
的話,要么是被默認為CommonJS,要么"type": "module"
會導致這些文件都被視為ES模塊。
// ./index.cjs
export.name = 'name';
// ./wrapper.mjs
import cjsModule from './index.cjs'
export const name = cjsModule.name;
在這個例子中:
// 使用ESM引入
import { name } from 'example'
// 使用CommonJS引入
const { name } = require('example')
這兩種方式引入的name都是相同的單例。
package.json文件可以直接定義單獨的CommonJS和ES模塊入口點:
// package.json
{
"type": "module",
"exports": {
"import": "./index.mjs",
"require": "./index.cjs"
}
}
如果包的CommonJS和ESM版本是等效的,則可以做到這一點,例如因為一個是另一個的轉譯輸出;并且包的狀態管理被仔細隔離(或包是無狀態的)
狀態是一個問題的原因是因為包的CommonJS和ESM版本都可能在應用程序中使用;例如,用戶的引用程序代碼可以importESM版本,而依賴項require CommonJS版本。如果發生這種情況,包的兩個副本將被加載到內存中,因此將出現兩個不同的狀態。這可能會導致難以解決的錯誤。
除了編寫無狀態包(例如,如果JavaScript的Math是一個包,它將是無狀態的,因為它的所有方法都是靜態的),還有一些方法可以隔離狀態,以便在可能加載的CommonJS和ESM之間共享它包的實例:
如果可能,在實例化對象中包含所有狀態。比如JavaScript的Date,需要實例化包含狀態;如果是包,會這樣使用:
import Date from 'date';
const someDate = new Date();
// someDate 包含狀態;Date 不包含
new關鍵字不是必需的;包的函數可以返回新的對象,或修改傳入的對象,以保持包外部的狀態。
在包的CommonJS和ESM版本之間共享的一個或過個CommonJS文件中隔離狀態。比如CommonJS和ESM入口點分別是index.cjs和index.mjs:
// index.cjs
const state = require('./state.cjs')
module.exports.state = state;
// index.mjs
import state from './state.cjs'
export {
state
}
即使example在應用程序中通過require和import使用example的每個引用都包含相同的狀態;并且任一模塊系統修改狀態將適用二者皆是。
“node中的模塊系統原理是什么”的內容就介紹到這里了,感謝大家的閱讀。如果想了解更多行業相關的知識可以關注億速云網站,小編將為大家輸出更多高質量的實用文章!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。