ES 模块
Nuxt 使用原生的 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 会在该库的 package.json 中查找 exports 条目,如果未定义 exports 则回退到 main 条目。
对于动态导入也是如此,例如 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)
如果你从一个 Node.js 认为是 CJS 的 ESM 语法构建产物中做具名导入,你也可能会得到这个错误:
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/esm/loader.js:177:24)
at async Object.loadESM (internal/process/esm_loader.js:68:5)
排查 ESM 问题
如果你遇到了这些错误,问题几乎肯定出在上游库。它们需要修复自己的库 以支持被 Node 导入。
转译库
在此期间,你可以通过将这些库添加到 build.transpile,告诉 Nuxt 不要尝试导入它们:
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 }
然而,由于语法检测的复杂性和不同的打包格式,互操作默认导入总有可能失败,最终得到类似这样的结果:
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 = function () { /* ... */ }
exports.hello = 'world'
export default function () { /* ... */ }
export const hello = 'world'
const myLib = require('my-lib')
import myLib from 'my-lib'
// 或
const dynamicMyLib = await import('my-lib').then(lib => lib.default || lib)
在 ESM 模块中,与 CJS 不同,require、require.resolve、__filename 和 __dirname 这些全局变量不可用,
应该替换为 import() 和 import.meta.filename。
const { join } = require('node:path')
const newDir = join(__dirname, 'new-dir')
import { fileURLToPath } from 'node:url'
const newDir = fileURLToPath(new URL('./new-dir', import.meta.url))
const someFile = require.resolve('./lib/foo.js')
import { resolvePath } from 'mlly'
const someFile = await resolvePath('my-lib', { url: import.meta.url })
最佳实践
- 优先使用具名导出而非默认导出。这有助于减少 CJS 冲突。(参见默认导出 一节)
- 尽可能避免依赖 Node.js 内置模块以及 CommonJS 或仅限 Node.js 的依赖,使你的库无需 Nitro polyfill 即可在浏览器和边缘 Worker 中使用。
- 使用新的带条件导出的
exports字段。(了解更多)
{
"exports": {
".": {
"import": "./dist/mymodule.mjs"
}
}
}