高級前端理解的CommonJS模塊和ESM模塊
阮一峰在 ES6 入門 中提到 ES6 模塊與 CommonJS 模塊有一些重大的差異:
CommonJS 模塊輸出的是一個值的拷貝,ES6 模塊輸出的是值的引用。
CommonJS 模塊是運行時加載,ES6 模塊是編譯時輸出接口。
再細(xì)讀上面阮老師提到的差異,會產(chǎn)生諸多疑問:
為什么 CommonJS 模塊輸出的是一個值的拷貝?其具體細(xì)節(jié)是什么樣子的?
什么叫 運行時加載?
什么叫 編譯時輸出接口?
為什么 ES6 模塊輸出的是值的引用?
于是就有了這篇文章,力求把 ESM 模塊 和 CommonJS 模塊 討論清楚。
CommonJS 產(chǎn)生的歷史背景
CommonJS 由 Mozilla 工程師 Kevin Dangoor 于 2009 年 1 月創(chuàng)立,最初命名為ServerJS。2009 年 8 月,該項目更名為CommonJS。旨在解決 Javascript 中缺少模塊化標(biāo)準(zhǔn)的問題。
Node.js 后來也采用了 CommonJS 的模塊規(guī)范。
由于 CommonJS 并不是 ECMAScript 標(biāo)準(zhǔn)的一部分,所以 類似 module 和 require 并不是 JS 的關(guān)鍵字,僅僅是對象或者函數(shù)而已,意識到這一點很重要。
我們可以在打印 module、require 查看細(xì)節(jié):
console.log(module);
console.log(require);
// out:
Module {
id: '.',
path: '/Users/xxx/Desktop/esm_commonjs/commonJS',
exports: {},
filename: '/Users/xxx/Desktop/esm_commonjs/commonJS/c.js',
loaded: false,
children: [],
paths: [
'/Users/xxx/Desktop/esm_commonjs/commonJS/node_modules',
'/Users/xxx/Desktop/esm_commonjs/node_modules',
'/Users/xxx/Desktop/node_modules',
'/Users/xxx/node_modules',
'/Users/node_modules',
'/node_modules'
]
}
[Function: require] {
resolve: [Function: resolve] { paths: [Function: paths] },
main: Module {
id: '.',
path: '/Users/xxx/Desktop/esm_commonjs/commonJS',
exports: {},
filename: '/Users/xxx/Desktop/esm_commonjs/commonJS/c.js',
loaded: false,
children: [],
paths: [
'/Users/xxx/Desktop/esm_commonjs/commonJS/node_modules',
'/Users/xxx/Desktop/esm_commonjs/node_modules',
'/Users/xxx/Desktop/node_modules',
'/Users/xxx/node_modules',
'/Users/node_modules',
'/node_modules'
]
},
extensions: [Object: null prototype] {
'.js': [Function (anonymous)],
'.json': [Function (anonymous)],
'.node': [Function (anonymous)]
},
cache: [Object: null prototype] {
'/Users/xxx/Desktop/esm_commonjs/commonJS/c.js': Module {
id: '.',
path: '/Users/xxx/Desktop/esm_commonjs/commonJS',
exports: {},
filename: '/Users/xxx/Desktop/esm_commonjs/commonJS/c.js',
loaded: false,
children: [],
paths: [Array]
}
}
}
可以看到 module 是一個對象, require 是一個函數(shù),僅此而已。
我們來重點介紹下 module 中的一些屬性:
exports:這就是 module.exports 對應(yīng)的值,由于還沒有賦任何值給它,它目前是一個空對象。
loaded:表示當(dāng)前的模塊是否加載完成。
paths:node 模塊的加載路徑,這塊不展開講,感興趣可以看node 文檔
require 函數(shù)中也有一些值得注意的屬性:
main 指向當(dāng)前當(dāng)前引用自己的模塊,所以類似 python 的 __name__ == '__main__', node 也可以用 require.main === module 來確定是否是以當(dāng)前模塊來啟動程序的。
extensions 表示目前 node 支持的幾種加載模塊的方式。
cache 表示 node 中模塊加載的緩存,也就是說,當(dāng)一個模塊加載一次后,之后 require 不會再加載一次,而是從緩存中讀取。
前面提到,CommonJS 中 module 是一個對象, require 是一個函數(shù)。而與此相對應(yīng)的 ESM 中的 import 和 export 則是關(guān)鍵字,是 ECMAScript 標(biāo)準(zhǔn)的一部分。理解這兩者的區(qū)別非常關(guān)鍵。
先看幾個 CommonJS 例子
大家看看下面幾個 CommonJS 例子,看看能不能準(zhǔn)確預(yù)測結(jié)果:
例一,在模塊外為簡單類型賦值:
// a.js
let val = 1;
const setVal = (newVal) => {
val = newVal
}
module.exports = {
val,
setVal
}
// b.js
const { val, setVal } = require('./a.js')
console.log(val);
setVal(101);
console.log(val);
運行 b.js,輸出結(jié)果為:
1
1
例二,在模塊外為引用類型賦值:
// a.js
let obj = {
val: 1
};
const setVal = (newVal) => {
obj.val = newVal
}
module.exports = {
obj,
setVal
}
// b.js
const { obj, setVal } = require('./a.js')
console.log(obj);
setVal(101);
console.log(obj);
運行 b.js,輸出結(jié)果為:
{ val: 1 }
{ val: 101 }
例三,在模塊內(nèi)導(dǎo)出后改變簡單類型:
// a.js
let val = 1;
setTimeout(() => {
val = 101;
}, 100)
module.exports = {
val
}
// b.js
const { val } = require('./a.js')
console.log(val);
setTimeout(() => {
console.log(val);
}, 200)
運行 b.js,輸出結(jié)果為:
1
1
例四,在模塊內(nèi)導(dǎo)出后用 module.exports 再導(dǎo)出一次:
// a.js
setTimeout(() => {
module.exports = {
val: 101
}
}, 100)
module.exports = {
val: 1
}
// b.js
const a = require('./a.js')
console.log(a);
setTimeout(() => {
console.log(a);
}, 200)
運行 b.js,輸出結(jié)果為:
{ val: 1 }
{ val: 1 }
例五,在模塊內(nèi)導(dǎo)出后用 exports 再導(dǎo)出一次:
// a.js
setTimeout(() => {
module.exports.val = 101;
}, 100)
module.exports.val = 1
// b.js
const a = require('./a.js')
console.log(a);
setTimeout(() => {
console.log(a);
}, 200)
運行 b.js,輸出結(jié)果為:
{ val: 1 }
{ val: 101 }
如何解釋上面的例子?沒有魔法!一言道破 CommonJS 值拷貝的細(xì)節(jié)
拿出 JS 最樸素的思維,來分析上面例子的種種現(xiàn)象。
例一中,代碼可以簡化為:
const myModule = {
exports: {}
}
let val = 1;
const setVal = (newVal) => {
val = newVal
}
myModule.exports = {
val,
setVal
}
const { val: useVal, setVal: useSetVal } = myModule.exports
console.log(useVal);
useSetVal(101)
console.log(useVal);
例二中,代碼可以簡化為:
const myModule = {
exports: {}
}
let obj = {
val: 1
};
const setVal = (newVal) => {
obj.val = newVal
}
myModule.exports = {
obj,
setVal
}
const { obj: useObj, setVal: useSetVal } = myModule.exports
console.log(useObj);
useSetVal(101)
console.log(useObj);
例三中,代碼可以簡化為:
const myModule = {
exports: {}
}
let val = 1;
setTimeout(() => {
val = 101;
}, 100)
myModule.exports = {
val
}
const { val: useVal } = myModule.exports
console.log(useVal);
setTimeout(() => {
console.log(useVal);
}, 200)
例四中,代碼可以簡化為:
const myModule = {
exports: {}
}
setTimeout(() => {
myModule.exports = {
val: 101
}
}, 100)
myModule.exports = {
val: 1
}
const useA = myModule.exports
console.log(useA);
setTimeout(() => {
console.log(useA);
}, 200)
例五中,代碼可以簡化為:
const myModule = {
exports: {}
}
setTimeout(() => {
myModule.exports.val = 101;
}, 100)
myModule.exports.val = 1;
const useA = myModule.exports
console.log(useA);
setTimeout(() => {
console.log(useA);
}, 200)
嘗試運行上面的代碼,可以發(fā)現(xiàn)和 CommonJS 輸出的效果一致。所以 CommonJS 不是什么魔法,僅僅是日常寫的最簡簡單單的 JS 代碼。
其值拷貝發(fā)生在給 module.exports 賦值的那一刻,例如:
let val = 1;
module.exports = {
val
}
做的事情僅僅是給 module.exports 賦予了一個新的對象,在這個對象里有一個key叫做 val,這個 val 的值是當(dāng)前模塊中 val 的值,僅此而已。
CommonJS 的具體實現(xiàn)
為了更透徹的了解 CommonJS,我們來寫一個簡單的模塊加載器,主要參考了 nodejs 源碼;
在 node v16.x 中 module 主要實現(xiàn)在 lib/internal/modules/cjs/loader.js 文件下。
在 node v4.x 中 module 主要實現(xiàn)在 lib/module.js 文件下。
下面的實現(xiàn)主要參考了 node v4.x 中的實現(xiàn),因為老版本相對更“干凈”一些,更容易抓住細(xì)節(jié)。
另外 深入Node.js的模塊加載機制,手寫require函數(shù) 這篇文章寫的也很不錯,下面的實現(xiàn)很多也參考了這篇文章。
為了跟官方Module名字區(qū)分開,我們自己的類命名為MyModule:
function MyModule(id = '') {
this.id = id; // 模塊路徑
this.exports = {}; // 導(dǎo)出的東西放這里,初始化為空對象
this.loaded = false; // 用來標(biāo)識當(dāng)前模塊是否已經(jīng)加載
}
require方法
我們一直用的 require 其實是 Module 類的一個實例方法,內(nèi)容很簡單,先做一些參數(shù)檢查,然后調(diào)用 Module._load 方法,源碼在這里,本示例為了簡潔,去掉了一些判斷:
MyModule.prototype.require = function (id) {
return MyModule._load(id);
}
require 是一個很簡單函數(shù),主要是包裝了 _load 函數(shù),這個函數(shù)主要做了如下事情:
先檢查請求的模塊在緩存中是否已經(jīng)存在了,如果存在了直接返回緩存模塊的 exports
如果不在緩存中,就創(chuàng)建一個 Module 實例,將該實例放到緩存中,用這個實例加載對應(yīng)的模塊,并返回模塊的 exports
MyModule._load = function (request) { // request是傳入的路徑
const filename = MyModule._resolveFilename(request);
// 先檢查緩存,如果緩存存在且已經(jīng)加載,直接返回緩存
const cachedModule = MyModule._cache[filename];
if (cachedModule) {
return cachedModule.exports;
}
// 如果緩存不存在,我們就加載這個模塊
constmodule = newMyModule(filename);
// load之前就將這個模塊緩存下來,這樣如果有循環(huán)引用就會拿到這個緩存,但是這個緩存里面的exports可能還沒有或者不完整
MyModule._cache[filename] = module;
// 如果 load 失敗,需要將 _cache 中相應(yīng)的緩存刪掉。這里簡單起見,不做這個處理
module.load(filename);
returnmodule.exports;
}
可以看到上述源碼還調(diào)用了兩個方法:MyModule._resolveFilename 和 MyModule.prototype.load,下面我們來實現(xiàn)下這兩個方法。
MyModule._resolveFilename
這個函數(shù)的作用是通過用戶傳入的 require 參數(shù)來解析到真正的文件地址,源碼中這個方法比較復(fù)雜,因為他要支持多種參數(shù):內(nèi)置模塊,相對路徑,絕對路徑,文件夾和第三方模塊等等。
本示例為了簡潔,只實現(xiàn)相對文件的導(dǎo)入:
MyModule._resolveFilename = function (request) {
returnpath.resolve(request);
}
MyModule.prototype.load
MyModule.prototype.load 是一個實例方法,源代碼在這里,這個方法就是真正用來加載模塊的方法,這其實也是不同類型文件加載的一個入口,不同類型的文件會對應(yīng) MyModule._extensions 里面的一個方法:
MyModule.prototype.load = function (filename) {
// 獲取文件后綴名
const extname = path.extname(filename);
// 調(diào)用后綴名對應(yīng)的處理函數(shù)來處理,當(dāng)前實現(xiàn)只支持 JS
MyModule._extensions[extname](this, filename);
this.loaded = true;
}
加載文件: MyModule._extensions['X']
前面提到不同文件類型的處理方法都掛載在 MyModule._extensions 上,事實上 node 的加載器不僅僅可以加載 .js 模塊,也可以加載 .json 和 .node 模塊。本示例簡單起見僅實現(xiàn) .js 類型文件的加載:
MyModule._extensions['.js'] = function (module, filename) {
const content = fs.readFileSync(filename, 'utf8');
module._compile(content, filename);
}
可以看到j(luò)s的加載方法很簡單,只是把文件內(nèi)容讀出來,然后調(diào)了另外一個實例方法 _compile 來執(zhí)行他。對應(yīng)的源碼在這里。
_compile 實現(xiàn)
MyModule.prototype._compile 是加載JS文件的核心所在,這個方法需要將目標(biāo)文件拿出來執(zhí)行一遍。對應(yīng)的源碼在這里。
_compile 主要做了如下事情:
1、執(zhí)行之前需要將它整個代碼包裹一層,以便注入 exports, require, module, __dirname, __filename,這也是我們能在JS文件里面直接使用這幾個變量的原因。要實現(xiàn)這種注入也不難,假如我們 require 的文件是一個簡單的 Hello World,長這樣:
module.exports = "hello world";
那我們怎么來給他注入 module 這個變量呢?答案是執(zhí)行的時候在他外面再加一層函數(shù),使他變成這樣:
function (module) { // 注入module變量,其實幾個變量同理
module.exports = "hello world";
}
nodeJS 也是這樣實現(xiàn)的,在node源碼里,會有這樣的代碼:
NativeModule.wrap = function(script) {
return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};
NativeModule.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
這樣通過MyModule.wrap包裝的代碼就可以獲取到 exports, require, module, __filename, __dirname 這幾個變量了。
2、放入沙盒里執(zhí)行包裝好的代碼,并返回模塊的 export。沙盒執(zhí)行使用了 node 的 vm 模塊。
在本實現(xiàn)中,_compile 實現(xiàn)如下:
MyModule.prototype._compile = function (content, filename) {
var self = this;
// 獲取包裝后函數(shù)體
const wrapper = MyModule.wrap(content);
// vm是nodejs的虛擬機沙盒模塊,runInThisContext方法可以接受一個字符串并將它轉(zhuǎn)化為一個函數(shù)
// 返回值就是轉(zhuǎn)化后的函數(shù),所以compiledWrapper是一個函數(shù)
const compiledWrapper = vm.runInThisContext(wrapper, {
filename
});
const dirname = path.dirname(filename);
const args = [self.exports, self.require, self, filename, dirname];
return compiledWrapper.apply(self.exports, args);
}
wrapper 和 warp 的實現(xiàn)如下:
MyModule.wrapper = [
'(function (myExports, myRequire, myModule, __filename, __dirname) { ',
'\n});'
];
MyModule.wrap = function (script) {
return MyModule.wrapper[0] + script + MyModule.wrapper[1];
};
注意上面的 wrapper 中我們使用了 myRequire 和 myModule 來區(qū)分原生的 require 和 module, 下面的例子中我們會使用自己實現(xiàn)的函數(shù)來加載文件。
最后生成一個實例并導(dǎo)出
最后我們 new 一個 MyModule 的實理并導(dǎo)出,方便外面使用:
const myModuleInstance = new MyModule();
const MyRequire = (id) => {
return myModuleInstance.require(id);
}
module.exports = {
MyModule,
MyRequire
}
完整代碼
最后的完整代碼如下:
const path = require('path');
const vm = require('vm');
const fs = require('fs');
function MyModule(id = '') {
this.id = id; // 模塊路徑
this.exports = {}; // 導(dǎo)出的東西放這里,初始化為空對象
this.loaded = false; // 用來標(biāo)識當(dāng)前模塊是否已經(jīng)加載
}
MyModule._cache = {};
MyModule._extensions = {};
MyModule.wrapper = [
'(function (myExports, myRequire, myModule, __filename, __dirname) { ',
'\n});'
];
MyModule.wrap = function (script) {
return MyModule.wrapper[0] + script + MyModule.wrapper[1];
};
MyModule.prototype.require = function (id) {
return MyModule._load(id);
}
MyModule._load = function (request) { // request是傳入的路徑
const filename = MyModule._resolveFilename(request);
// 先檢查緩存,如果緩存存在且已經(jīng)加載,直接返回緩存
const cachedModule = MyModule._cache[filename];
if (cachedModule) {
return cachedModule.exports;
}
// 如果緩存不存在,我們就加載這個模塊
// 加載前先new一個MyModule實例,然后調(diào)用實例方法load來加載
// 加載完成直接返回module.exports
const module = new MyModule(filename);
// load之前就將這個模塊緩存下來,這樣如果有循環(huán)引用就會拿到這個緩存,但是這個緩存里面的exports可能還沒有或者不完整
MyModule._cache[filename] = module;
// 如果 load 失敗,需要將 _cache 中相應(yīng)的緩存刪掉。這里簡單起見,不做這個處理
module.load(filename);
return module.exports;
}
MyModule._resolveFilename = function (request) {
return path.resolve(request);
}
MyModule.prototype.load = function (filename) {
// 獲取文件后綴名
const extname = path.extname(filename);
// 調(diào)用后綴名對應(yīng)的處理函數(shù)來處理,當(dāng)前實現(xiàn)只支持 JS
MyModule._extensions[extname](this, filename);
this.loaded = true;
}
MyModule._extensions['.js'] = function (module, filename) {
var content = fs.readFileSync(filename, 'utf8');
module._compile(content, filename);
};
MyModule.prototype._compile = function (content, filename) {
var self = this;
// 獲取包裝后函數(shù)體
const wrapper = MyModule.wrap(content);
// vm是nodejs的虛擬機沙盒模塊,runInThisContext方法可以接受一個字符串并將它轉(zhuǎn)化為一個函數(shù)
// 返回值就是轉(zhuǎn)化后的函數(shù),所以compiledWrapper是一個函數(shù)
const compiledWrapper = vm.runInThisContext(wrapper, {
filename
});
const dirname = path.dirname(filename);
const args = [self.exports, self.require, self, filename, dirname];
return compiledWrapper.apply(self.exports, args);
}
const myModuleInstance = new MyModule();
const MyRequire = (id) => {
return myModuleInstance.require(id);
}
module.exports = {
MyModule,
MyRequire
}
題外話:源代碼中的 require 是如何實現(xiàn)的?
細(xì)心的讀者會發(fā)現(xiàn): nodejs v4.x 源碼中實現(xiàn) require 的文件 lib/module.js 中,也使用到了 require 函數(shù)。
這似乎產(chǎn)生是先有雞還是先有蛋的悖論,我還沒把你造出來,你怎么就用起來了?
事實上,源碼中的 require 有另外簡單的實現(xiàn),它被定義在 src/node.js 中,源碼在這里。
用自定義的 MyModule 來加載文件
剛剛我們實現(xiàn)了一個簡單的 Module,但是能不能正常用還存疑。是騾子是馬拉出來遛遛,我們用自己的 MyModule 來加載文件,看看能不能正常運行。
可以查看 demos/01,代碼的入口為 app.js:
const { MyRequire } = require('./myModule.js');
MyRequire('./b.js');
b.js 的代碼如下:
const { obj, setVal } = myRequire('./a.js')
console.log(obj);
setVal(101);
console.log(obj);
可以看到現(xiàn)在我們用 myRequire 取代 require 來加載 ./a.js 模塊。
再看看 ./a.js 的代碼:
let obj = {
val: 1
};
const setVal = (newVal) => {
obj.val = newVal
}
myModule.exports = {
obj,
setVal
}
可以看到現(xiàn)在我們用 myModule 取代 module 來導(dǎo)出模塊。
最后執(zhí)行 node app.js 查看運行結(jié)果:
{ val: 1 }
{ val: 101 }
可以看到最終效果和使用原生的 module 模塊一致。
用自定義的 MyModule 來測試循環(huán)引用
在這之前,我們先看看原生的 module 模塊的循環(huán)引用會發(fā)生什么異常。可以查看 demos/02,代碼的入口為 app.js:
require('./a.js')
看看 ./a.js 的代碼:
const { b, setB } = require('./b.js');
console.log('running a.js');
console.log('b val', b);
console.log('setB to bb');
setB('bb')
let a = 'a';
const setA = (newA) => {
a = newA;
}
module.exports = {
a,
setA
}
再看看 ./b.js 的代碼:
const { a, setA } = require('./a.js');
console.log('running b.js');
console.log('a val', a);
console.log('setA to aa');
setA('aa')
let b = 'b';
const setB = (newB) => {
b = newB;
}
module.exports = {
b,
setB
}
可以看到 ./a.js 和 ./b.js 在文件的開頭都相互引用了對方。
執(zhí)行 node app.js 查看運行結(jié)果:
running b.js
a val undefined
setA to aa
/Users/xxx/Desktop/esm_commonjs/demos/02/b.js:9
setA('aa')
^
TypeError: setA is not a function
at Object.<anonymous> (/Users/xxx/Desktop/esm_commonjs/demos/02/b.js:9:1)
at xxx
我們會發(fā)現(xiàn)一個 TypeError 的異常報錯,提示 setA is not a function。這樣的異常在預(yù)期之內(nèi),我們再試試自己實現(xiàn)的 myModule 的異常是否和原生 module 的行為一致。
我們查看 demos/03,這里我們用自己的 myModule 來復(fù)現(xiàn)上面的循環(huán)引用,代碼的入口為 app.js:
const { MyRequire } = require('./myModule.js');
MyRequire('./a.js');
a.js 的代碼如下:
const { b, setB } = myRequire('./b.js');
console.log('running a.js');
console.log('b val', b);
console.log('setB to bb');
setB('bb')
let a = 'a';
const setA = (newA) => {
a = newA;
}
myModule.exports = {
a,
setA
}
再看看 ./b.js 的代碼:
const { a, setA } = myRequire('./a.js');
console.log('running b.js');
console.log('a val', a);
console.log('setA to aa');
setA('aa')
let b = 'b';
const setB = (newB) => {
b = newB;
}
myModule.exports = {
b,
setB
}
可以看到現(xiàn)在我們用 myRequire 取代了 require,用 myModule 取代了 module。
最后執(zhí)行 node app.js 查看運行結(jié)果:
running b.js
a val undefined
setA to aa
/Users/xxx/Desktop/esm_commonjs/demos/03/b.js:9
setA('aa')
^
TypeError: setA is not a function
at Object.<anonymous> (/Users/xxx/Desktop/esm_commonjs/demos/03/b.js:9:1)
at xxx
可以看到,myModule 的行為和原生 Module 處理循環(huán)引用的異常是一致的。
疑問:為什么 CommonJS 相互引用沒有產(chǎn)生類似“死鎖”的問題?
我們可以發(fā)現(xiàn) CommonJS 模塊相互引用時,沒有產(chǎn)生類似死鎖的問題。關(guān)鍵在 Module._load 函數(shù)里,具體源代碼在這里。Module._load 函數(shù)主要做了下面這些事情:
檢查緩存,如果緩存存在且已經(jīng)加載,直接返回緩存,不做下面的處理
如果緩存不存在,新建一個 Module 實例
將這個 Module 實例放到緩存中
通過這個 Module 實例來加載文件
返回這個 Module 實例的 exports
其中的關(guān)鍵在 放到緩存中 與 加載文件 的順序,在我們的 MyModule 中,也就是這兩行代碼:
MyModule._cache[filename] = module;
module.load(filename);
回到上面循環(huán)加載的例子中,解釋一下到底發(fā)生了什么:
當(dāng) app.js 加載 a.js 時,Module 會檢查緩存中有沒有 a.js,發(fā)現(xiàn)沒有,于是 new 一個 a.js 模塊,并將這個模塊放到緩存中,再去加載 a.js 文件本身。
在加載 a.js 文件時,Module 發(fā)現(xiàn)第一行是加載 b.js,它會檢查緩存中有沒有 b.js,發(fā)現(xiàn)沒有,于是 new 一個 b.js 模塊,并將這個模塊放到緩存中,再去加載 b.js 文件本身。
在加載 b.js 文件時,Module 發(fā)現(xiàn)第一行是加載 a.js,它會檢查緩存中有沒有 a.js,發(fā)現(xiàn)存在,于是 require 函數(shù)返回了緩存中的 a.js。
但是其實這個時候 a.js 根本還沒有執(zhí)行完,還沒走到 module.exports 那一步,所以 b.js 中 require('./a.js') 返回的只是一個默認(rèn)的空對象。所以最終會報 setA is not a function 的異常。
說到這里,那如何設(shè)計會導(dǎo)致“死鎖”呢?其實也很簡單 —— 將 放到緩存中 與 加載文件 的執(zhí)行順序互換,在我們的 MyModule 代碼中,也就是這樣寫:
module.load(filename);
MyModule._cache[filename] = module;
這樣互換一下,再執(zhí)行 demo03,我們發(fā)現(xiàn)異常如下:
RangeError: Maximum call stack size exceeded
at console.value (node:internal/console/constructor:290:13)
at console.log (node:internal/console/constructor:360:26)
我們發(fā)現(xiàn)這樣寫會死鎖,最終導(dǎo)致 JS 報棧溢出異常。
JavaScript 的執(zhí)行過程
接下來我們要講解 ESM 的模塊導(dǎo)入,為了方便理解 ESM 的模塊導(dǎo)入,這里需要補充一個知識點 —— JavaScript 的執(zhí)行過程。
JavaScript 執(zhí)行過程分為兩個階段:
編譯階段
執(zhí)行階段
編譯階段
在編譯階段 JS 引擎主要做了三件事:
詞法分析
語法分析
字節(jié)碼生成
這里不詳情講這三件事的具體細(xì)節(jié),感興趣的讀者可以閱讀 the-super-tiny-compiler 這個倉庫,它通過幾百行的代碼實現(xiàn)了一個微形編譯器,并詳細(xì)講了這三個過程的具體細(xì)節(jié)。
執(zhí)行階段
在執(zhí)行階段,會分情況創(chuàng)建各種類型的執(zhí)行上下文,例如:全局執(zhí)行上下文 (只有一個)、函數(shù)執(zhí)行上下文。而執(zhí)行上下文的創(chuàng)建分為兩個階段:
創(chuàng)建階段
執(zhí)行階段
在創(chuàng)建階段會做如下事情:
綁定 this
為函數(shù)和變量分配內(nèi)存空間
初始化相關(guān)變量為 undefined
我們?nèi)粘L岬降?變量提升 和 函數(shù)提升 就是在 創(chuàng)建階段 做的,所以下面的寫法并不會報錯:
console.log(msg);
add(1,2)
var msg = 'hello'
functionadd(a,b){
return a + b;
}
因為在執(zhí)行之前的創(chuàng)建階段,已經(jīng)分配好了 msg 和 add 的內(nèi)存空間。
JavaScript 的常見報錯類型
為了更容易理解 ESM 的模塊導(dǎo)入,這里再補充一個知識點 —— JavaScript 的常見報錯類型。
1、RangeError
這類錯誤很常見,例如棧溢出就是 RangeError;
functiona () {
b()
}
functionb () {
a()
}
a()
// out:
// RangeError: Maximum call stack size exceeded
2、ReferenceError
ReferenceError 也很常見,打印一個不存在的值就是 ReferenceError:
hello
// out:
// ReferenceError: hello is not defined
3、SyntaxError
SyntaxError 也很常見,當(dāng)語法不符合 JS 規(guī)范時,就會報這種錯誤:
console.log(1));
// out:
// console.log(1));
// ^
// SyntaxError: Unexpected token ')'
4、TypeError
TypeError 也很常見,當(dāng)一個基礎(chǔ)類型當(dāng)作函數(shù)來用時,就會報這個錯誤:
vara = 1;
a()
// out:
// TypeError: a is not a function
上面的各種 Error 類型中,SyntaxError 最為特殊,因為它是 編譯階段 拋出來的錯誤,如果發(fā)生語法錯誤,JS 代碼一行都不會執(zhí)行。而其他類型的異常都是 執(zhí)行階段 的錯誤,就算報錯,也會執(zhí)行異常之前的腳本。
什么叫 編譯時輸出接口? 什么叫 運行時加載?
ESM 之所以被稱為 編譯時輸出接口,是因為它的模塊解析是發(fā)生在 編譯階段。
也就是說,import 和 export 這些關(guān)鍵字是在編譯階段就做了模塊解析,這些關(guān)鍵字的使用如果不符合語法規(guī)范,在編譯階段就會拋出語法錯誤。
例如,根據(jù) ES6 規(guī)范,import 只能在模塊頂層聲明,所以下面的寫法會直接報語法錯誤,不會有 log 打印,因為它壓根就沒有進(jìn)入 執(zhí)行階段:
console.log('hello world');
if (true) {
import { resolve } from'path';
}
// out:
// import { resolve } from 'path';
// ^
// SyntaxError: Unexpected token '{'
與此對應(yīng)的 CommonJS,它的模塊解析發(fā)生在 執(zhí)行階段,因為 require 和 module 本質(zhì)上就是個函數(shù)或者對象,只有在 執(zhí)行階段 運行時,這些函數(shù)或者對象才會被實例化。因此被稱為 運行時加載。
這里要特別強調(diào),與CommonJS 不同,ESM 中 import 的不是對象, export 的也不是對象。例如,下面的寫法會提示語法錯誤:
// 語法錯誤!這不是解構(gòu)?。?!
import { a: myA } from './a.mjs'
// 語法錯誤!
export {
a: "a"
}
import 和 export 的用法很像導(dǎo)入一個對象或者導(dǎo)出一個對象,但這和對象完全沒有關(guān)系。他們的用法是 ECMAScript 語言層面的設(shè)計的,并且“恰巧”的對象的使用類似。
所以在編譯階段,import 模塊中引入的值就指向了 export 中導(dǎo)出的值。如果讀者了解 linux,這就有點像 linux 中的硬鏈接,指向同一個 inode?;蛘吣脳:投褋肀扔?,這就像兩個指針指向了同一個棧。
ESM 的加載細(xì)節(jié)
在講解ESM 的加載細(xì)節(jié)之前,我們要了解 ESM 中也存在 變量提升 和 函數(shù)提升 ,意識到這一點非常重要。
拿前面 demos/02 中提到的循環(huán)引用舉例子,將其改造為 ESM 版的循環(huán)引用,查看 demos/04,代碼的入口為 app.js:
import'./a.mjs';
看看 ./a.mjs 的代碼:
import { b, setB } from'./b.mjs';
console.log('running a.mjs');
console.log('b val', b);
console.log('setB to bb');
setB('bb')
let a = 'a';
constsetA = (newA) => {
a = newA;
}
export {
a,
setA
}
再看看 ./b.mjs 的代碼:
import { a, setA } from'./a.mjs';
console.log('running b.mjs');
console.log('a val', a);
console.log('setA to aa');
setA('aa')
let b = 'b';
constsetB = (newB) => {
b = newB;
}
export {
b,
setB
}
可以看到 ./a.mjs 和 ./b.mjs 在文件的開頭都相互引用了對方。
執(zhí)行 node app.mjs 查看運行結(jié)果:
running b.mjs
file:///Users/xxx/Desktop/esm_commonjs/demos/04/b.mjs:5
console.log('a val', a);
^
ReferenceError: Cannot access 'a' before initialization
at file:///Users/xxx/Desktop/esm_commonjs/demos/04/b.mjs:5:22
我們會發(fā)現(xiàn)一個 ReferenceError 的異常報錯,提示不能在初始化之前使用變量。這是因為我們使用了 let 定義變量,使用了 const 定義函數(shù),導(dǎo)致無法做變量和函數(shù)提升。
怎么修改才能正常運行呢?其實很簡單:用 var 代替 let,使用 function 來定義函數(shù),我們查看 demos/05 來看效果:
看看 ./a.mjs 的代碼:
console.log('b val', b);
console.log('setB to bb');
setB('bb')
var a = 'a';
functionsetA(newA) {
a = newA;
}
export {
a,
setA
}
再看看 ./b.mjs 的代碼:
import { a, setA } from'./a.mjs';
console.log('running b.mjs');
console.log('a val', a);
console.log('setA to aa');
setA('aa')
var b = 'b';
functionsetB(newB) {
b = newB;
}
export {
b,
setB
}
執(zhí)行 node app.mjs 查看運行結(jié)果:
running b.mjs
a val undefined
setA to aa
running a.mjs
b val b
setB to bb
可以發(fā)現(xiàn)這樣修改后可以正常執(zhí)行,沒有出現(xiàn)異常報錯。
寫到這里我們可以詳細(xì)談?wù)?ESM 的加載細(xì)節(jié)了,它其實和前面提到的 CommonJS 的 Module._load 函數(shù)做的事情有些類似:
檢查緩存,如果緩存存在且已經(jīng)加載,則直接從緩存模塊中提取相應(yīng)的值,不做下面的處理
如果緩存不存在,新建一個 Module 實例
將這個 Module 實例放到緩存中
通過這個 Module 實例來加載文件
加載文件后到全局執(zhí)行上下文時,會有創(chuàng)建階段和執(zhí)行階段,在創(chuàng)建階段做函數(shù)和變量提升,接著執(zhí)行代碼。
返回這個 Module 實例的 exports
結(jié)合 demos/05 的循環(huán)加載,我們再做一個詳細(xì)的解釋:
當(dāng) app.mjs 加載 a.mjs 時,Module 會檢查緩存中有沒有 a.mjs,發(fā)現(xiàn)沒有,于是 new 一個 a.mjs 模塊,并將這個模塊放到緩存中,再去加載 a.mjs 文件本身。
在加載 a.mjs 文件時,在 創(chuàng)建階段 會為全局上下文中的函數(shù) setA 和 變量 a 分配內(nèi)存空間,并初始化變量 a 為 undefined。在執(zhí)行階段,發(fā)現(xiàn)第一行是加載 b.mjs,它會檢查緩存中有沒有 b.mjs,發(fā)現(xiàn)沒有,于是 new 一個 b.mjs 模塊,并將這個模塊放到緩存中,再去加載 b.mjs 文件本身。
在加載 b.mjs 文件時,在 創(chuàng)建階段 會為全局上下文中的函數(shù) setB 和 變量 b 分配內(nèi)存空間,并初始化變量 b 為 undefined。在執(zhí)行階段,發(fā)現(xiàn)第一行是加載 a.mjs,它會檢查緩存中有沒有 a.mjs,發(fā)現(xiàn)存在,于是 import 返回了緩存中 a.mjs 導(dǎo)出的相應(yīng)的值。
雖然這個時候 a.mjs 根本還沒有執(zhí)行過,但是它的 創(chuàng)建階段 已經(jīng)完成了,即在內(nèi)存中也已經(jīng)存在了 setA 函數(shù)和值為 undefined 的變量 a。所以這時候在 b.mjs 里可以正常打印 a 并使用 setA 函數(shù)而沒有異常拋錯。
再談 ESM 和 CommonJS 的區(qū)別
不同點:this 的指向不同
CommonJS 的 this 指向可以查看源碼:
var args = [self.exports, require, self, filename, dirname];
return compiledWrapper.apply(self.exports, args);
很清楚的可以看到 this 指向的是當(dāng)前 module 的默認(rèn) exports;
而 ESM 由于語言層面的設(shè)計指向的是 undefined。
不同點:__filename,__dirname 在 CommonJS 中存在,在 ESM 中不存在
在 CommonJS 中,模塊的執(zhí)行需要用函數(shù)包起來,并指定一些常用的值,可以查看[源碼](segmentfault.com/a/[node%E6%…
NativeModule.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
所以我們?nèi)植趴梢灾苯佑?__filename、__dirname。而 ESM 沒有這方面的設(shè)計,所以在 ESM 中不能直接使用 __filename 和 __dirname。
相同點:ESM 和 CommonJS 都有緩存
這一點兩種模塊方案一致,都會緩存模塊,模塊加載一次后會緩存起來,后續(xù)再次加載會用緩存里的模塊。
參考文檔
阮一峰:Module 的加載實現(xiàn)
深入Node.js的模塊加載機制,手寫require函數(shù)
commonjs 與 esm 的區(qū)別
The Node.js Way - How require() Actually Works
stackoverflow:How does require() in node.js work?
Node模塊加載機制:展示了一些魔改 require 的場景
docs: ES 模塊和 CommonJS 之間的差異
Requiring modules in Node.js: Everything you need to know
JavaScript Execution Context and Hoisting Explained with Code Examples
深入了解JavaScript執(zhí)行過程(JS系列之一)
JS執(zhí)行過程詳解
7 Types of Native Errors in JavaScript You Should Know
作者:FLY
https://mp.weixin.qq.com/s/cBqbsHGhEW6N8deiMKMFOQ
作者:FLY
歡迎關(guān)注微信公眾號 :前端Q