ES 模块

Nuxt 使用原生 ES 模块。

本指南旨在解释什么是 ES 模块,以及如何使 Nuxt 应用(或上游库)与 ESM 兼容。

¥This guide helps explain what ES Modules are and how to make a Nuxt app (or upstream library) compatible with ESM.

背景

¥Background

CommonJS 模块

¥CommonJS Modules

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

¥CommonJS (CJS) is a format introduced by Node.js that allows sharing functionality between isolated JavaScript modules (read more). You might be already familiar with this syntax:

const a = require('./a')

module.exports.a = a

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

¥Bundlers like webpack and Rollup support this syntax and allow you to use modules written in CommonJS in the browser.

ESM 语法

¥ESM Syntax

大多数时候,当人们谈论 ESM 与 CJS 时,他们指的是编写 modules 的不同语法。

¥Most of the time, when people talk about ESM vs. CJS, they are talking about a different syntax for writing modules.

import a from './a'

export { a }

在 ECMAScript 模块 (ESM) 成为标准之前(这花了 10 多年的时间!),像 webpack 这样的工具,甚至像 TypeScript 这样的语言都开始支持所谓的 ESM 语法。然而,与实际规范有一些关键区别;这里是 实用解释器

¥Before ECMAScript Modules (ESM) became a standard (it took more than 10 years!), tooling like webpack and even languages like TypeScript started supporting so-called ESM syntax. However, there are some key differences with actual spec; here's a helpful explainer.

什么是 '原生' ESM?

¥What is 'Native' ESM?

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

¥You may have been writing your app using ESM syntax for a long time. After all, it's natively supported by the browser, and in Nuxt 2 we compiled all the code you wrote to the appropriate format (CJS for server, ESM for browser).

将模块添加到包中时,情况会略有不同。示例库可能同时公开 CJS 和 ESM 版本,让我们选择所需的版本:

¥When adding modules to your package, things were a little different. A sample library might expose both CJS and ESM versions, and let us pick which one we wanted:

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

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

¥So in Nuxt 2, the bundler (webpack) would pull in the CJS file ('main') for the server build and use the ESM file ('module') for the client build.

但是,在最近的 Node.js LTS 版本中,现在可以在 Node.js 中使用 使用原生 ESM 模块。这意味着 Node.js 本身可以使用 ESM 语法处理 JavaScript,尽管它默认情况下不这样做。启用 ESM 语法的两种最常见方法是:

¥However, in recent Node.js LTS releases, it is now possible to use native ESM module within Node.js. That means that Node.js itself can process JavaScript using ESM syntax, although it doesn't do it by default. The two most common ways to enable ESM syntax are:

  • package.json 中设置 "type": "module",并继续使用 .js 扩展
  • 使用 .mjs 文件扩展名(推荐)

这就是我们在 Nuxt Nitro 中所做的;我们输出一个 .output/server/index.mjs 文件。这告诉 Node.js 将此文件视为原生 ES 模块。

¥This is what we do for Nuxt Nitro; we output a .output/server/index.mjs file. That tells Node.js to treat this file as a native ES module.

Node.js 上下文中哪些是有效的导入?

¥What Are Valid Imports in a Node.js Context?

当你对模块进行 import 而不是 require 操作时,Node.js 会以不同的方式解析它。例如,当你导入 sample-library 时,Node.js 将不会查找 main,而是在该库的 package.json 中查找 exportsmodule 条目。

¥When you import a module rather than require it, Node.js resolves it differently. For example, when you import sample-library, Node.js will look not for the main but for the exports or module entry in that library's package.json.

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

¥This is also true of dynamic imports, like const b = await import('sample-library').

Node 支持以下类型的导入(参见 docs):

¥Node supports the following kinds of imports (see docs):

  1. .mjs 结尾的文件 - 这些组件应使用 ESM 语法
  2. .cjs 结尾的文件 - 这些组件应使用 CJS 语法
  3. .js 结尾的文件 - 这些组件应使用 CJS 语法,除非其 package.json 包含 "type": "module"

可能存在哪些问题?

¥What Kinds of Problems Can There Be?

长期以来,模块作者一直在使用 ESM 语法构建,但使用 .esm.js.es.js 等约定,这些约定已添加到 package.json 中的 module 字段中。到目前为止,这还不是问题,因为它们仅被像 webpack 这样的打包工具使用,而这些工具并不特别在意文件扩展名。

¥For a long time module authors have been producing ESM-syntax builds but using conventions like .esm.js or .es.js, which they have added to the module field in their package.json. This hasn't been a problem until now because they have only been used by bundlers like webpack, which don't especially care about the file extension.

但是,如果你尝试在 Node.js ESM 上下文中导入包含 .esm.js 文件的包,它将无法工作,并且会收到如下错误:

¥However, if you try to import a package with an .esm.js file in a Node.js ESM context, it won't work, and you'll get an error like:

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 语法构建中导入了命名导入,你也可能会遇到此错误:

¥You might also get this error if you have a named import from an ESM-syntax build that Node.js thinks is 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 问题排查

¥Troubleshooting ESM Issues

如果你遇到这些错误,问题几乎肯定出在上游库上。它们需要 修复其库 才能支持 Node 导入。

¥If you encounter these errors, the issue is almost certainly with the upstream library. They need to fix their library to support being imported by Node.

库的转译

¥Transpiling Libraries

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

¥In the meantime, you can tell Nuxt not to try to import these libraries by adding them to build.transpile:

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

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

¥You may find that you also need to add other packages that are being imported by these libraries.

别名库

¥Aliasing Libraries

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

¥In some cases, you may also need to manually alias the library to the CJS version, for example:

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

默认导出

¥Default Exports

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

¥A dependency with CommonJS format, can use module.exports or exports to provide a default export:

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

如果我们 require 这样的依赖,这通常会很有效:

¥This normally works well if we require such dependency:

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

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

原生 ESM 模式下的 Node.js启用 esModuleInterop 的 TypeScript 和 以及 webpack 等打包工具提供了兼容机制,以便我们可以默认导入这些库。此机制通常称为 "interop 需要默认":

¥Node.js in native ESM mode, typescript with esModuleInterop enabled and bundlers such as webpack, provide a compatibility mechanism so that we can default import such library. This mechanism is often referred to as "interop require default":

import pkg from 'cjs-pkg'

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

但是,由于语法检测的复杂性和不同的 bundle 格式,互操作默认设置总是有可能失败,最终会导致类似这样的结果:

¥However, because of the complexities of syntax detection and different bundle formats, there is always a chance that the interop default fails and we end up with something like this:

import pkg from 'cjs-pkg'

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

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

¥Also when using dynamic import syntax (in both CJS and ESM files), we always have this situation:

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

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

¥In this case, we need to manually interop the default export:

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

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

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

¥For handling more complex situations and more safety, we recommend and internally use mlly in Nuxt that can preserve named exports.

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' }

库创建者指南

¥Library Author Guide

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

¥The good news is that it's relatively simple to fix issues of ESM compatibility. There are two main options:

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

迁移

¥Migration

从 CJS 到 ESM 的第一步是将所有 require 的使用更新为使用 import

¥The initial step from CJS to ESM is updating any usage of require to use import instead:

module.exports = ...

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

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

¥In ESM Modules, unlike CJS, require, require.resolve, __filename and __dirname globals are not available and should be replaced with import() and import.meta.filename.

import { join } from 'path'

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

最佳实践

¥Best Practices

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