ES模块 ES Modules

本指南有助于解释什么是 ES 模块以及如何使 Nuxt 应用程序(或上游库)与 ESM 兼容。

本篇背景

Nuxt 3(和 Bridge)使用原生 ES 模块。

CommonJS Modules

CommonJS (CJS) 是 Node.js 引入的一种格式,允许在独立的 JavaScript 模块之间共享功能(阅读更多)。 您可能已经熟悉这种语法:

js
const a = require('./a')

module.exports.a = a

像 webpack 和 Rollup 这样的捆绑器支持这种语法,并允许你在浏览器中使用用 CommonJS 编写的模块。

ESM Syntax

大多数时候,当人们谈论 ESM 与 CJS 时,他们谈论的是编写模块的不同语法 .

js
import a from './a'

export { a }

在 ECMAScript 模块 (ESM) 成为标准之前(用了 10 多年!),像 webpack 这样的工具甚至像 TypeScript 这样的语言都开始了 支持所谓的 ESM 语法

但是,与实际规格存在一些关键差异; 这是 有用的解释器

什么是“原生”ESM?

您可能已经使用 ESM 语法编写应用程序很长时间了。 毕竟,浏览器原生支持它,在 Nuxt 2 中,我们将您编写的所有代码编译为适当的格式(CJS 用于服务器,ESM 用于浏览器)。

当使用安装到包中的模块时,情况会有所不同。 示例库可能同时公开 CJS 和 ESM 版本,让我们选择我们想要的:

json
{
  "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 上下文中的有效导入?

当您“导入”一个模块而不是“要求”它时,Node.js 会以不同的方式解析它。 例如,当您导入 sample-library 时,Node.js 不会查找 main 而是查找该库的 package.json 中的 exportsmodule 条目。

动态导入也是如此,例如 const b = await import('sample-library')

Node 支持以下类型的导入(请参阅 文档):

  1. .mjs 结尾的文件——这些文件应该使用 ESM 语法
  2. .cjs 结尾的文件——这些文件应该使用 CJS 语法
  3. .js 结尾的文件——除非它们的 package.jsontype: 'module',否则这些文件应该使用 CJS 语法

会出现什么样的问题?

长期以来,模块作者一直在生成 ESM 语法构建,但使用诸如 .esm.js.es.js 之类的约定,他们已将这些约定添加到其 package.json 中的 module 字段中。 直到现在这还不是问题,因为它们只被像 webpack 这样的打包器使用,它们并不特别关心文件扩展名。

但是,如果您尝试在 Node.js ESM 上下文中导入带有 .esm.js 文件的包,它将不起作用,并且您会收到如下错误:

bash
(node:22145) Warning:要加载 ES 模块,请在 package.json 中设置 "type": "module" 或使用 .mjs 扩展名。
/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 语法构建中进行命名导入,您也可能会遇到此错误:

bash
file:///path/to/index.mjs:5
import { named } from 'sample-library'
         ^^^^^
SyntaxError: 未找到命名导出“命名”。 请求的模块“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.js 导入。

转译库

同时,您可以通过将它们添加到 build.transpile 来告诉 Nuxt 不要尝试导入这些库:

js
export default defineNuxtConfig({
  build: {
    transpile: ['sample-library']
  }
})

您可能会发现您_还_需要添加这些库正在导入的其他包。

别名库

在某些情况下,您可能还需要手动将库别名为 CJS 版本,例如:

js
export default defineNuxtConfig({
  alias: {
    'sample-library': 'sample-library/dist/sample-library.cjs.js'
  }
})

默认导出

CommonJS 格式的依赖项,可以使用 module.exportsexports 来提供默认导出:

node_modules/cjs-pkg/index.js
js
module.exports = { test: 123 }
// or
exports.test = 123

如果我们“需要”这样的依赖,这通常会很好地工作:

test.cjs
js
const pkg = require('cjs-pkg')
console.log(pkg) // { test: 123 }

本地 ESM 模式下的 Node.js启用esModuleInterop的Typescript 和 webpack 等打包器提供了一种兼容机制,以便我们可以默认导入此类库。 这种机制通常被称为“导出默认模块并转换为可交互引用” interop require default

js
import pkg from 'cjs-pkg'
console.log(pkg) // { test: 123 }

然而,由于语法检测的复杂性和不同的包格式,互操作默认值总是有可能失败,我们最终会得到这样的结果:

js
import pkg from 'cjs-pkg'
console.log(pkg) // { default: { test: 123 } }

同样在使用动态导入语法时(在 CJS 和 ESM 文件中),我们总是会遇到这种情况:

js
import('cjs-pkg').then(console.log)
// [Module: null prototype] { default: { test: '123' } }

在这种情况下,我们需要手动互操作默认导出:

js
// Static import

// Dynamic import
import('cjs-pkg').then(m => m.default || m).then(console.log)

为了处理更复杂的情况和更安全,我们建议并在 Nuxt 3 内部使用可以保留命名导出的 mlly

js
import { interopDefault } from 'mlly'

// 假设情况是 { default: { foo: 'bar' }, baz: 'qux' }
import myModule from 'my-module'

console.log(interopDefault(myModule)) // { foo: 'bar', baz: 'qux' }

库作者指南

好消息是修复 ESM 兼容性问题相对简单。 有两个主要选项:

  1. 您可以将 ESM 文件重命名为以 .mjs 结尾。
    这是推荐的最简单的方法。 您可能需要解决库的依赖项问题,可能还需要解决构建系统的问题,但在大多数情况下,这应该可以解决问题。 还建议将 CJS 文件重命名为以.cjs结尾,以获得最大的明确性。
  2. 您可以选择将整个图书馆设为 ESM-only
    这意味着在您的 package.json 中设置 type: 'module' 并确保您构建的库使用 ESM 语法。 但是,您可能会遇到依赖项问题 - 这种方法意味着您的库 只能 在 ESM 上下文中使用。

迁移

从 CJS 到 ESM 的第一步是更新任何对 require 的使用,改为使用 import

js
CJS
js
module.exports = ...
exports.hello = ...
js
CJS
js
const myLib = require('my-lib')

在 ESM 模块中,与 CJS 不同,requirerequire.resolve__filename__dirname 全局变量不可用 并且应该替换为 import()import.meta.filename

js
CJS
js
import { join } from 'node:path'
const newDir = join(__dirname, 'new-dir')
js
CJS
js
const someFile = require.resolve('./lib/foo.js')

最佳实践

  • 更喜欢命名导出而不是默认导出。 这有助于减少 CJS 冲突。 (参见 默认导出 部分)
  • 尽可能避免依赖 Node.js 内置插件和 CommonJS 或 Node.js-only 依赖项,以使您的库可在浏览器和 Edge Workers 中使用,而无需 Nitro polyfill。
  • 使用带有条件导出的新“导出”字段。 (阅读更多)。
json
{
  "exports": {
    ".": {
      "import": "./dist/mymodule.mjs"
    }
  }
}