兩年前寫過一篇文章介紹模組系統:理解前端模組概念:CommonJs與ES6Module。這篇文章的知識面都是針對剛入門的,比較淺顯。在這也修正文章的幾個錯誤:
關於模組系統的基礎知識都在上一篇文章說的差不多了,所以這篇文章會重點關注模組系統的內部原理以及更加完整的介紹不同模組系統的差異,上一篇文章出現的內容在這就不再重複了。
並不是所有程式語言都有內建的模組系統,JavaScript誕生後的很長一段時間都沒有模組系統。
在瀏覽器環境中只能使用<script></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導出。後面的模組系統也正是基於這樣的思路而開發的。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
用于JSONapplication/wasm
用于Wasmimport '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之间共享它包的实例:
import Date from 'date'; const someDate = new Date(); // someDate 包含状态;Date 不包含
new关键字不是必需的;包的函数可以返回新的对象,或修改传入的对象,以保持包外部的状态。
// 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相关知识,请访问:nodejs 教程!
以上是一文解析node中的模組系統的詳細內容。更多資訊請關注PHP中文網其他相關文章!