ES模块
本指南旨在帮助解释什么是ES模块,以及如何使Nuxt应用程序(或上游库)与ESM兼容。
背景
CommonJS模块
CommonJS(CJS)是由Node.js引入的一种格式,允许在独立的JavaScript模块之间共享功能(了解更多)。 你可能已经熟悉这种语法:
const a = require('./a')
module.exports.a = a
像webpack和Rollup这样的打包工具支持这种语法,并允许你在浏览器中使用CommonJS编写的模块。
ESM语法
大多数情况下,当人们谈论ESM与CJS时,他们谈论的是一种不同的用于编写模块的语法。
import a from './a'
export { a }
在ECMAScript模块(ESM)成为标准之前(花了超过10年的时间!),像webpack这样的工具甚至像TypeScript这样的语言开始支持所谓的ESM语法。 然而,与实际规范相比,存在一些关键差异;这是一个有用的解释者。
什么是'原生'ESM?
你可能已经使用ESM语法编写你的应用程序很长时间了。毕竟,它在浏览器中得到了原生支持,在Nuxt 2中,我们将你编写的所有代码编译为适当的格式(服务器使用CJS,浏览器使用ESM)。
当使用你要安装到你的包中的模块时,情况会有所不同。一个示例库可能会暴露出CJS和ESM版本,并让我们选择使用哪个:
{
"name": "sample-library",
"main": "dist/sample-library.cjs.js",
"module": "dist/sample-library.esm.js"
}
因此,在Nuxt 2中,打包工具(webpack)将为服务器构建引入CJS文件('main'),并为客户端构建使用ESM文件('module')。
然而,在最新的Node.js LTS版本中,现在可以在Node.js中使用原生ESM模块。这意味着Node.js本身可以使用ESM语法处理JavaScript,尽管默认情况下并不这样做。启用ESM语法的两种最常见的方法是:
- 在你的
package.json
中设置type: 'module'
并继续使用.js
扩展名 - 使用
.mjs
文件扩展名(推荐)
这就是我们在Nuxt Nitro中所做的;我们输出一个.output/server/index.mjs
文件。这告诉Node.js将此文件视为原生ES模块。
在Node.js上下文中什么是有效的导入?
当你import
一个模块而不是require
它时,Node.js会以不同的方式解析它。例如,当你导入sample-library
时,Node.js不会寻找main
,而是寻找该库的package.json
中的exports
或module
入口。
这对于动态导入也是如此,比如const b = await import('sample-library')
。
Node支持以下类型的导入(参见文档):
- 以
.mjs
结尾的文件 - 这些文件应该使用ESM语法 - 以
.cjs
结尾的文件 - 这些文件应该使用CJS语法 - 以
.js
结尾的文件 - 这些文件应该使用CJS语法,除非它们的package.json
中有type: 'module'
可能会遇到什么问题?
很长一段时间里,模块作者一直在生成ESM语法构建,但是使用了.esm.js
或.es.js
等命名约定,并将其添加到了其package.json
中的module
字段中。直到现在,这一直不是问题,因为它们只被webpack等打包工具使用,这些工具对文件扩展名并不特别关心。
然而,如果你尝试在Node.js的ESM上下文中导入一个带有.esm.js
文件的包,它将无法工作,并且你将收到如下错误:
(node:22145) 警告: 要加载ES模块,请在package.json中设置"type": "module",或使用.mjs扩展名。
/path/to/index.js:1
export default {}
^^^^^^
SyntaxError: 意外的标记'export'
at wrapSafe (internal/modules/cjs/loader.js:1001:16)
at Module._compile (internal/modules/cjs/loader.js:1049:27)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
....
at async Object.loadESM (internal/process/esm_loader.js:68:5)
如果你从一个ESM语法构建中导入一个带有命名导入的包,并且Node.js认为它是CJS,你也会遇到这个错误:
file:///path/to/index.mjs:5
import { named } from 'sample-library'
^^^^^
SyntaxError: 找不到命名导出 'named'。请求的模块 'sample-library' 是一个CommonJS模块,可能不支持所有module.exports作为命名导出。
可以始终通过默认导出来导入CommonJS模块,例如使用:
import pkg from 'sample-library';
const { named } = pkg;
at ModuleJob._instantiate (internal/modules/esm/module_job.js:120:21)
at async ModuleJob.run (internal/modules/esm/module_job.js:165:5)
at async Loader.import (internal/modules/esm/loader.js:177:24)
at async Object.loadESM (internal/process/esm_loader.js:68:5)
解决ESM问题
如果你遇到这些错误,问题几乎肯定出在上游库。他们需要修复他们的库以支持被Node导入。
转译库
与此同时,你可以告诉Nuxt不要尝试导入这些库,方法是将它们添加到build.transpile
中:
export default defineNuxtConfig({
build: {
transpile: ['sample-library']
}
})
你可能还需要添加其他被这些库导入的包。
别名库
在某些情况下,你可能还需要手动将库别名为CJS版本,例如:
export default defineNuxtConfig({
alias: {
'sample-library': 'sample-library/dist/sample-library.cjs.js'
}
})
默认导出
具有CommonJS格式的依赖项可以使用module.exports
或exports
来提供默认导出:
module.exports = { test: 123 }
// 或者
exports.test = 123
如果我们使用require
导入这样的依赖项,通常效果很好:
const pkg = require('cjs-pkg')
console.log(pkg) // { test: 123 }
原生ESM模式的Node.js、启用esModuleInterop
的TypeScript以及像webpack这样的打包工具提供了兼容机制,以便我们可以默认导入这样的库。
这种机制通常被称为"interop require default":
import pkg from 'cjs-pkg'
console.log(pkg) // { test: 123 }
然而,由于语法检测和不同的打包格式的复杂性,interop默认可能会失败,导致我们得到如下结果:
import pkg from 'cjs-pkg'
console.log(pkg) // { default: { test: 123 } }
此外,当使用动态导入语法(在CJS和ESM文件中都是如此)时,我们总是会遇到这种情况:
import('cjs-pkg').then(console.log) // [Module: null prototype] { default: { test: '123' } }
在这种情况下,我们需要手动进行默认导出的interop:
// 静态导入
import { default as pkg } from 'cjs-pkg'
// 动态导入
import('cjs-pkg').then(m => m.default || m).then(console.log)
为了处理更复杂的情况并提供更高的安全性,我们建议在Nuxt 3中使用mlly,它可以保留命名导出。
import { interopDefault } from 'mlly'
// 假设结构是{ default: { foo: 'bar' }, baz: 'qux' }
import myModule from 'my-module'
console.log(interopDefault(myModule)) // { foo: 'bar', baz: 'qux' }
库作者指南
好消息是,修复ESM兼容性问题相对简单。有两个主要选项:
- 你可以将ESM文件重命名为
.mjs
结尾。
这是推荐且最简单的方法。 你可能需要解决库的依赖关系问题,可能还需要解决构建系统的问题,但在大多数情况下,这应该可以解决问题。同时,建议将CJS文件重命名为.cjs
,以确保最明确。 - 你可以选择使整个库仅支持ESM。
这意味着在你的package.json
中设置type: 'module'
,并确保你的构建库使用ESM语法。然而,你可能会遇到依赖项的问题,而且这种方法意味着你的库只能在ESM上下文中使用。
迁移
从CJS到ESM的初始步骤是将require
的任何用法更新为使用import
:
module.exports = ...
exports.hello = ...
const myLib = require('my-lib')
在ESM模块中,与CJS不同,require
、require.resolve
、__filename
和__dirname
全局变量不可用,应该用import()
和import.meta.filename
替换。
import { join } from 'path'
const newDir = join(__dirname, 'new-dir')
const someFile = require.resolve('./lib/foo.js')
最佳实践
- 建议使用命名导出而不是默认导出。这有助于减少CJS冲突。(参见默认导出部分)
- 尽量避免依赖Node.js内置模块和仅限于CommonJS或仅限于Node.js的依赖项,以使你的库在浏览器和Edge Worker中可用,而不需要Nitro的polyfill。
- 使用新的
exports
字段和条件导出。 (了解更多)。
{
"exports": {
".": {
"import": "./dist/mymodule.mjs"
}
}
}