Bootstrap

深入浅出 ESM 模块 和 CommonJS 模块

阮一峰在 中提到 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 标准的一部分,所以 类似 并不是 JS 的关键字,仅仅是对象或者函数而已,意识到这一点很重要。

我们可以在打印 、 查看细节:

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]
    }
  }
}

可以看到 是一个对象, 是一个函数,仅此而已。

我们来重点介绍下 中的一些属性:

  • :这就是 对应的值,由于还没有赋任何值给它,它目前是一个空对象。

  • :表示当前的模块是否加载完成。

  • :node 模块的加载路径,这块不展开讲,感兴趣可以看

函数中也有一些值得注意的属性:

  • 指向当前当前引用自己的模块,所以类似 python 的 , node 也可以用 来确定是否是以当前模块来启动程序的。

  • 表示目前 node 支持的几种加载模块的方式。

  • 表示 node 中模块加载的缓存,也就是说,当一个模块加载一次后,之后 不会再加载一次,而是从缓存中读取。

前面提到,CommonJS 中 是一个对象, 是一个函数。而与此相对应的 ESM 中的 则是关键字,是 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);

运行 ,输出结果为:

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

运行 ,输出结果为:

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

运行 ,输出结果为:

1
1

例四,在模块内导出后用 再导出一次:

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

运行 ,输出结果为:

{ val: 1 }
{ val: 1 }

例五,在模块内导出后用 再导出一次:

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

运行 ,输出结果为:

{ 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 代码。

其值拷贝发生在给 赋值的那一刻,例如:

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

做的事情仅仅是给 赋予了一个新的对象,在这个对象里有一个key叫做 ,这个 的值是当前模块中 的值,仅此而已。

CommonJS 的具体实现

为了更透彻的了解 CommonJS,我们来写一个简单的模块加载器,主要参考了 nodejs 源码;

在 node v16.x 中 module 主要实现在 下。

在 node v4.x 中 module 主要实现在 下。

下面的实现主要参考了 node v4.x 中的实现,因为老版本相对更“干净”一些,更容易抓住细节。

另外 这篇文章写的也很不错,下面的实现很多也参考了这篇文章。

为了跟官方Module名字区分开,我们自己的类命名为MyModule:

function MyModule(id = '') {
  this.id = id;             // 模块路径
  this.exports = {};        // 导出的东西放这里,初始化为空对象
  this.loaded = false;      // 用来标识当前模块是否已经加载
}

require方法

我们一直用的 其实是 Module 类的一个实例方法,内容很简单,先做一些参数检查,然后调用 Module._load 方法,源码在,本示例为了简洁,去掉了一些判断:

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

  // 如果缓存不存在,我们就加载这个模块
  const module = new MyModule(filename);

  // load之前就将这个模块缓存下来,这样如果有循环引用就会拿到这个缓存,但是这个缓存里面的exports可能还没有或者不完整
  MyModule._cache[filename] = module;

  // 如果 load 失败,需要将 _cache 中相应的缓存删掉。这里简单起见,不做这个处理
  module.load(filename);

  return module.exports;
}

可以看到上述源码还调用了两个方法: 和 ,下面我们来实现下这两个方法。

MyModule._resolveFilename

这个函数的作用是通过用户传入的 require 参数来解析到真正的文件地址,中这个方法比较复杂,因为他要支持多种参数:内置模块,相对路径,绝对路径,文件夹和第三方模块等等。

本示例为了简洁,只实现相对文件的导入:

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

MyModule.prototype.load

是一个实例方法,源代码在,这个方法就是真正用来加载模块的方法,这其实也是不同类型文件加载的一个入口,不同类型的文件会对应 里面的一个方法:

MyModule.prototype.load = function (filename) {
  // 获取文件后缀名
  const extname = path.extname(filename);

  // 调用后缀名对应的处理函数来处理,当前实现只支持 JS
  MyModule._extensions[extname](this, filename);

  this.loaded = true;
}

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

前面提到不同文件类型的处理方法都挂载在 上,事实上 的加载器不仅仅可以加载 模块,也可以加载 和 模块。本示例简单起见仅实现 类型文件的加载:

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

可以看到js的加载方法很简单,只是把文件内容读出来,然后调了另外一个实例方法 来执行他。对应的源码在

_compile 实现

是加载JS文件的核心所在,这个方法需要将目标文件拿出来执行一遍。对应的源码在

主要做了如下事情:

1、执行之前需要将它整个代码包裹一层,以便注入 , , , , ,这也是我们能在JS文件里面直接使用这几个变量的原因。要实现这种注入也不难,假如我们 require 的文件是一个简单的 ,长这样:

module.exports = "hello world";

那我们怎么来给他注入 这个变量呢?答案是执行的时候在他外面再加一层函数,使他变成这样:

function (module) { // 注入module变量,其实几个变量同理
  module.exports = "hello world";
}

nodeJS 也是这样实现的,在里,会有这样的代码:

NativeModule.wrap = function(script) {
  return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};

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

这样通过MyModule.wrap包装的代码就可以获取到 , , , , 这几个变量了。

2、放入沙盒里执行包装好的代码,并返回模块的 export。沙盒执行使用了 node 的 模块。

在本实现中, 实现如下:

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

和 的实现如下:

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

MyModule.wrap = function (script) {
  return MyModule.wrapper[0] + script + MyModule.wrapper[1];
};

注意上面的 中我们使用了 和 来区分原生的 和 , 下面的例子中我们会使用自己实现的函数来加载文件。

最后生成一个实例并导出

最后我们 new 一个 的实理并导出,方便外面使用:

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 源码中实现 的 中,也使用到了 函数。

这似乎产生是先有鸡还是先有蛋的悖论,我还没把你造出来,你怎么就用起来了?

事实上,源码中的 有另外简单的实现,它被定义在 中,源码在

用自定义的 MyModule 来加载文件

刚刚我们实现了一个简单的 Module,但是能不能正常用还存疑。是骡子是马拉出来遛遛,我们用自己的 来加载文件,看看能不能正常运行。

可以查看 ,代码的入口为 :

const { MyRequire } = require('./myModule.js');

MyRequire('./b.js');

的代码如下:

const { obj, setVal } = myRequire('./a.js')

console.log(obj);

setVal(101);

console.log(obj);

可以看到现在我们用 取代 来加载 模块。

再看看 的代码:

let obj = {
  val: 1
};

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

myModule.exports = {
  obj,
  setVal
}

可以看到现在我们用 取代 来导出模块。

最后执行 查看运行结果:

{ val: 1 }
{ val: 101 }

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

用自定义的 MyModule 来测试循环引用

在这之前,我们先看看原生的 module 模块的循环引用会发生什么异常。可以查看 ,代码的入口为 :

require('./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
}

再看看 的代码:

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
}

可以看到 和 在文件的开头都相互引用了对方。

执行 查看运行结果:

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. (/Users/xxx/Desktop/esm_commonjs/demos/02/b.js:9:1)
    at xxx

我们会发现一个 TypeError 的异常报错,提示 。这样的异常在预期之内,我们再试试自己实现的 的异常是否和原生 的行为一致。

我们查看 ,这里我们用自己的 来复现上面的循环引用,代码的入口为 :

const { MyRequire } = require('./myModule.js');

MyRequire('./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
}

再看看 的代码:

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
}

可以看到现在我们用 取代了 ,用 取代了 。

最后执行 查看运行结果:

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. (/Users/xxx/Desktop/esm_commonjs/demos/03/b.js:9:1)
    at xxx

可以看到, 的行为和原生 处理循环引用的异常是一致的。

疑问:为什么 CommonJS 相互引用没有产生类似“死锁”的问题?

我们可以发现 CommonJS 模块相互引用时,没有产生类似死锁的问题。关键在 函数里,具体源代码在。 函数主要做了下面这些事情:

其中的关键在 放到缓存中加载文件 的顺序,在我们的 中,也就是这两行代码:

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

回到上面循环加载的例子中,解释一下到底发生了什么:

当 加载 时,Module 会检查缓存中有没有 ,发现没有,于是 new 一个 模块,并将这个模块放到缓存中,再去加载 文件本身。

在加载 文件时,Module 发现第一行是加载 ,它会检查缓存中有没有 ,发现没有,于是 new 一个 模块,并将这个模块放到缓存中,再去加载 文件本身。

在加载 文件时,Module 发现第一行是加载 ,它会检查缓存中有没有 ,发现存在,于是 函数返回了缓存中的 。

但是其实这个时候 根本还没有执行完,还没走到 那一步,所以 中 返回的只是一个默认的空对象。所以最终会报 的异常。

说到这里,那如何设计会导致“死锁”呢?其实也很简单 —— 将 放到缓存中加载文件 的执行顺序互换,在我们的 代码中,也就是这样写:

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 引擎主要做了三件事:

  • 词法分析

  • 语法分析

  • 字节码生成

这里不详情讲这三件事的具体细节,感兴趣的读者可以阅读 这个仓库,它通过几百行的代码实现了一个微形编译器,并详细讲了这三个过程的具体细节。

执行阶段

在执行阶段,会分情况创建各种类型的执行上下文,例如:全局执行上下文 (只有一个)、函数执行上下文。而执行上下文的创建分为两个阶段:

  • 创建阶段

  • 执行阶段

在创建阶段会做如下事情:

  • 绑定 this

  • 为函数和变量分配内存空间

  • 初始化相关变量为 undefined

我们日常提到的 变量提升 和 函数提升 就是在 创建阶段 做的,所以下面的写法并不会报错:

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

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

因为在执行之前的创建阶段,已经分配好了 和 的内存空间。

JavaScript 的常见报错类型

为了更容易理解 ESM 的模块导入,这里再补充一个知识点 —— JavaScript 的常见报错类型

1、RangeError

这类错误很常见,例如栈溢出就是 ;

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

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

2、ReferenceError

也很常见,打印一个不存在的值就是 :

hello

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

3、SyntaxError

也很常见,当语法不符合 JS 规范时,就会报这种错误:

console.log(1));

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

4、TypeError

也很常见,当一个基础类型当作函数来用时,就会报这个错误:

var a = 1;
a()

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

上面的各种 Error 类型中, 最为特殊,因为它是 编译阶段 抛出来的错误,如果发生语法错误,JS 代码一行都不会执行。而其他类型的异常都是 执行阶段 的错误,就算报错,也会执行异常之前的脚本。

什么叫 ? 什么叫 ?

ESM 之所以被称为 ,是因为它的模块解析是发生在 编译阶段

也就是说, 和 这些关键字是在编译阶段就做了模块解析,这些关键字的使用如果不符合语法规范,在编译阶段就会抛出语法错误。

例如,根据 ES6 规范, 只能在模块顶层声明,所以下面的写法会直接报语法错误,不会有 log 打印,因为它压根就没有进入 执行阶段

console.log('hello world');

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

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

与此对应的 CommonJS,它的模块解析发生在 执行阶段,因为 和 本质上就是个函数或者对象,只有在 执行阶段 运行时,这些函数或者对象才会被实例化。因此被称为 。

这里要特别强调,与CommonJS 不同,ESM 中 的不是对象, 的也不是对象。例如,下面的写法会提示语法错误:

// 语法错误!这不是解构!!!
import { a: myA } from './a.mjs'

// 语法错误!
export {
  a: "a"
}

和 的用法很像导入一个对象或者导出一个对象,但这和对象完全没有关系。他们的用法是 ECMAScript 语言层面的设计的,并且“恰巧”的对象的使用类似。

所以在编译阶段, 模块中引入的值就指向了 中导出的值。如果读者了解 linux,这就有点像 linux 中的硬链接,指向同一个 inode。或者拿栈和堆来比喻,这就像两个指针指向了同一个栈。

ESM 的加载细节

在讲解ESM 的加载细节之前,我们要了解 ESM 中也存在 变量提升函数提升 ,意识到这一点非常重要。

拿前面 中提到的循环引用举例子,将其改造为 ESM 版的循环引用,查看 ,代码的入口为 :

import './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
}

再看看 的代码:

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
}

可以看到 和 在文件的开头都相互引用了对方。

执行 查看运行结果:

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 的异常报错,提示不能在初始化之前使用变量。这是因为我们使用了 定义变量,使用了 定义函数,导致无法做变量和函数提升。

怎么修改才能正常运行呢?其实很简单:用 代替 ,使用 function 来定义函数,我们查看 来看效果:

看看 的代码:


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

console.log('setB to bb');

setB('bb')

var a = 'a';

function setA(newA) {
  a = newA;
}

export {
  a,
  setA
}

再看看 的代码:

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
}

执行 查看运行结果:

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

可以发现这样修改后可以正常执行,没有出现异常报错。

写到这里我们可以详细谈谈 ESM 的加载细节了,它其实和前面提到的 CommonJS 的 函数做的事情有些类似:

结合 的循环加载,我们再做一个详细的解释:

当 加载 时,Module 会检查缓存中有没有 ,发现没有,于是 new 一个 模块,并将这个模块放到缓存中,再去加载 文件本身。

在加载 文件时,在 创建阶段 会为全局上下文中的函数 和 变量 分配内存空间,并初始化变量 为 。在执行阶段,发现第一行是加载 ,它会检查缓存中有没有 ,发现没有,于是 new 一个 模块,并将这个模块放到缓存中,再去加载 文件本身。

在加载 文件时,在 创建阶段 会为全局上下文中的函数 和 变量 分配内存空间,并初始化变量 为 。在执行阶段,发现第一行是加载 ,它会检查缓存中有没有 ,发现存在,于是 返回了缓存中 导出的相应的值。

虽然这个时候 根本还没有执行过,但是它的 创建阶段 已经完成了,即在内存中也已经存在了 函数和值为 的变量 。所以这时候在 里可以正常打印 并使用 函数而没有异常抛错。

再谈 ESM 和 CommonJS 的区别

不同点:this 的指向不同

CommonJS 的 this 指向可以查看

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

很清楚的可以看到 指向的是当前 的默认 ;

而 ESM 由于语言层面的设计指向的是 。

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

在 CommonJS 中,模块的执行需要用函数包起来,并指定一些常用的值,可以查看[源码](

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

所以我们全局才可以直接用 、。而 ESM 没有这方面的设计,所以在 ESM 中不能直接使用 和 。

相同点:ESM 和 CommonJS 都有缓存

这一点两种模块方案一致,都会缓存模块,模块加载一次后会缓存起来,后续再次加载会用缓存里的模块。

参考文档