ECMAScript中CommonJs和ES6 module的区别是什么呢?

2023-02-2119:13:10WEB前端开发Comments1,614 views字数 18170阅读模式
作者:王玉略
来源:知乎文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

阮一峰在 ES6 入门 中提到 ES6 模块与 CommonJS 模块有一些重大的差异:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

再细读上面阮老师提到的差异,会产生诸多疑问:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

  • 为什么 CommonJS 模块输出的是一个值的拷贝?其具体细节是什么样子的?
  • 什么叫 运行时加载?
  • 什么叫 编译时输出接口
  • 为什么 ES6 模块输出的是值的引用?

于是就有了这篇文章,力求把 ESM 模块CommonJS 模块 讨论清楚。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

CommonJS 产生的历史背景

CommonJS 由 Mozilla 工程师 Kevin Dangoor 于 2009 年 1 月创立,最初命名为ServerJS。2009 年 8 月,该项目更名为CommonJS。旨在解决 Javascript 中缺少模块化标准的问题。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

Node.js 后来也采用了 CommonJS 的模块规范。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

由于 CommonJS 并不是 ECMAScript 标准的一部分,所以 类似 modulerequire 并不是 JS 的关键字,仅仅是对象或者函数而已,意识到这一点很重要。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

我们可以在打印 modulerequire 查看细节:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 是一个函数,仅此而已。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

我们来重点介绍下 module 中的一些属性:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

  • exports:这就是 module.exports 对应的值,由于还没有赋任何值给它,它目前是一个空对象。
  • loaded:表示当前的模块是否加载完成。
  • paths:node 模块的加载路径,这块不展开讲,感兴趣可以看node 文档

require 函数中也有一些值得注意的属性:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

  • main 指向当前当前引用自己的模块,所以类似 python 的 __name__ == '__main__', node 也可以用 require.main === module 来确定是否是以当前模块来启动程序的。
  • extensions 表示目前 node 支持的几种加载模块的方式。
  • cache 表示 node 中模块加载的缓存,也就是说,当一个模块加载一次后,之后 require 不会再加载一次,而是从缓存中读取。

前面提到,CommonJS 中 module 是一个对象, require 是一个函数。而与此相对应的 ESM 中的 importexport 则是关键字,是 ECMAScript 标准的一部分。理解这两者的区别非常关键。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

先看几个 CommonJS 例子

大家看看下面几个 CommonJS 例子,看看能不能准确预测结果:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

例一,在模块外为简单类型赋值:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

// 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,输出结果为:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

1
1

例二,在模块外为引用类型赋值:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

// 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,输出结果为:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

{ val: 1 }
{ val: 101 }

例三,在模块内导出后改变简单类型:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

// 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,输出结果为:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

1
1

例四,在模块内导出后用 module.exports 再导出一次:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

// 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,输出结果为:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

{ val: 1 }
{ val: 1 }

例五,在模块内导出后用 exports 再导出一次:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

// 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,输出结果为:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

{ val: 1 }
{ val: 101 }

如何解释上面的例子?没有魔法!一言道破 CommonJS 值拷贝的细节

拿出 JS 最朴素的思维,来分析上面例子的种种现象。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

例一中,代码可以简化为:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

例二中,代码可以简化为:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

例三中,代码可以简化为:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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)

例四中,代码可以简化为:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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)

例五中,代码可以简化为:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 代码。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

其值拷贝发生在给 module.exports 赋值的那一刻,例如:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

做的事情仅仅是给 module.exports 赋予了一个新的对象,在这个对象里有一个key叫做 val,这个 val 的值是当前模块中 val 的值,仅此而已。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

CommonJS 的具体实现

为了更透彻的了解 CommonJS,我们来写一个简单的模块加载器,主要参考了 nodejs 源码;文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

在 node v16.x 中 module 主要实现在 lib/internal/modules/cjs/loader.js 文件下。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

在 node v4.x 中 module 主要实现在 lib/module.js 文件下。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

下面的实现主要参考了 node v4.x 中的实现,因为老版本相对更“干净”一些,更容易抓住细节。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

另外 深入Node.js的模块加载机制,手写require函数 这篇文章写的也很不错,下面的实现很多也参考了这篇文章。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

为了跟官方Module名字区分开,我们自己的类命名为MyModule:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

require方法

我们一直用的 require 其实是 Module 类的一个实例方法,内容很简单,先做一些参数检查,然后调用 Module._load 方法,源码在这里,本示例为了简洁,去掉了一些判断:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

require 是一个很简单函数,主要是包装了 _load 函数,这个函数主要做了如下事情:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

  • 先检查请求的模块在缓存中是否已经存在了,如果存在了直接返回缓存模块的 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,下面我们来实现下这两个方法。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

MyModule._resolveFilename

这个函数的作用是通过用户传入的 require 参数来解析到真正的文件地址,源码中这个方法比较复杂,因为他要支持多种参数:内置模块,相对路径,绝对路径,文件夹和第三方模块等等。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

本示例为了简洁,只实现相对文件的导入:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

MyModule.prototype.load

MyModule.prototype.load 是一个实例方法,源代码在这里,这个方法就是真正用来加载模块的方法,这其实也是不同类型文件加载的一个入口,不同类型的文件会对应 MyModule._extensions 里面的一个方法:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 类型文件的加载:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

可以看到js的加载方法很简单,只是把文件内容读出来,然后调了另外一个实例方法 _compile 来执行他。对应的源码在这里文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

_compile 实现

MyModule.prototype._compile 是加载JS文件的核心所在,这个方法需要将目标文件拿出来执行一遍。对应的源码在这里文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

_compile 主要做了如下事情:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

1、执行之前需要将它整个代码包裹一层,以便注入 exports, require, module, __dirname, __filename,这也是我们能在JS文件里面直接使用这几个变量的原因。要实现这种注入也不难,假如我们 require 的文件是一个简单的 Hello World,长这样:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

module.exports = "hello world";

那我们怎么来给他注入 module 这个变量呢?答案是执行的时候在他外面再加一层函数,使他变成这样:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

nodeJS 也是这样实现的,在node源码里,会有这样的代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 这几个变量了。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

2、放入沙盒里执行包装好的代码,并返回模块的 export。沙盒执行使用了 node 的 vm 模块。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

在本实现中,_compile 实现如下:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 的实现如下:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

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

注意上面的 wrapper 中我们使用了 myRequiremyModule 来区分原生的 requiremodule, 下面的例子中我们会使用自己实现的函数来加载文件。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

最后生成一个实例并导出

最后我们 new 一个 MyModule 的实理并导出,方便外面使用:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

const myModuleInstance = new MyModule();
const MyRequire = (id) => {
  return myModuleInstance.require(id);
}

module.exports = {
  MyModule,
  MyRequire
}

完整代码

最后的完整代码如下:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 函数。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

这似乎产生是先有鸡还是先有蛋的悖论,我还没把你造出来,你怎么就用起来了?文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

事实上,源码中的 require 有另外简单的实现,它被定义在 src/node.js 中,源码在这里文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

用自定义的 MyModule 来加载文件

刚刚我们实现了一个简单的 Module,但是能不能正常用还存疑。是骡子是马拉出来遛遛,我们用自己的 MyModule 来加载文件,看看能不能正常运行。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

可以查看 demos/01,代码的入口为 app.js:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

MyRequire('./b.js');

b.js 的代码如下:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

console.log(obj);

setVal(101);

console.log(obj);

可以看到现在我们用 myRequire 取代 require 来加载 ./a.js 模块。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

再看看 ./a.js 的代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

let obj = {
  val: 1
};

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

myModule.exports = {
  obj,
  setVal
}

可以看到现在我们用 myModule 取代 module 来导出模块。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

最后执行 node app.js 查看运行结果:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

{ val: 1 }
{ val: 101 }

可以看到最终效果和使用原生的 module 模块一致。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

在这之前,我们先看看原生的 module 模块的循环引用会发生什么异常。可以查看 demos/02,代码的入口为 app.js文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

require('./a.js')

看看 ./a.js 的代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 的代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 在文件的开头都相互引用了对方。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

执行 node app.js 查看运行结果:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 的行为一致。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

我们查看 demos/03,这里我们用自己的 myModule 来复现上面的循环引用,代码的入口为 app.js文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

MyRequire('./a.js');

a.js 的代码如下:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 的代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

最后执行 node app.js 查看运行结果:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 处理循环引用的异常是一致的。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

我们可以发现 CommonJS 模块相互引用时,没有产生类似死锁的问题。关键在 Module._load 函数里,具体源代码在这里Module._load 函数主要做了下面这些事情:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

  1. 检查缓存,如果缓存存在且已经加载,直接返回缓存,不做下面的处理
  2. 如果缓存不存在,新建一个 Module 实例
  3. 将这个 Module 实例放到缓存中
  4. 通过这个 Module 实例来加载文件
  5. 返回这个 Module 实例的 exports

其中的关键在 放到缓存中加载文件 的顺序,在我们的 MyModule 中,也就是这两行代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

回到上面循环加载的例子中,解释一下到底发生了什么:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

app.js 加载 a.js 时,Module 会检查缓存中有没有 a.js,发现没有,于是 new 一个 a.js 模块,并将这个模块放到缓存中,再去加载 a.js 文件本身。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

在加载 a.js 文件时,Module 发现第一行是加载 b.js,它会检查缓存中有没有 b.js,发现没有,于是 new 一个 b.js 模块,并将这个模块放到缓存中,再去加载 b.js 文件本身。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

在加载 b.js 文件时,Module 发现第一行是加载 a.js,它会检查缓存中有没有 a.js,发现存在,于是 require 函数返回了缓存中的 a.js文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

但是其实这个时候 a.js 根本还没有执行完,还没走到 module.exports 那一步,所以 b.jsrequire('./a.js') 返回的只是一个默认的空对象。所以最终会报 setA is not a function 的异常。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

说到这里,那如何设计会导致“死锁”呢?其实也很简单 —— 将 放到缓存中加载文件 的执行顺序互换,在我们的 MyModule 代码中,也就是这样写:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

这样互换一下,再执行 demo03,我们发现异常如下:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 报栈溢出异常。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

JavaScript 的执行过程

接下来我们要讲解 ESM 的模块导入,为了方便理解 ESM 的模块导入,这里需要补充一个知识点 —— JavaScript 的执行过程文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

JavaScript 执行过程分为两个阶段:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

  • 编译阶段
  • 执行阶段

编译阶段

在编译阶段 JS 引擎主要做了三件事:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

  • 词法分析
  • 语法分析
  • 字节码生成

这里不详情讲这三件事的具体细节,感兴趣的读者可以阅读 the-super-tiny-compiler 这个仓库,它通过几百行的代码实现了一个微形编译器,并详细讲了这三个过程的具体细节。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

执行阶段

在执行阶段,会分情况创建各种类型的执行上下文,例如:全局执行上下文 (只有一个)、函数执行上下文。而执行上下文的创建分为两个阶段:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

  • 创建阶段
  • 执行阶段

在创建阶段会做如下事情:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

  • 绑定 this
  • 为函数和变量分配内存空间
  • 初始化相关变量为 undefined

我们日常提到的 变量提升 和 函数提升 就是在 创建阶段 做的,所以下面的写法并不会报错:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

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

因为在执行之前的创建阶段,已经分配好了 msgadd 的内存空间。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

JavaScript 的常见报错类型

为了更容易理解 ESM 的模块导入,这里再补充一个知识点 —— JavaScript 的常见报错类型文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

1、RangeError

这类错误很常见,例如栈溢出就是 RangeError文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

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

2、ReferenceError

ReferenceError 也很常见,打印一个不存在的值就是 ReferenceError文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

hello

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

3、SyntaxError

SyntaxError 也很常见,当语法不符合 JS 规范时,就会报这种错误:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

console.log(1));

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

4、TypeError

TypeError 也很常见,当一个基础类型当作函数来用时,就会报这个错误:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

var a = 1;
a()

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

上面的各种 Error 类型中,SyntaxError 最为特殊,因为它是 编译阶段 抛出来的错误,如果发生语法错误,JS 代码一行都不会执行。而其他类型的异常都是 执行阶段 的错误,就算报错,也会执行异常之前的脚本。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

什么叫 编译时输出接口? 什么叫 运行时加载?

ESM 之所以被称为 编译时输出接口,是因为它的模块解析是发生在 编译阶段文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

也就是说,importexport 这些关键字是在编译阶段就做了模块解析,这些关键字的使用如果不符合语法规范,在编译阶段就会抛出语法错误。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

例如,根据 ES6 规范,import 只能在模块顶层声明,所以下面的写法会直接报语法错误,不会有 log 打印,因为它压根就没有进入 执行阶段文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

console.log('hello world');

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

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

与此对应的 CommonJS,它的模块解析发生在 执行阶段,因为 requiremodule 本质上就是个函数或者对象,只有在 执行阶段 运行时,这些函数或者对象才会被实例化。因此被称为 运行时加载文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

这里要特别强调,与CommonJS 不同,ESM 中 import 的不是对象, export 的也不是对象。例如,下面的写法会提示语法错误:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

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

importexport 的用法很像导入一个对象或者导出一个对象,但这和对象完全没有关系。他们的用法是 ECMAScript 语言层面的设计的,并且“恰巧”的对象的使用类似。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

所以在编译阶段,import 模块中引入的值就指向了 export 中导出的值。如果读者了解 linux,这就有点像 linux 中的硬链接,指向同一个 inode。或者拿栈和堆来比喻,这就像两个指针指向了同一个栈。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

ESM 的加载细节

在讲解ESM 的加载细节之前,我们要了解 ESM 中也存在 变量提升函数提升 ,意识到这一点非常重要。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

拿前面 demos/02 中提到的循环引用举例子,将其改造为 ESM 版的循环引用,查看 demos/04,代码的入口为 app.js文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

import './a.mjs';

看看 ./a.mjs 的代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 的代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 在文件的开头都相互引用了对方。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

执行 node app.mjs 查看运行结果:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 定义函数,导致无法做变量和函数提升。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

怎么修改才能正常运行呢?其实很简单:用 var 代替 let,使用 function 来定义函数,我们查看 demos/05 来看效果:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

看看 ./a.mjs 的代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 的代码:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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 查看运行结果:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

可以发现这样修改后可以正常执行,没有出现异常报错。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

写到这里我们可以详细谈谈 ESM 的加载细节了,它其实和前面提到的 CommonJS 的 Module._load 函数做的事情有些类似:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

  1. 检查缓存,如果缓存存在且已经加载,则直接从缓存模块中提取相应的值,不做下面的处理
  2. 如果缓存不存在,新建一个 Module 实例
  3. 将这个 Module 实例放到缓存中
  4. 通过这个 Module 实例来加载文件
  5. 加载文件后到全局执行上下文时,会有创建阶段和执行阶段,在创建阶段做函数和变量提升,接着执行代码。
  6. 返回这个 Module 实例的 exports

结合 demos/05 的循环加载,我们再做一个详细的解释:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

app.mjs 加载 a.mjs 时,Module 会检查缓存中有没有 a.mjs,发现没有,于是 new 一个 a.mjs 模块,并将这个模块放到缓存中,再去加载 a.mjs 文件本身。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

在加载 a.mjs 文件时,在 创建阶段 会为全局上下文中的函数 setA 和 变量 a 分配内存空间,并初始化变量 aundefined。在执行阶段,发现第一行是加载 b.mjs,它会检查缓存中有没有 b.mjs,发现没有,于是 new 一个 b.mjs 模块,并将这个模块放到缓存中,再去加载 b.mjs 文件本身。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

在加载 b.mjs 文件时,在 创建阶段 会为全局上下文中的函数 setB 和 变量 b 分配内存空间,并初始化变量 bundefined。在执行阶段,发现第一行是加载 a.mjs,它会检查缓存中有没有 a.mjs,发现存在,于是 import 返回了缓存中 a.mjs 导出的相应的值。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

虽然这个时候 a.mjs 根本还没有执行过,但是它的 创建阶段 已经完成了,即在内存中也已经存在了 setA 函数和值为 undefined 的变量 a。所以这时候在 b.mjs 里可以正常打印 a 并使用 setA 函数而没有异常抛错。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

再谈 ESM 和 CommonJS 的区别

不同点:this 的指向不同

CommonJS 的 this 指向可以查看源码文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

很清楚的可以看到 this 指向的是当前 module 的默认 exports文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

而 ESM 由于语言层面的设计指向的是 undefined文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

不同点:filename,dirname 在 CommonJS 中存在,在 ESM 中不存在

在 CommonJS 中,模块的执行需要用函数包起来,并指定一些常用的值,可以查看[源码](node源码文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

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

所以我们全局才可以直接用 __filename__dirname。而 ESM 没有这方面的设计,所以在 ESM 中不能直接使用 __filename__dirname文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

相同点:ESM 和 CommonJS 都有缓存

这一点两种模块方案一致,都会缓存模块,模块加载一次后会缓存起来,后续再次加载会用缓存里的模块。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html

参考文档

文章源自菜鸟学院-https://www.cainiaoxueyuan.com/gcs/30914.html
  • 本站内容整理自互联网,仅提供信息存储空间服务,以方便学习之用。如对文章、图片、字体等版权有疑问,请在下方留言,管理员看到后,将第一时间进行处理。
  • 转载请务必保留本文链接:https://www.cainiaoxueyuan.com/gcs/30914.html

Comment

匿名网友 填写信息

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定