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) 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/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 }
// or
exports.test = 123
如果我们 require
这样的依赖项,通常可以正常工作
const pkg = require('cjs-pkg')
console.log(pkg) // { test: 123 }
Node.js 在原生 ESM 模式下,启用了 esModuleInterop
的 typescript 以及 webpack 等打包工具,提供了一种兼容性机制,以便我们可以默认导入这样的库。此机制通常被称为“互操作 require 默认值”
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' } }
在这种情况下,我们需要手动互操作默认导出
// Static import
import { default as pkg } from 'cjs-pkg'
// Dynamic import
import('cjs-pkg').then(m => m.default || m).then(console.log)
为了处理更复杂的情况并提高安全性,我们建议并在内部使用 Nuxt 中的 mlly,它可以保留命名导出。
import { interopDefault } from 'mlly'
// Assuming the shape is { 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 的依赖项,以使您的库可以在浏览器和边缘工作器中使用,而无需 Nitro polyfill。
- 使用带有条件导出的新
exports
字段。(阅读更多)。
{
"exports": {
".": {
"import": "./dist/mymodule.mjs"
}
}
}