关于JavaScript模块机制的一些令人不好理解五大问题

问题一:ESM 是什么?

之前 JavaScript 一直没有一个模块机制,所谓的 IIFE、CommonJs、UMD 等都是三方规范和实现,与 JavaScript 自身并无关系。

Node.js 一直以来用的都是 CommonJS 模块机制,而非 "JavaScript 模块机制"或 "ECMAScript 模块机制"。

像 Sea.js、require.js 都是三方实现的 ,甚至 Node.js 中的 CommonJS 也是 Node.js 在自身代码里面实现的,并不是 V8 的能力。

ECMAScript modules 是 ECMAScript 的官方模块机制,与 CommonJS 等都不一样,其内置在 JavaScript 语言特性中,而不用自己实现一套模块机制。

如果你用了 V8 对应版本,那么只要通过 V8 的 API 适配一下模块加载,就直接拥有了导入模块的能力。

问题二:Node.js 中同时存在 ESM 和 CJS两种模块机制

ESM 是 ECMAScript 官方的模块机制,从语法层面直接支持。

虽然语法上面支持了,但是当 Node.js 拿到一个 import ... 'foo' 的时候,还是得决定怎么加载一个模块。

V8 只是实现了语法上面的解析,具体加载代码等操作还是需要各运行时自行适配。

毕竟不同运行时对于标识解析、代码加载的规则不一样,比如 Deno 支持从 HTTP 进行远端加载,而 Node.js 至少在 v18 还没有内置默认打开,需要用户自行实现或开启。

Node.js 支持两套模块机制,但其又各自独立。

不同的场景下,Node.js 会将一个模块判断为是 CJS 模块,还是 ESM 模块。

通常一个 *.mjs 会被认为是 ECMAScript module,而一个 *.cjs 则会被认为是 CommonJS 模块。

如果是 *.js 文件,则需要看离它最近的父 package.json 文件,Node.js 在 v12.0.0 中,为 package.json 增加了 type 字段,用于判别其麾下的 *.js 文件是 ECMAScript module 还是 CommonJS 模块。

  • 若type 值为 module,则其 *.js 为 ECMAScript module;

  • 若type 值为 commonjs 或者不存在该值,则其 *.js 为 CommonJS 模块。

问题三: CJS 与 ESM 能互相引用吗?

在 CJS模块机制下能使用使用ESM 的 import语法吗?

js 复制代码
// test2.js
console.log('test2', module)
console.log('test2 require', require.main)

// test.js
import * as a from './test2.js'

执行 node test.js,报错:

如果把 test.js 重命名为 test.mjs 则可正常运行。

在 ESM 下对 CJS 模块进行 import 可以吗?

比如,把 test2.js 改成 cjs 模式导出,test.mjs仍然可以用 import 导入。

不过这么一来,由于入口文件是 ESM 的,所以 test2.js 中的require.main 就不存在了。

在 ESM 的作用域下,不再存在 CJS 对应的上下文,如 require()、module、exports 这些统统不存在。

也就说,把 test.mjs 源码改为如下代码,直接会报错。

js 复制代码
require('test2');

ESM 可以通过 import 加载 CJS 模块,而反过来 CJS 模块是无法通过 require() 来加载 ESM 的。

这里涉及到一个本质问题,那就是模块加载的异同步问题。

CJS 的 require() 机制是完全同步的,而 ECMAScript module 的 import 机制则是异步的。

import 是异步的,那么在内部通过同步的方式模拟一个 require 流程是没问题的,所以 ECMAScript module 下可以通过 import 去加载 CommonJS 模块;

反过来不行,一个同步的东西是无法加载异步内容的,至少无法通过比较正统的方式解决。

ECMAScript module 加载机制是异步的。虽然在 import * as mod from 'xxx' 的语法中我们看起来是同步的,但其实在引擎内部帮你吃掉了异步的部分。

在 import() 中,该异步需要自己处理,它的返回值是一个 Promise。如:

js 复制代码
import('xxx').then(mod => { 
    // 这里的 mod 就是加载的 ECMAScript module 
});

问题四:什么叫运行时加载和编译时加载?

JavaScript 执行过程分为两个阶段: 编译阶段 和 执行阶段。

JavaScript代码经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码。如下图:

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

js 复制代码
showName()
console.log(myname)
var myname = 'jack'
function showName() {
    console.log('函数showName被执行');
}

下面,利用JavaScript 的常见报错类型来理解什么是编译阶段和执行阶段。

  1. RangeError

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

js 复制代码
function a () {  
    b()  
}  
function b () {  
    a()  
}  
a()  
  
// out:  
// RangeError: Maximum call stack size exceeded
  1. ReferenceError

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

js 复制代码
hello  
  
// out:  
// ReferenceError: hello is not defined
  1. SyntaxError

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

js 复制代码
console.log(1));  
  
// out:  
// SyntaxError: Unexpected token ')'
  1. TypeError

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

js 复制代码
var a = 1;  
a()  
  
// out:  
// TypeError: a is not a function

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

ESM 编译时加载 VS CJS 运行时加载

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

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

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

ESM 的设计理念是希望在编译时就确定模块依赖关系即输入输出,那如何在编译时就能确定依赖关系呢?

要想在编译阶段就能确定依赖关系,那必须要把 import 进行类似于变量提升。

import 提升 其实跟 JavaScript 代码的变量提升一样,在编译阶段发现有 import 就会像 var 一样进行提升。

为了验证这一点,看一下如下代码:

js 复制代码
console.log('main.js开始执行')
import say from './a.js'
console.log('main.js执行完毕')

// a.js
console.log('a模块加载')
export default function say() {
  console.log('hello , world')
}

执行顺序如下:

js 复制代码
a模块加载 
main.js开始执行 
main.js执行完毕

因为这种静态语法,所以import, export不能放在块级作用域或条件语句中。

js 复制代码
// 错误写法
function say() {
  import name from './a.js'
  export const author = 'dog'
}
// 错误写法
isexport && export const name = '《React进阶实践指南》'

在编译过程中确定了导入和导出的关系,所以更方便去查找依赖,这为tree shaking(摇树)创造了条件,这也是 ESM 支持tree-shaking操作的原因。

像这段CJS模块的加载代码被 parse 成 AST(抽象语法树) 时,很难分析出究竟依赖了哪些模块。

js 复制代码
const bar = require(`all/${['f', 'o', 'o'].join('')}`)
const foo = _.get(require('all'), 'foo')

同样,CJS 在做模块导出时也无法静态识别:

js 复制代码
module.exports = {}
Object.assign(module.exports, {bar: 'foo'})

但是在 ESM 中,import/exports 一目了然,对于没有被 import 的部分,也很自然的容易区分出来。

总之,就是通过限定语法,让本来需要运行代码才能确定的依赖,可以在 AST 阶段就能确定下来。

问题五:ESM 的使用需要注意什么?

  1. import的值是一个常量
js 复制代码
// test.mjs
export let a = 1
export function plus() {
  a++
}

// main.mjs
import { a, plus } from './test.mjs'
console.log(a) // 1
a++

当执行 main.js的时候会报错 Uncaught TypeError: Assignment to constant variable。

通过 import 导入的值可以看出是一个 const 常量,不能修改。

当第一次打印导入的变量 a 的值是 1,然后执行 plus 方法,再次打印 a 发现值是 2。

也就是,使用 import 导入的变量是与原变量是引用关系,而不是拷贝。

  1. ESM 不能解构

与 CJS 不同,ESM 中 import 的不是对象, export 的也不是对象。例如,下面的写法会提示语法错误:

js 复制代码
// 语法错误!这不是解构!!!  
import { a: myA } from './a.mjs'  
  
// 语法错误!  
export {  
    a: 'a'  
}

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

  1. EMS的使用

请参考这篇文字全解析 ESM 模块语法,出去还是进来都由你说了算

总结

  1. ESM是JS在语法层面实现了模块机制,而不是第三方实现。如果你用了 V8 对应版本,那么只要通过 V8 的 API 适配一下模块加载,就直接拥有了导入模块的能力。

  2. Nodejs目前支持ESM和CJS两种模块机制,ESM可以通过import导入CJS模块,反之不行。这是因为两者模块加载的同步异步问题,CJS是同步加载,ESM是异步加载,异步可以实现同步,但是同步无法实现异步。另外,ESM的异步过程发生在引擎内部,对外表现为同步。

  3. ESM在编译阶段就确定了模块依赖,而不是在运行时阶段确定,这为tree shaking提供了条件。

相关推荐
To_OC9 小时前
从一头雾水到跑通全流程:我用一个周末啃透了JWT登录鉴权
前端·后端·http
excel11 小时前
研究 Vue 3 源码的收获
前端·vue.js
可乐鸡翅yeah_12 小时前
业务中 M3U8 水印相关坑,硬水印和动态水印区别
前端·网络·数据库·ffmpeg·m3u8在线
lerhxx13 小时前
AI 应用如何高效优雅地恢复中断?—— "连接解耦 + 状态持久化"
前端·javascript
计算机魔术师13 小时前
METR 演示 AI 智能体如何篡改 Inspect 评估记录以掩盖不当行为
前端
前端snow13 小时前
ai agent --- 异步处理之 Rabbit MQ
前端
念何架构之路13 小时前
zap扩展生态与总结
java·前端·数据库
独孤九剑打醒他14 小时前
【原创开源】【概念设计】源 - 栅 - 漏 - 栅 - 源 横向双栅 MOS,低压交流多值逻辑芯片探索
前端·其他·架构·开源·硬件工程
mantou13214 小时前
我给 AI Agent 做了个「油猴」:让 Claude Code / Codex 直接用你已登录的浏览器
前端·javascript·后端
默_笙14 小时前
🍕 一个主编、三个工种、两本手册:搭一支 AI 调研队
前端·javascript