原生 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 语法。 然而,与实际规格有一些关键的区别;这里是一个有用的解释者。
什么是 Native 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) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
/path/to/index.js:1
export default {}
^^^^^^
SyntaxError: Unexpected token '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 export 'named' not found. The requested module 'sample-library' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:
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']
}
})
你可能会发现,你也 build.transpile
中需要添加其他包,这些包被这些库导入。
别名库
在某些情况下,你可能还需要手动将库别名为 CJS 版本,例如:
export default defineNuxtConfig({
alias: {
'sample-library': 'sample-library/dist/sample-library.cjs.js'
}
})
默认导出
一个 CommonJS 格式的依赖,可以使用 module.exports
或 exports
来提供一个默认导出:
module.exports = { test: 123 }
// or
exports.test = 123
这通常在我们可以 require
这样的依赖时工作得很好:
const pkg = require('cjs-pkg')
console.log(pkg) // { test: 123 }
Node.js 在原生 ESM 模式下,启用 esModuleInterop
的 typescript(https://www.typescriptlang.org/tsconfig#esModuleInterop)和 webpack 等捆绑器提供了一个兼容机制,以便我们可以默认导入此类库。
这种机制通常被称为 “interop require default”:
import pkg from 'cjs-pkg'
console.log(pkg) // { test: 123 }
然而,由于语法检测的复杂性和不同的捆绑格式,总是有可能兼容默认导入失败,我们最终得到这样的结果:
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' } }
在这种情况下,我们需要手动进行默认导入:
// 静态导入
import { default as pkg } from 'cjs-pkg'
// 动态导入
import('cjs-pkg').then(m => m.default || m).then(console.log)
为了处理更复杂的情况并提高安全性,我们建议并在 Nuxt 内部使用 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 内置和仅 Node.js 的依赖,以便你的库可以在没有 Nitro polyfills 的情况下在浏览器和使用 Edge Workers。
- 使用新的
exports
字段和条件导出。(阅读更多)。
{
"exports": {
".": {
"import": "./dist/mymodule.mjs"
}
}
}