关于javascript:深入浅出-ESM-模块-和-CommonJS-模块

9次阅读

共计 18157 个字符,预计需要花费 46 分钟才能阅读完成。

阮一峰在 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

正文完
 0