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)。

当把模块发布到你的 package 时,情况有点不同。一个示例库可能同时暴露 CJS 和 ESM 版本,并让我们选择使用哪一个:

{
  "name": "sample-library",
  "main": "dist/sample-library.cjs.js",
  "module": "dist/sample-library.esm.js"
}

因此在 Nuxt 2 中,打包器(webpack)会在服务器构建中拉取 CJS 文件('main'),并在客户端构建中使用 ESM 文件('module')。

module 字段是打包器(例如 webpack 和 Rollup)约定使用的字段,Node.js 本身并不识别它。Node.js 在模块解析时只会使用 exportsmain 字段。

然而,在近期的 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 条目,如果未定义则回退到 main 条目。

动态导入也一样,比如 const b = await import('sample-library')

Node 支持以下几类导入(参见文档):

  1. .mjs 结尾的文件 —— 预期使用 ESM 语法
  2. .cjs 结尾的文件 —— 预期使用 CJS 语法
  3. .js 结尾的文件 —— 预期使用 CJS 语法,除非其 package.json 中有 "type": "module"

可能出现哪些问题?

长期以来,模块作者一直在生成 ESM 语法的构建产物,但使用类似 .esm.js.es.js 的约定,并将其添加到 package.jsonmodule 字段中。直到现在这并不是问题,因为这些构建产物通常仅被像 webpack 这样的打包器使用,而打包器并不太在意文件扩展名。

然而,如果你在 Node.js 的 ESM 上下文中尝试导入一个带有 .esm.js 文件的包,它将无法工作,你会得到类似这样的错误:

Terminal
(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 认为某个使用 ESM 语法的构建是 CJS,也可能出现这样的错误:

Terminal
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 导入。

转译(Transpiling)库

同时,你可以通过将这些库添加到 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.exportsexports 来提供默认导出:

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

如果我们使用 require 引入这样的依赖,这通常工作良好:

test.cjs
const pkg = require('cjs-pkg')

console.log(pkg) // { test: 123 }

在 Node.js 的原生 ESM 模式下在启用了 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' } }

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

// Static import
import { default as pkg } from 'cjs-pkg'

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

为了解决更复杂的情况并提高安全性,我们推荐并在内部使用 mlly 在 Nuxt 中保留命名导出。

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 兼容性问题相对简单。有两种主要选择:

  1. 你可以将 ESM 文件重命名为以 .mjs 结尾。
    这是推荐且最简单的方法。你可能需要解决库的依赖项以及可能的构建系统问题,但在大多数情况下,这应该能为你解决问题。也建议将 CJS 文件重命名为以 .cjs 结尾,以便更明确。
  2. 你可以选择让整个库仅为 ESM。
    这意味着在你的 package.json 中设置 "type": "module" 并确保构建产物使用 ESM 语法。不过,你可能会遇到与依赖项相关的问题 —— 此方法意味着你的库只能在 ESM 上下文中被消费。

迁移

从 CJS 到 ESM 的初始步骤是把任何使用 require 的地方改为使用 import

module.exports = function () { /* ... */ }

exports.hello = 'world'
const myLib = require('my-lib')

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

const { join } = require('node:path')

const newDir = join(__dirname, 'new-dir')
const someFile = require.resolve('./lib/foo.js')

最佳实践

  • 优先使用命名导出而不是默认导出。这有助于减少与 CJS 的冲突。(参见 默认导出 小节)
  • 尽可能避免依赖 Node.js 内置模块以及仅适用于 CommonJS 或 Node.js 的依赖,以便你的库可在浏览器和 Edge Workers 中使用,而无需 Nitro 的 polyfill。
  • 使用带有条件导出的新 exports 字段。(阅读更多)。
{
  "exports": {
    ".": {
      "import": "./dist/mymodule.mjs"
    }
  }
}