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 不会查找 main 字段,而是会查找该库的 package.json 中的 exportsmodule 字段。

动态导入,如 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 认为是 CJS 的 ESM 构建中做了命名导入,也可能出现此错误:

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

转译库

在此期间,你可以通过将这些库添加到 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 }
// 或者
exports.test = 123

如果用 require 引入此依赖,这通常工作正常:

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

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

Node.js 的本地 ESM 模式、开启了 esModuleInteropTypeScript 和像 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 兼容性问题相对容易修复。主要有两种选择:

  1. 将你的 ESM 文件重命名为以 .mjs 结尾。
    这是推荐且最简单的做法。 你可能需要解决库依赖和构建系统的问题,但大多数情况下,这能帮你解决问题。建议将 CJS 文件重命名为 .cjs 以便最明确地表示。
  2. 选择让整个库只支持 ESM。
    这意味着在你的 package.json 设置 "type": "module",并确保构建库使用 ESM 语法。但是,可能会面临依赖问题,而且这意味着你的库只能被 ESM 环境消耗。

迁移

从 CJS 转向 ESM 的第一步是把所有 require 用法改为 import

module.exports = ...

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

在 ESM 模块中,不像 CJS,requirerequire.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 的依赖,以使你的库可以在浏览器和 Edge Workers 中使用,无需 Nitro polyfill。
  • 使用新的 exports 字段和条件导出功能(了解更多)。
{
  "exports": {
    ".": {
      "import": "./dist/mymodule.mjs"
    }
  }
}