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

问题一:ESM 是什么?

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

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

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

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

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

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

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

虽然语法上面支持了,但是当 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,则其 *.jsECMAScript module

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

问题三: CJSESM 能互相引用吗?

CJS模块机制下能使用使用ESMimport语法吗?

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()moduleexports 这些统统不存在

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

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

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

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

CJSrequire() 机制是完全同步的,而 ECMAScript moduleimport 机制则是异步的

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 之所以被称为 编译时加载,是因为它的模块解析是发生在 编译阶段

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

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

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模块的加载代码被 parseAST(抽象语法树) 时,很难分析出究竟依赖了哪些模块。

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 不同,ESMimport 的不是对象, export 的也不是对象。例如,下面的写法会提示语法错误:

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

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

  1. EMS的使用

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

总结

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

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

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

相关推荐
雪落满地香14 分钟前
前端:改变鼠标点击物体的颜色
前端
余生H1 小时前
前端Python应用指南(二)深入Flask:理解Flask的应用结构与模块化设计
前端·后端·python·flask·全栈
outstanding木槿1 小时前
JS中for循环里的ajax请求不数据
前端·javascript·react.js·ajax
酥饼~1 小时前
html固定头和第一列简单例子
前端·javascript·html
一只不会编程的猫1 小时前
高德地图自定义折线矢量图形
前端·vue.js·vue
m0_748250931 小时前
html 通用错误页面
前端·html
来吧~1 小时前
vue3使用video-player实现视频播放(可拖动视频窗口、调整大小)
前端·vue.js·音视频
han_1 小时前
不是哥们,我的console.log突然打印不出东西了!
前端·javascript·chrome
魔术师卡颂1 小时前
最近看到太多 cursor 带来的焦虑,有些话想说
前端·aigc·openai
鎈卟誃筅甡1 小时前
Vuex 的使用和原理详解
前端·javascript