node.js極速入門課程:進入學習
兩年前寫過一篇文章介紹模組系統:理解前端模組概念:CommonJs與ES6Module。這篇文章的知識面都是針對剛入門的,比較淺顯。在這也糾正文章的幾個錯誤:
【模組】和【模組系統】 是兩碼事。模組是軟體中的一個單元,而模組系統則是一套語法或工具,模組系統能讓開發者在專案中定義和使用模組。
ECMAScript Module縮寫是ESM,或是ESModule,而不是ES6Module。
關於模組系統的基礎知識都在上一篇文章說的差不多了,所以這篇文章會重點關注模組系統的內部原理以及更加完整的介紹不同模組系統之間的區別,上一篇文章出現的內容在這就不再重複了。
並不是所有程式語言都有內建的模組系統,JavaScript誕生後的很長一段時間都沒有模組系統。
在瀏覽器環境中只能使用標籤來引入不用的程式碼文件,這種方法共享一個全域作用域,可謂是問題多多;加上前端日新月異的發展,這種方法已經不滿足當下的需求了。在沒官方的模組系統出現前,前端社群自己創建第三方模組系統,用的較多的有:非同步模組定義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 } 從 'url' import { dirname } 從 '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.js官方文檔
Node.js Design Patterns