深入盘点 CommonJS 和 ESM 的原理、差异

2,494 阅读5分钟

一、缘起

1.1 为什么我们需要模块化?

我们常说的前端模块化, 一般指的就是 JavaScript 的 模块化, 一开始 JS 只是被作为简单的网页脚本语言被使用, 但是随着 WEB 的快速发展, 我们所编写的 JS 代码变得越来越复杂, 这时 模块化 自然就成为一个趋势, 其目的就是试图将代码进行合理的拆分、隔离、复用。从而加强代码的可维护性和管理。

那么我们来看下, 如果没有 模块化 我们在开发过程中可能会遇到哪些问题:

  1. 全局变量(内存泄露), 命名冲突问题: 如下代码所示, 定义了全局变量 data、name, getName 这里可能就会引起一些内存泄露问题, 因为它们是在全局作用域上进行声明的, 导致它们是不会被销毁掉, 更多关于内存泄露细节可以查阅 《从 GC 到 WeakMap、WeakSet》; 同时我们假设这三个脚本是不同人进行维护的, 这时脚本之间代码是没有隔离的, 声明的变量都是可以相互覆盖的, 这样的话就很容易出现很多 bug 从而增加维护成本
<script>
  var name = 1
  var data = {
    userName: '墨渊君'
  }
  function getName(){
    console.log('name:', name)
  }
  setTimeout(() => {
    getName() // 打印 name: 3
  }, 1000)
</script>
<script>
  var name = 2
</script>
<script>
  var name = 3
</script>
  1. 脚本顺序问题: 在实际开发过程中, 我们可能需要对代码进行拆分, 这时就需要特别注意脚本引用的一个顺序; 或者在使用第三方库时(比如 JQuery), 我们就需要保证脚本之间的引入顺序不能错乱, 如果你在加载第三方库之前, 使用了第三库进行开发, 那么程序就会抛出错误, 因为找不到第三库了(还没引进来)
<script>
$("button.continue" ).html( "Next Step..." ) // 将会报错
</script>
<script src="http://xxxxx/JQuery.js"></script>
  1. 最大问题: 当系统复杂起来、开发人员多起来, 如何能够更好的分离? 如何让代码有更高复用性和更高可维护性? 如何实现按需加载? 就成为一大难题

1.2 模块化概念 & 作用

我们来畅享下, 如果要解决上面的问题我们可以怎么做:

  • 将相关联的代码进行拆分出来, 假设这部分代码和其他代码相对来说是独立的, 代码中的变量和函数都有自己的作用域, 这样拆分出来的独立的、和其他代码隔离的部分我们称之为 模块
  • 如此, 我们可以将我们的代码拆分多个独立的 模块, 每个 模块 编写维护特定功能部分
  • 每个 模块 可以将内部的方法、变量共享出去, 模块 可以导入其他 模块 共享的数据

image

如上面所描述的「将代码按照一定规则进行拆分, 形成独立的个体(模块)」这种编程思路则被称之为 模块化, 那么这么做可以给我带来哪些好处呢?

  • 每个模块形成独立的作用域, 模块之间不会相互影响, 如此就可以避免全局变量的泛滥以及变量的相互覆盖
  • 将代码拆成彼此独立的小模块, 这些小模块又可以相互组合复用, 最终就像乐高积木一样将这些模块组成一个大型的项目, 这样就不用考虑脚本顺序问题, 因为它们之间是通过组合的方式进行使用
  • 在项目开发维护, 我们可能只需要将精力放在每个模块内即可, 这样就可以实现代码的高复用、高可维护性难题

1.3 早期的几种实现

  1. 全局 function 模式: 将不同功能封装成不同的全局函数, 但是这样其实并没有解决实际问题, 该有的问题还是存在的
// 所有 function 都是挂在 window 下面的
funtion api(){
  return { ... }
}

function handle(data, key){
  return { ... }
}

function sum(a, b){
  return a + b;
}

const data = api();
const a = handle(data, 'a')
  1. 全局 namespace 模式: 解决了全局变量问题, 解决了命名冲突问题; 但是存在数据安全的问题, 外部可以直接修改模块内部数据
window.__Module = {
  x: 1,
  api(){ ... },
  handle(){ ... },,
  sum(a,b){
    return a + b
  }
}

const module = window.__Module
consr data = module.api()

console.log(module.x) // 1
module.x = 2
  1. IIFE 模式(匿名函数自调用): 借用函数作用域来隔离代码, 通过自执行函数创建闭包, 解决私有化的问题, 外部只能通过暴露的方法操作
(function(window){
  var x = 1;

  function api(){ ... }

  function setX(v){
    x = v
  }

  function getX(){
    return x
  }

  window.__Module = {
    x,
    setX,
    getX,
    api,
  }
})(window)

const m = window.__Module

// 这里改的是函数作用域内变量的值
m.setX(10)
console.log(m.getX()) // 10

// 这里改的是对象属性的值, 不是修改的模块内部的data
m.x = 2
console.log(m.getX()) // 10

二、Commonjs

Commonjs 是早期社区比较流行的一个 模块化 方案, 那时候 ES Module 还未出现, 后来 Node 将其集成进来了, 所以当我们聊起 Commonjs 一般都会和 Node 关联起来

2.1 语法

首先我们先看下 Commonjs 的一个基本语法, 这里将包含 3 个部分: 导出、导入、模块路径查找规则; 当然这里不会对 Commonjs 语法做出详细讲解, 你可以当做是简单回顾

  1. 导出: 两种方式, 一种是通过 exports.[key] = value 将值一个个导出, 另一种则是通过 module.exports = {xxx} 方式将要导出的值批量导出
let name = '墨渊君'

// 1. 导出单值
exports.age = 18
exports.name = age
exports.getName = () => {
  console.log(name)
}

// 2. 批量导出
module.exports = {
  name,
  age: 18,
  getName: () => {
    console.log(name)
  }
}
  1. 导入: 使用 require() 方法进行导入, 该方法会返回一个对象, 对象包含对应模块导出的所有相关属性、值
// 读取核心模块 fs  
const fs = require('fs')

// 获取 `./b.js` 导出的内容, require() 方法返回的是一个对象, 然后直接对对象进行解构, 拿到需要的值
const { name, age } = require('./b.js')
  1. 路径查找规则: 下面简单介绍下 require() 是如何处理参数(模块路径)的
  • 核心模块: 像 fs、http、path 等 Node 自带的核心模块, 在 Node 中已被编译成二进制代码, 可直接读取, 而且加载过程中速度也是最快的
  • 具体路径: 以 ./、../ 和 / 开始的标识符, 会被当作文件模块进行处理。require() 方法会将路径转换成真实路径, 并以真实路径作为索引进行使用
  • 自定义模块: 不带路径的非核心模块, 它一般指的是一个 NPM 包, 它的查找会遵循以下原则:
    • 查找包路径: 先在当前目录下查找 node_modules 目录, 看是否存在对应的 NPM 包, 如果没有则向上一层层继续查找
    • 查找入口文件: 找到 NPM 包后, 会查找到对应 NPM 包中的 package.json 下 main 属性所指向的文件, 如果没有 package.json 文件或者 main 属性, 在 node 环境下则会依次查找 index.js、index.json、index.node 等文件

image

关于 路径查找规则 更多信息可见 modules_all_together

2.2 原理

下面我们来简单看下 Commonjs 的一个实现原理, 希望通过它能够帮助我们更好理解, Commonjs 模块化在日常工作、面试过程中遇到的各种奇葩问题。

总得来说这里就分为两部分: 导入 以及 导出, 至于 路径查找 则不是我们要关注的。

首先, 对于 Commonjs 来说, 一个文件其实就相当于是一个 模块

2.2.1 导出

假设我们有个模块代码如下:

const aModule = require('./a.cjs')

// 单值导出
exports.name = '墨渊君'

// 批量导出
module.exports = {
  age: 18,
  getName: () => {
    console.log(this.name)
  }
}

那么你是否有过疑惑, 模块代码中 exports、module.exports 以及 require() 这些变量和方法是怎么来的呢? exports 和 module.exports 又有啥区别呢?

对于 Commonjs 其实 Node 在处理文件时, 会对源代码进行一层包装, 也就是会包裹一层函数, 而模块文件中的主体代码实际上是会在该函数内被执行。而 exports、module.exports 以及 require() 则是这个包裹的函数所提供的。

大致情况如下代码所示, 简单理解就是对模块的主体代码包裹一层函数, 函数接收 exports、require、module 等参数:

+ function (exports, require, module) {
  const aModule = require('./a.cjs')

  // 单值导出
  exports.name = '墨渊君'

  // 批量导出
  module.exports = {
    age: 18,
    getName: () => {
      console.log(this.name)
    }
  }
+}

当然实际源码部分肯定更复杂一点, 但是核心代码大致如下:

  • 有个工具函数 compiledWrapper
  • 参数 content 是模块源码字符串
  • 参数 exports, require, module 则是 模块化 相关参数
  • eval(content) 则是执行模块代码(访问相关变量, 并往 模块对象 挂载数据)
const compiledWrapper = function(content, exports, require, module) {
  // 执行文件代码字符串, 这样就可以调用到上下文变量了, 就可以往 exports 中挂载数据
  eval(content)
}

那么上面我们还留了一个问题 exports 和 module.exports 它们之间的区别是啥呢? 从上面代码我们知道这两个参数是调用包装函数 compiledWrapper 时传进来的参数, 那么什么时候会调用上面包装的函数呢? 答案是在执行 require() 时被调用的, 那么我们带着这个问题来简单看下 require() 的一个原理 👇🏻

2.2.2 导入

在 Commonjs 中我们是通过 require() 来导入模块的, 这里我们直接看 require() 代码、以及注释 👇🏻

// id 为路径标识符
function require(id) {
  // 计算绝对路径: 不是我们要讨论的重点, 这里我就是瞎写的, 只要知道这里还有获取模块文件绝对路径的步骤
  var filename = resolveFilename(id);

  // 缓存处理: 判断 Module._cache 中是否存在对于 filename 的缓存对象, 有则直接返回缓存的数据
  if(Module._cache[filename]){
    return Module._cache[filename].exports
  }
 
  // 创建当前模块的 module
  const module = { exports: {}, loaded: false , ... }

  // 将 module 缓存到 Module 的缓存属性 _cache 中, 路径标识符作为 filename
  Module._cache[filename] = module

  // 加载文件、并执行
  const content = fs.readFileSync(filename, 'utf8');
  compiledWrapper.call(module.exports, content, module.exports, require, module)
  
  // 加载完成
  module.loaded = true 
  
  // 返回当前模块对象
  return module.exports
}

image

这里我们需要特别注意两个点:

  • 存在缓存机制, 在最开始会先判断是否有缓存, 有则直接读取缓存, 所以相同模块只会执行一次
  • 在执行模块代码前, 就会先将创建的空的模块对象存入缓存, 这在 循环引用 的情况下就特别重要, 因为没执行模块代码就表示这时还未真正挂载数据, 只要其他模块又引了该模块就会拿到空值

那么 exports 和 module.exports 它们之间的区别是啥呢? 答案就是它们其实都是一个引用地址, 指向了当前模块导出的对象, exports 是 module 中的一个属性, 里面包含模块的所有导出, 当然里面也不止 exports 一个属性

console.log(exports === module.exports) // true
console.log(module) // 打印内容如下

Module {
  id: '/Users/qianyin/coding/tmp/module/commont/a.cjs',
  path: '/Users/qianyin/coding/tmp/module/commont',
  exports: {},
  filename: '/Users/qianyin/coding/tmp/module/commont/a.cjs',
  loaded: false,
  children: [],
  paths: [
    '/Users/qianyin/coding/tmp/module/commont/node_modules',
    '/Users/qianyin/coding/tmp/module/node_modules',
    '/Users/qianyin/coding/tmp/node_modules',
    '/Users/qianyin/coding/node_modules',
    '/Users/qianyin/node_modules',
    '/Users/node_modules',
    '/node_modules'
  ]
}

更多源码部分可见 node/blob/main/lib/internal/modules/cjs/loader

2.3 特性

  1. 运行时加载: 从上面代码可知, require() 本质上就是一个可执行方法, 在执行该方法时才会去读取并执行模块内的代码, 然后挂载要导出的属性, 并导出

  2. 缓存机制: 在执行 require() 方法时, 会先去判断是否已经加载过相同模块的, 如果已经加载将直接返回缓存内容的

function require(id) {
  // 缓存处理: 判断 Module._cache 中是否存在对于 filename 的缓存对象, 有则直接返回缓存的数据
  if(Module._cache[filename]){
    return Module._cache[filename].exports
  }
}
  1. 导出的是值: 在 Commonjs 中, 导出操作其实就是常规的对象的属性赋值操作, 如下代码所示, 对于 非引用类型 的数据在赋值时实际上就是对值的一个拷贝, 对于引用类型则是对地址的拷贝, 本质上就是常规的 JS 对象操作
// a.js 代码
let num = 1

let user = { name: 'LH' }

exports.num = num   // 赋值, 是对变量 num 进行了拷贝, 拷贝的是 1 这个值
exports.user = user // 赋值, 是对变量 user 进行了拷贝, 拷贝的是对象的引用地址

exports.addNum = () => { num += 1 }  // 修改变量 num 的值
exports.getNum = () => { console.log('模块内部 num 变量:', num) }

exports.setName = () => { user.name = '墨渊君' } // 修改变量 user 中的属性
exports.getName = () => { console.log('模块内部 user 变量中的 name:', user.name) }

如下代码:

  • 引入了 ./a.cjs 模块
  • 先是打印了导出模块的信息
  • 然后调用模块内部方法修改变量的值或属性
  • 最后打印修改后模块导出的内容、以及内部的变量的情况
const a = require('./a.cjs')

console.log('A 模块 num:', a.num) // 打印, A 模块 num: 1
console.log('A 模块 user.name:', a.user.name) // 打印, A 模块 user.name: LH

a.addNum() // 修改模块内部 num 变量的值
a.setName() // 修改模块内部 user 的 name 属性值

console.log('A 模块 num:', a.num) // 打印, A 模块 num: 1
console.log('A 模块 user.name:', a.user.name) // 打印, A 模块 user.name: 墨渊君

a.getNum() // 打印, 模块内部 num 变量: 2
a.getName() //  打印, 模块内部user 变量中的 name: 墨渊君

下图是在我们执行完 require('./a.cjs') 后, 内存中的变量的情况, 这里我们忽略 setName、getName 两个方法, 这两个方法本质是直接引用了函数外部的变量, 所以不管如何拿到的肯定都是模块内部的变量的真实值(这里其实形成了个闭包)

image

  1. this 指向当前模块: 从源码中我们其实也能看出, 代码中通过包装函数 compiledWrapper 来运行模块主代码, 但是在执行包装函数时通过 call 等方式修改了函数的 this 执行, 这样的话在模块内部 this 指向就被相应的调整了
function require(id) {
  ...
  compiledWrapper.call(module.exports, content, module.exports, require, module)
  ...
}

所以我们可以在函数中可通过 this 访问到当前模块导出的对象

exports.num = 12

exports.addNum = () => { 
  this.num += 1
}

exports.getNum = () => { 
  console.log(this.num)
}
  1. 同步执行: 是的 require() 的执行过程它是同步的, 你也许会疑惑, 同步的话它不会因为加载资源太慢影响资源吗? 其实还行, 因为所有资源都是在本地进行加载的, 所以速度还是可以接受的
// main.cjs
console.log('m1')
const a = require('./a.cjs')
console.log('m2')
// a.cjs
console.log('a1')
const a = require('./b.cjs')
console.log('a2')
// b.cjs
console.log('b1')

最后执行 main.cjs 输出: m1、a1、b1、a2、m2

  1. 动态加载: 何谓动态加载, 就是可以特定条件下动态加载模块, 而之所以能够做到动态加载主要原因还是因为 require() 本质上就是常规的函数而已, 所以严格来说你可以在任何地方调用 require() 来加载你所需要的模块
const handler = (isUser) => {
  let module = require('./a.js')
  if (isUser) {
    module = require('./user.js')
  }
}
  1. 循环引用问题: 通过缓存机制解决循环引用问题, 但是需要注意取值时机问题, 因为存在一种情况就是在模块值还没挂载时, 再次引用同一模块会取到空值
// main.cjs
const a = require('./a.cjs')
console.log('main 模块 - a:', a)
// a.cjs
const b = require('./b.cjs')

console.log('a 模块 - b:', b)
module.exports = {
  age: 18,
  name: '墨渊君',
  moduleName: 'a',
}
// b.cjs
const a = require('./a.cjs')
console.log('b 模块 - a', a)

module.exports = {
  moduleName: 'b'
}

执行完 main.js, 最后打印结果如下:

b 模块 - a {}
a 模块 - b: { moduleName: 'b' }
main 模块 - a: { age: 18, name: '墨渊君', moduleName: 'a' }
(node:57192) Warning: Accessing non-existent property 'Symbol(nodejs.util.inspect.custom)' of module exports inside circular dependency

如下图是上面代码整个执行流程:

image

拿不到数据? 这里我们可以用 setTimeout, 在下一个宏任务中获取值, 这时上一个宏任务也就是赋值操作已完成(异步)

const a = require('./a.cjs')
setTimeout(() => {
  console.log(a)
}, 0)
  1. 不能通过 exports = {} 全量导出: 📢 在模块内 exports = {} 是不能取代 module.exports = {} 来进行全量导出的, 如下代码执行 main.cjs 获取到的模块内容是空对象 {}
// main.cjs
const a = require('./a.cjs')
console.log(a) // {}
// a.cjs
exports = {
  name: '墨渊君'
}

其实这里有点类似如下代码, 在执行 compiledWrapper 函数时, 会先声明一个变量 exports 来存参数, 这里的值其实就是 { name: 'LH' } 对象的引用地址, 这时函数内部重新为变量 exports 设置值, 最终修改的只是函数执行期间声明的变量, 并不是修改外部参数

function compiledWrapper(exports) {
  exports = { name: '墨渊君' }
}

const exports = { name: 'LH' }
compiledWrapper(exports)

如下图, 最开始把对象的引用地址赋值给了 exports 变量, 后面又重新创建了对象, exports 指向了新的对象, 但是原先的对象从始至终都没有改变

image

  1. module.exports = {} 和 exports.xx = xxx 不能同时使用: 如下代码, exports.name = '墨渊君' 赋值将会失效, 因为后面通过 module.exports = {} 改变了最后真正导出的对象实例, 这时 exports 还指向原先的对象实例, 导致前面单值导出丢失
// main.cjs
const a = require('./a.cjs')
console.log(a) // 打印, { age: 18, address: '杭州' }
// a.cjs
exports.name = '墨渊君'

module.exports = {
  age: 18,
  address: '杭州',
}

image

三、Es Module

ESM(EcmaScript Module) 是 JS 后面推出一个标准、规范化的模块 语法。现在已经得到大部分浏览器的支持了, 同时最新的 Node 也是支持 ESM 了。

3.1 语法

同样的, 在开始前我们先简单看下 ESM 的基本语法, 这里同样将包含 3 个部分: 导出、导入、模块路径查找规则; 当然这里也同样不会对 ESM 语法做出详细讲解, 只是做个简单回顾。

  1. 导出: 直接看代码以及注释
// 导出单个值
export const name = '墨渊君';
export function FunctionName(){...}
export class ClassName {...}

// 同时导出多个值
export { name1, name2, …, nameN };

// 通过 as 进行重命名导出
export { variable1 as name1, variable2 as name2 };

// 解构导出并重命名
export const { name1, name2: bar } = obj;

// 导出默认值
export default expression;
export default function (…) { … } 
export default function name1(…) { … } 
export { name1 as default, … };

// 导出其他模块的内容(合集)
export * from './b'; // 导出 b 模块中的所有导出项(不包含默认导出项) 
export * as name1 from './b'; // Draft ECMAScript® 2O21
export { name1, name2 } from './b'; // 从 b 模块中导出指定参数
export { import1 as name1, import2 as name2 } from './b'; // 从 b 模块中导出指定参数并重命名
export { default } from './b'; // 从 b 模块中导出默认值
  1. 导入: 直接看代码以及注释
// 导出默认值
import defaultExport from "module-name";

// 导出指定参数
import { export1 } from "module-name";
import { export1, export2 } from "module-name";

// 使用 as 为导出的参数设置别名
import * as name from "module-name";
import { export1 as alias1 } from "module-name";
import { default as alias } from "module-name";
import { export1, export2 as alias2,  } from "module-name";
import { "string name" as alias } from "module-name";

// 同时导出默认、以及其他参数
import defaultExport, { export1 } from "module-name";
import defaultExport, * as name from "module-name";

// 不导入任何值, 会直接运行里面的代码模块
import "module-name";
  1. 路径查找规则: 路径处理规则基本同 Commonjs 就不展开了

3.2 原理

这里主要分为三个阶段进行展开: 构造、实例化、求值

  1. 构造: 从入口文件出发, 根据 import 一层层解析, 每个模块都会生成一个模块记录 Module Record

image

当然构造这一步骤实际情况又是比较复杂的:

  • 首先会先查找相关依赖, 形成一个依赖关系树(AST)
  • 其次还需要加载 import 对应模块的资源, 而为了尽量不阻塞线程, 这一步实际上又是异步进行的! 不同于 Commonjs 这里加载的资源大多可能是外部的资源, 所以速度肯定没有读取本地资源来得快, 所以就整成异步加载了!!
  • 当然这里其实也是存在缓存的, 一旦模块记录 Module Record 被创建, 它会被记录在模块映射 Module Map 中。被记录后, 如果再有对相同 URL 的请求, 将直接采用模块映射 Module Map 中 URL 对应的模块记录 Module Record
  1. 实例化: 所谓实例化就是在内存中开辟出一块空间来, 存储所有 exprot 出来的数据, 同时将 export 和 import 涉及到的变量都指向对应的内存快中, 这一步又被称之为链接(linking)! 需要注意的是, 这里只是开辟了一批内存用于存储变量, 但是这里并没有执行代码, 所以实际上变量的值还未填充到内存中, 只是开辟内存并建立起一个完整的依赖关系图

image

  1. 求值: 上面一步划分了一块内存区域用来存储数据, 但实际上并没执行模块内的代码, 也就是没有实际往内存中填充数据, 而求值这一步目前就是执行所有模块的最外层代码, 往内存里填充相关的数据

3.3 特性

  1. 编译时加载: 如上文原理部分所描述的那样, ESM 在 构造 和 实例化 阶段就会去加载并解析模块了, 而这时模块内部代码其实并不会去执行(在求值阶段才开始执行代码); ESM 的设计思想是尽量的 静态化, 使得编译时就能确定模块的依赖关系, 以及输入和输出的变量, 也正是 静态化 特性使得在 Webpack 可以实现 Tree Shaking 功能

  2. 缓存机制: 如上文原理部分所描述的, 在加载模块时会有一个全局的映射表, 对于相同 URL 的模块只会加载一次, 当然模块只会加载一次, 里面代码自然也只会执行一次; 如下代码, 运行 main.mjs 会发现只会打印出一个 1

// main.mjs
import './a.mjs'
import './a.mjs'
// a.mjs
console.log('1')
  1. 导出的是引用地址: 在 ESM 中所有的值都是 动态绑定 的, 简单理解就是所有的值都是存在内存中, 通过引用地址的方式进行连接的(包括基本类型数据), 所以模块内部变量如果改变那么该值在其他地方引用到的话也是会同步变化的, 即便这个值是个基本类型数据
// a.mjs
export let num = 1

export const addNum = () => {
  num += 1
}

export const getNum = () => {
  console.log('getNum:', num)
}
// main.mjs
import { num, addNum, getNum } from './a.mjs'

console.log(num) // 1
addNum() 
console.log(num) // 2
getNum() // getNum: 2

image

  1. this 指向: 在 ESM 模块中, this 等于 undefined
console.log(this === undefined) // true
  1. 静态 & 动态: 上文我们提到 ESM 是静态化的, 也就是你在执行代码前就已经经过构造和实例化了, 也就是在这之前所有模块的相互依赖图都已经建立起来了

所以对于 import xxx from xxx 语法来说它是只支持 静态化 的, 也就是说它们只能在模块内的顶层被使用, 不能在条件判断或者函数内部被调用, 并且路径也只能是字符串字面量, 不能带有变量, 因为在解析模块时, 模块代码是还没运行的, 这时是根本不知道变量值是啥

let add = ''

let moduleName = 'a'

import aModule from `./${moduleName}.mjs` // 报错, 

const adModule = () => {
  import { num, addNum, getNum } from './a.mjs' // 报错, 导入声明只能在模块顶部使用
}

if (add) {
  import { num, addNum, getNum } from './a.mjs' // 报错, 导入声明只能在模块顶部使用
}

那么我们是不是就不能在 ES 中动态加载模块呢? 其实不是的, ES 之后其实又新增了 import() 方法, 通过它我们就能够实现动态加载的功能, 记住该方法返回的是一个 Promise

const adModule = async (moduleName) => {
  const res = await import(`./${moduleName}.mjs`)
  // res 打印内容如下:
  // [Module: null prototype] {
  //   addNum: [Function: addNum],
  //   getNum: [Function: getNum],
  //   num: 1
  // }
}

adModule('a')
  1. 循环引用问题: 如上文所述, ESM 的动态绑定的, 在运行代码前所以依赖关系都已经建立, 所以对于 ESM 根本不用关心是否发生了 循环引言, 只是在取值时需要注意取值的时机(确保初始化完成)
// main.mjs
import a from './a.mjs'

console.log('main 模块 - a:', a)
// a.mjs
import b from './b.mjs'

console.log('a 模块 - b:', b)

export default {
  age: 18,
  name: '墨渊君',
  moduleName: 'a',
}
// b.mjs
import a from './a.mjs'

console.log('b 模块 - a', a)

export default {
  moduleName: 'b'
}

上面代码, 执行 main.mjs 将会报错, 因为部分模块的值还未初始化完成(还没绑定)

image

解决办法就是需要注意读取数据时机, 比如这里我们可以使用 setTimeout 来获取值

// main.mjs
import a from './a.mjs'

setTimeout(() => {
  console.log('main 模块 - a:', a)
}, 0)
// a.mjs
import b from './b.mjs'

setTimeout(() => {
  console.log('a 模块 - b:', b)
}, 0)

export default {
  age: 18,
  name: '墨渊君',
  moduleName: 'a',
}
// b.mjs
import a from './a.mjs'

setTimeout(() => {
  console.log('b 模块 - a', a)
}, 0)

export default {
  moduleName: 'b'
}

执行 main.mjs 结果如下:

image

  1. 严格模式, 也就是导出的值是不允许被修改: 如下代码所示, 变量 a 是从 ./a.mjs 导出的内容, 这里的变量 a 有点类似 const 声明的变量, 它的值是不能被修改的, 当然改里面的属性还是可以的
import a from './a.mjs'

a = {} // 报错, 无法赋值
a.age = 1 // 这个是可以的

四、区别

最后我们简单看下 Commonjs 和 ESM 的区别吧

类别CommonjsESM
底层就是普通函数从引擎、语法层面做了支持
模块加载时机运行时加载编译时加载
缓存机制支持支持
导出数据格式是正常 JS 操作, 基本类型导出的是值的拷贝, 引用对象导出的是引用地址导出的都是引用地址(即动态绑定)
this 指向指向当前模块等于 undefined
动态加载因为是普通函数, 所以完全支持import xxx from xxx 是静态化的, 但是提供了函数 import() 可实现动态加载
循环引用问题通过缓存机制实现, 但是有可能取到空值编译阶段就已经确定依赖关系, 并且值和变量是动态绑定的, 不存在循环引用问题, 当然实际还是需要注意取值时机

补充: 为什么不在浏览器也用 CommonJS

  • 回答这个问题之前, 我们首先要清楚一个事实, CommonJS 的 require 语法是同步的, 当我们使用 require 加载一个模块的时候, 必须要等这个模块加载完后, 才会执行后面的代码
  • 那我们的问题也就很容易回答了, NodeJS 是服务端, 使用 require 语法加载模块, 一般是一个文件, 只需要从本地硬盘中读取文件, 它的速度是比较快的
  • 但是在浏览器端就不一样了, 文件一般存放在服务器或者 CDN 上, 如果使用同步的方式加载一个模块还需要由网络来决定快慢, 可能时间会很长, 这样浏览器很容易进入 假死状态
  • 所以异步加载, 是比较适合在浏览器端使用

五、参考

大家好, 我是墨渊君, 如果您喜欢我的文章可以: