動態

詳情 返回 返回

深入淺出 ESM 模塊 和 CommonJS 模塊 - 動態 詳情

阮一峯在 ES6 入門 中提到 ES6 模塊與 CommonJS 模塊有一些重大的差異:

  • CommonJS 模塊輸出的是一個值的拷貝,ES6 模塊輸出的是值的引用。
  • CommonJS 模塊是運行時加載,ES6 模塊是編譯時輸出接口。

再細讀上面阮老師提到的差異,會產生諸多疑問:

  • 為什麼 CommonJS 模塊輸出的是一個值的拷貝?其具體細節是什麼樣子的?
  • 什麼叫 運行時加載?
  • 什麼叫 編譯時輸出接口
  • 為什麼 ES6 模塊輸出的是值的引用?

於是就有了這篇文章,力求把 ESM 模塊CommonJS 模塊 討論清楚。

CommonJS 產生的歷史背景

CommonJS 由 Mozilla 工程師 Kevin Dangoor 於 2009 年 1 月創立,最初命名為ServerJS。2009 年 8 月,該項目更名為CommonJS。旨在解決 Javascript 中缺少模塊化標準的問題。

Node.js 後來也採用了 CommonJS 的模塊規範。

由於 CommonJS 並不是 ECMAScript 標準的一部分,所以 類似 modulerequire 並不是 JS 的關鍵字,僅僅是對象或者函數而已,意識到這一點很重要。

我們可以在打印 modulerequire 查看細節:

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 是一個函數,僅此而已。

我們來重點介紹下 module 中的一些屬性:

  • exports:這就是 module.exports 對應的值,由於還沒有賦任何值給它,它目前是一個空對象。
  • loaded:表示當前的模塊是否加載完成。
  • paths:node 模塊的加載路徑,這塊不展開講,感興趣可以看node 文檔

require 函數中也有一些值得注意的屬性:

  • main 指向當前當前引用自己的模塊,所以類似 python 的 __name__ == '__main__', node 也可以用 require.main === module 來確定是否是以當前模塊來啓動程序的。
  • extensions 表示目前 node 支持的幾種加載模塊的方式。
  • cache 表示 node 中模塊加載的緩存,也就是説,當一個模塊加載一次後,之後 require 不會再加載一次,而是從緩存中讀取。

前面提到,CommonJS 中 module 是一個對象, require 是一個函數。而與此相對應的 ESM 中的 importexport 則是關鍵字,是 ECMAScript 標準的一部分。理解這兩者的區別非常關鍵。

先看幾個 CommonJS 例子

大家看看下面幾個 CommonJS 例子,看看能不能準確預測結果:

例一,在模塊外為簡單類型賦值:

// 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,輸出結果為:

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,輸出結果為:

{ val: 1 }
{ val: 101 }

例三,在模塊內導出後改變簡單類型:

// 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,輸出結果為:

1
1

例四,在模塊內導出後用 module.exports 再導出一次:

// 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,輸出結果為:

{ val: 1 }
{ val: 1 }

例五,在模塊內導出後用 exports 再導出一次:

// 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,輸出結果為:

{ val: 1 }
{ val: 101 }

如何解釋上面的例子?沒有魔法!一言道破 CommonJS 值拷貝的細節

拿出 JS 最樸素的思維,來分析上面例子的種種現象。

例一中,代碼可以簡化為:

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)

嘗試運行上面的代碼,可以發現和 CommonJS 輸出的效果一致。所以 CommonJS 不是什麼魔法,僅僅是日常寫的最簡簡單單的 JS 代碼。

其值拷貝發生在給 module.exports 賦值的那一刻,例如:

let val = 1;
module.exports = {
  val
}

做的事情僅僅是給 module.exports 賦予了一個新的對象,在這個對象裏有一個key叫做 val,這個 val 的值是當前模塊中 val 的值,僅此而已。

CommonJS 的具體實現

為了更透徹的瞭解 CommonJS,我們來寫一個簡單的模塊加載器,主要參考了 nodejs 源碼;

在 node v16.x 中 module 主要實現在 lib/internal/modules/cjs/loader.js 文件下。

在 node v4.x 中 module 主要實現在 lib/module.js 文件下。

下面的實現主要參考了 node v4.x 中的實現,因為老版本相對更“乾淨”一些,更容易抓住細節。

另外 深入Node.js的模塊加載機制,手寫require函數 這篇文章寫的也很不錯,下面的實現很多也參考了這篇文章。

為了跟官方Module名字區分開,我們自己的類命名為MyModule:

function MyModule(id = '') {
  this.id = id;             // 模塊路徑
  this.exports = {};        // 導出的東西放這裏,初始化為空對象
  this.loaded = false;      // 用來標識當前模塊是否已經加載
}

require方法

我們一直用的 require 其實是 Module 類的一個實例方法,內容很簡單,先做一些參數檢查,然後調用 Module._load 方法,源碼在這裏,本示例為了簡潔,去掉了一些判斷:

MyModule.prototype.require = function (id) {
  return MyModule._load(id);
}

require 是一個很簡單函數,主要是包裝了 _load 函數,這個函數主要做了如下事情:

  • 先檢查請求的模塊在緩存中是否已經存在了,如果存在了直接返回緩存模塊的 exports
  • 如果不在緩存中,就創建一個 Module 實例,將該實例放到緩存中,用這個實例加載對應的模塊,並返回模塊的 exports
MyModule._load = function (request) {    // request是傳入的路徑
  const filename = MyModule._resolveFilename(request);

  // 先檢查緩存,如果緩存存在且已經加載,直接返回緩存
  const cachedModule = MyModule._cache[filename];
  if (cachedModule) {
    return cachedModule.exports;
  }

  // 如果緩存不存在,我們就加載這個模塊
  const module = new MyModule(filename);

  // load之前就將這個模塊緩存下來,這樣如果有循環引用就會拿到這個緩存,但是這個緩存裏面的exports可能還沒有或者不完整
  MyModule._cache[filename] = module;

  // 如果 load 失敗,需要將 _cache 中相應的緩存刪掉。這裏簡單起見,不做這個處理
  module.load(filename);

  return module.exports;
}

可以看到上述源碼還調用了兩個方法:MyModule._resolveFilenameMyModule.prototype.load,下面我們來實現下這兩個方法。

MyModule._resolveFilename

這個函數的作用是通過用户傳入的 require 參數來解析到真正的文件地址,源碼中這個方法比較複雜,因為他要支持多種參數:內置模塊,相對路徑,絕對路徑,文件夾和第三方模塊等等。

本示例為了簡潔,只實現相對文件的導入:

MyModule._resolveFilename = function (request) {
  return path.resolve(request);
}

MyModule.prototype.load

MyModule.prototype.load 是一個實例方法,源代碼在這裏,這個方法就是真正用來加載模塊的方法,這其實也是不同類型文件加載的一個入口,不同類型的文件會對應 MyModule._extensions 裏面的一個方法:

MyModule.prototype.load = function (filename) {
  // 獲取文件後綴名
  const extname = path.extname(filename);

  // 調用後綴名對應的處理函數來處理,當前實現只支持 JS
  MyModule._extensions[extname](this, filename);

  this.loaded = true;
}

加載文件: MyModule._extensions['X']

前面提到不同文件類型的處理方法都掛載在 MyModule._extensions 上,事實上 node 的加載器不僅僅可以加載 .js 模塊,也可以加載 .json.node 模塊。本示例簡單起見僅實現 .js 類型文件的加載:

MyModule._extensions['.js'] = function (module, filename) {
  const content = fs.readFileSync(filename, 'utf8');
  module._compile(content, filename);
}

可以看到js的加載方法很簡單,只是把文件內容讀出來,然後調了另外一個實例方法 _compile 來執行他。對應的源碼在這裏。

_compile 實現

MyModule.prototype._compile 是加載JS文件的核心所在,這個方法需要將目標文件拿出來執行一遍。對應的源碼在這裏。

_compile 主要做了如下事情:

1、執行之前需要將它整個代碼包裹一層,以便注入 exports, require, module, __dirname, __filename,這也是我們能在JS文件裏面直接使用這幾個變量的原因。要實現這種注入也不難,假如我們 require 的文件是一個簡單的 Hello World,長這樣:

module.exports = "hello world";

那我們怎麼來給他注入 module 這個變量呢?答案是執行的時候在他外面再加一層函數,使他變成這樣:

function (module) { // 注入module變量,其實幾個變量同理
  module.exports = "hello world";
}

nodeJS 也是這樣實現的,在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、放入沙盒裏執行包裝好的代碼,並返回模塊的 export。沙盒執行使用了 node 的 vm 模塊。

在本實現中,_compile 實現如下:

MyModule.prototype._compile = function (content, filename) {
  var self = this;
  // 獲取包裝後函數體
  const wrapper = MyModule.wrap(content);

  // vm是nodejs的虛擬機沙盒模塊,runInThisContext方法可以接受一個字符串並將它轉化為一個函數
  // 返回值就是轉化後的函數,所以compiledWrapper是一個函數
  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);
}

wrapperwarp 的實現如下:

MyModule.wrapper = [
  '(function (myExports, myRequire, myModule, __filename, __dirname) { ',
  '\n});'
];

MyModule.wrap = function (script) {
  return MyModule.wrapper[0] + script + MyModule.wrapper[1];
};
注意上面的 wrapper 中我們使用了 myRequiremyModule 來區分原生的 requiremodule, 下面的例子中我們會使用自己實現的函數來加載文件。

最後生成一個實例並導出

最後我們 new 一個 MyModule 的實理並導出,方便外面使用:

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 = {};        // 導出的東西放這裏,初始化為空對象
  this.loaded = false;      // 用來標識當前模塊是否已經加載
}

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);

  // 先檢查緩存,如果緩存存在且已經加載,直接返回緩存
  const cachedModule = MyModule._cache[filename];
  if (cachedModule) {
    return cachedModule.exports;
  }

  // 如果緩存不存在,我們就加載這個模塊
  // 加載前先new一個MyModule實例,然後調用實例方法load來加載
  // 加載完成直接返回module.exports
  const module = new MyModule(filename);

  // load之前就將這個模塊緩存下來,這樣如果有循環引用就會拿到這個緩存,但是這個緩存裏面的exports可能還沒有或者不完整
  MyModule._cache[filename] = module;

  // 如果 load 失敗,需要將 _cache 中相應的緩存刪掉。這裏簡單起見,不做這個處理
  module.load(filename);

  return module.exports;
}

MyModule._resolveFilename = function (request) {
  return path.resolve(request);
}

MyModule.prototype.load = function (filename) {
  // 獲取文件後綴名
  const extname = path.extname(filename);

  // 調用後綴名對應的處理函數來處理,當前實現只支持 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;
  // 獲取包裝後函數體
  const wrapper = MyModule.wrap(content);    

  // vm是nodejs的虛擬機沙盒模塊,runInThisContext方法可以接受一個字符串並將它轉化為一個函數
  // 返回值就是轉化後的函數,所以compiledWrapper是一個函數
  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 是如何實現的?

細心的讀者會發現: nodejs v4.x 源碼中實現 require 的文件 lib/module.js 中,也使用到了 require 函數。

這似乎產生是先有雞還是先有蛋的悖論,我還沒把你造出來,你怎麼就用起來了?

事實上,源碼中的 require 有另外簡單的實現,它被定義在 src/node.js 中,源碼在這裏)。

用自定義的 MyModule 來加載文件

剛剛我們實現了一個簡單的 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);

可以看到現在我們用 myRequire 取代 require 來加載 ./a.js 模塊。

再看看 ./a.js 的代碼:

let obj = {
  val: 1
};

const setVal = (newVal) => {
  obj.val = newVal
}

myModule.exports = {
  obj,
  setVal
}

可以看到現在我們用 myModule 取代 module 來導出模塊。

最後執行 node app.js 查看運行結果:

{ val: 1 }
{ val: 101 }

可以看到最終效果和使用原生的 module 模塊一致。

用自定義的 MyModule 來測試循環引用

在這之前,我們先看看原生的 module 模塊的循環引用會發生什麼異常。可以查看 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 在文件的開頭都相互引用了對方。

執行 node app.js 查看運行結果:

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

我們會發現一個 TypeError 的異常報錯,提示 setA is not a function。這樣的異常在預期之內,我們再試試自己實現的 myModule 的異常是否和原生 module 的行為一致。

我們查看 demos/03,這裏我們用自己的 myModule 來複現上面的循環引用,代碼的入口為 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
}

可以看到現在我們用 myRequire 取代了 require,用 myModule 取代了 module

最後執行 node app.js 查看運行結果:

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 處理循環引用的異常是一致的。

疑問:為什麼 CommonJS 相互引用沒有產生類似“死鎖”的問題?

我們可以發現 CommonJS 模塊相互引用時,沒有產生類似死鎖的問題。關鍵在 Module._load 函數裏,具體源代碼在這裏。Module._load 函數主要做了下面這些事情:

  1. 檢查緩存,如果緩存存在且已經加載,直接返回緩存,不做下面的處理
  2. 如果緩存不存在,新建一個 Module 實例
  3. 將這個 Module 實例放到緩存中
  4. 通過這個 Module 實例來加載文件
  5. 返回這個 Module 實例的 exports

其中的關鍵在 放到緩存中加載文件 的順序,在我們的 MyModule 中,也就是這兩行代碼:

MyModule._cache[filename] = module;
module.load(filename);

回到上面循環加載的例子中,解釋一下到底發生了什麼:

app.js 加載 a.js 時,Module 會檢查緩存中有沒有 a.js,發現沒有,於是 new 一個 a.js 模塊,並將這個模塊放到緩存中,再去加載 a.js 文件本身。

在加載 a.js 文件時,Module 發現第一行是加載 b.js,它會檢查緩存中有沒有 b.js,發現沒有,於是 new 一個 b.js 模塊,並將這個模塊放到緩存中,再去加載 b.js 文件本身。

在加載 b.js 文件時,Module 發現第一行是加載 a.js,它會檢查緩存中有沒有 a.js,發現存在,於是 require 函數返回了緩存中的 a.js

但是其實這個時候 a.js 根本還沒有執行完,還沒走到 module.exports 那一步,所以 b.jsrequire('./a.js') 返回的只是一個默認的空對象。所以最終會報 setA is not a function 的異常。

説到這裏,那如何設計會導致“死鎖”呢?其實也很簡單 —— 將 放到緩存中加載文件 的執行順序互換,在我們的 MyModule 代碼中,也就是這樣寫:

module.load(filename);
MyModule._cache[filename] = module;

這樣互換一下,再執行 demo03,我們發現異常如下:

RangeError: Maximum call stack size exceeded
    at console.value (node:internal/console/constructor:290:13)
    at console.log (node:internal/console/constructor:360:26)

我們發現這樣寫會死鎖,最終導致 JS 報棧溢出異常。

JavaScript 的執行過程

接下來我們要講解 ESM 的模塊導入,為了方便理解 ESM 的模塊導入,這裏需要補充一個知識點 —— JavaScript 的執行過程

JavaScript 執行過程分為兩個階段:

  • 編譯階段
  • 執行階段

編譯階段

在編譯階段 JS 引擎主要做了三件事:

  • 詞法分析
  • 語法分析
  • 字節碼生成

這裏不詳情講這三件事的具體細節,感興趣的讀者可以閲讀 the-super-tiny-compiler 這個倉庫,它通過幾百行的代碼實現了一個微形編譯器,並詳細講了這三個過程的具體細節。

執行階段

在執行階段,會分情況創建各種類型的執行上下文,例如:全局執行上下文 (只有一個)、函數執行上下文。而執行上下文的創建分為兩個階段:

  • 創建階段
  • 執行階段

在創建階段會做如下事情:

  • 綁定 this
  • 為函數和變量分配內存空間
  • 初始化相關變量為 undefined

我們日常提到的 變量提升 和 函數提升 就是在 創建階段 做的,所以下面的寫法並不會報錯:

console.log(msg);
add(1,2)

var msg = 'hello'
function add(a,b){
  return a + b;
}

因為在執行之前的創建階段,已經分配好了 msgadd 的內存空間。

JavaScript 的常見報錯類型

為了更容易理解 ESM 的模塊導入,這裏再補充一個知識點 —— JavaScript 的常見報錯類型

1、RangeError

這類錯誤很常見,例如棧溢出就是 RangeError

function a () {
  b()
}
function b () {
  a()
}
a()

// out: 
// RangeError: Maximum call stack size exceeded

2、ReferenceError

ReferenceError 也很常見,打印一個不存在的值就是 ReferenceError

hello

// out: 
// ReferenceError: hello is not defined

3、SyntaxError

SyntaxError 也很常見,當語法不符合 JS 規範時,就會報這種錯誤:

console.log(1));

// out:
// console.log(1));
//               ^
// SyntaxError: Unexpected token ')'

4、TypeError

TypeError 也很常見,當一個基礎類型當作函數來用時,就會報這個錯誤:

var a = 1;
a()

// out:
// TypeError: a is not a function

上面的各種 Error 類型中,SyntaxError 最為特殊,因為它是 編譯階段 拋出來的錯誤,如果發生語法錯誤,JS 代碼一行都不會執行。而其他類型的異常都是 執行階段 的錯誤,就算報錯,也會執行異常之前的腳本。

什麼叫 編譯時輸出接口? 什麼叫 運行時加載?

ESM 之所以被稱為 編譯時輸出接口,是因為它的模塊解析是發生在 編譯階段

也就是説,importexport 這些關鍵字是在編譯階段就做了模塊解析,這些關鍵字的使用如果不符合語法規範,在編譯階段就會拋出語法錯誤。

例如,根據 ES6 規範,import 只能在模塊頂層聲明,所以下面的寫法會直接報語法錯誤,不會有 log 打印,因為它壓根就沒有進入 執行階段

console.log('hello world');

if (true) {
  import { resolve } from 'path';
}

// out:
//   import { resolve } from 'path';
//          ^
// SyntaxError: Unexpected token '{'

與此對應的 CommonJS,它的模塊解析發生在 執行階段,因為 requiremodule 本質上就是個函數或者對象,只有在 執行階段 運行時,這些函數或者對象才會被實例化。因此被稱為 運行時加載

這裏要特別強調,與CommonJS 不同,ESM 中 import 的不是對象, export 的也不是對象。例如,下面的寫法會提示語法錯誤:

// 語法錯誤!這不是解構!!!
import { a: myA } from './a.mjs'

// 語法錯誤!
export {
  a: "a"
}

importexport 的用法很像導入一個對象或者導出一個對象,但這和對象完全沒有關係。他們的用法是 ECMAScript 語言層面的設計的,並且“恰巧”的對象的使用類似。

所以在編譯階段,import 模塊中引入的值就指向了 export 中導出的值。如果讀者瞭解 linux,這就有點像 linux 中的硬鏈接,指向同一個 inode。或者拿棧和堆來比喻,這就像兩個指針指向了同一個棧。

ESM 的加載細節

在講解ESM 的加載細節之前,我們要了解 ESM 中也存在 變量提升函數提升 ,意識到這一點非常重要。

拿前面 demos/02 中提到的循環引用舉例子,將其改造為 ESM 版的循環引用,查看 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';

const setA = (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';

const setB = (newB) => {
  b = newB;
}

export {
  b,
  setB
}

可以看到 ./a.mjs./b.mjs 在文件的開頭都相互引用了對方。

執行 node app.mjs 查看運行結果:

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

我們會發現一個 ReferenceError 的異常報錯,提示不能在初始化之前使用變量。這是因為我們使用了 let 定義變量,使用了 const 定義函數,導致無法做變量和函數提升。

怎麼修改才能正常運行呢?其實很簡單:用 var 代替 let,使用 function 來定義函數,我們查看 demos/05 來看效果:

看看 ./a.mjs 的代碼:


console.log('b val', b);

console.log('setB to bb');

setB('bb')

var a = 'a';

function setA(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';

function setB(newB) {
  b = newB;
}

export {
  b,
  setB
}

執行 node app.mjs 查看運行結果:

running b.mjs
a val undefined
setA to aa
running a.mjs
b val b
setB to bb

可以發現這樣修改後可以正常執行,沒有出現異常報錯。

寫到這裏我們可以詳細談談 ESM 的加載細節了,它其實和前面提到的 CommonJS 的 Module._load 函數做的事情有些類似:

  1. 檢查緩存,如果緩存存在且已經加載,則直接從緩存模塊中提取相應的值,不做下面的處理
  2. 如果緩存不存在,新建一個 Module 實例
  3. 將這個 Module 實例放到緩存中
  4. 通過這個 Module 實例來加載文件
  5. 加載文件後到全局執行上下文時,會有創建階段和執行階段,在創建階段做函數和變量提升,接着執行代碼。
  6. 返回這個 Module 實例的 exports

結合 demos/05 的循環加載,我們再做一個詳細的解釋:

app.mjs 加載 a.mjs 時,Module 會檢查緩存中有沒有 a.mjs,發現沒有,於是 new 一個 a.mjs 模塊,並將這個模塊放到緩存中,再去加載 a.mjs 文件本身。

在加載 a.mjs 文件時,在 創建階段 會為全局上下文中的函數 setA 和 變量 a 分配內存空間,並初始化變量 aundefined。在執行階段,發現第一行是加載 b.mjs,它會檢查緩存中有沒有 b.mjs,發現沒有,於是 new 一個 b.mjs 模塊,並將這個模塊放到緩存中,再去加載 b.mjs 文件本身。

在加載 b.mjs 文件時,在 創建階段 會為全局上下文中的函數 setB 和 變量 b 分配內存空間,並初始化變量 bundefined。在執行階段,發現第一行是加載 a.mjs,它會檢查緩存中有沒有 a.mjs,發現存在,於是 import 返回了緩存中 a.mjs 導出的相應的值。

雖然這個時候 a.mjs 根本還沒有執行過,但是它的 創建階段 已經完成了,即在內存中也已經存在了 setA 函數和值為 undefined 的變量 a。所以這時候在 b.mjs 裏可以正常打印 a 並使用 setA 函數而沒有異常拋錯。

再談 ESM 和 CommonJS 的區別

不同點:this 的指向不同

CommonJS 的 this 指向可以查看源碼:

var args = [self.exports, require, self, filename, dirname];
return compiledWrapper.apply(self.exports, args);

很清楚的可以看到 this 指向的是當前 module 的默認 exports

而 ESM 由於語言層面的設計指向的是 undefined

不同點:__filename,__dirname 在 CommonJS 中存在,在 ESM 中不存在

在 CommonJS 中,模塊的執行需要用函數包起來,並指定一些常用的值,可以查看源碼:

NativeModule.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

所以我們全局才可以直接用 __filename__dirname。而 ESM 沒有這方面的設計,所以在 ESM 中不能直接使用 __filename__dirname

相同點:ESM 和 CommonJS 都有緩存

這一點兩種模塊方案一致,都會緩存模塊,模塊加載一次後會緩存起來,後續再次加載會用緩存裏的模塊。

參考文檔

  • 阮一峯:Module 的加載實現
  • 深入Node.js的模塊加載機制,手寫require函數
  • commonjs 與 esm 的區別
  • 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執行過程(JS系列之一)
  • JS執行過程詳解
  • 7 Types of Native Errors in JavaScript You Should Know

Add a new 評論

Some HTML is okay.