Nuxt3 中文课程《实战全栈开发简书》限时优惠

ES模块

Nuxt 3(和Bridge)使用原生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.json中的module字段中。直到现在,这一直不是问题,因为它们只被webpack等打包工具使用,这些工具对文件扩展名并不特别关心。

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

终端
(node:22145) 警告: 要加载ES模块,请在package.json中设置"type": "module",或使用.mjs扩展名。
/path/to/index.js:1

export default {}
^^^^^^

SyntaxError: 意外的标记'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)

如果你从一个ESM语法构建中导入一个带有命名导入的包,并且Node.js认为它是CJS,你也会遇到这个错误:

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

转译库

与此同时,你可以告诉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.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 }

原生ESM模式的Node.js、启用esModuleInterop的TypeScript以及像webpack这样的打包工具提供了兼容机制,以便我们可以默认导入这样的库。 这种机制通常被称为"interop require default":

import pkg from 'cjs-pkg'

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

然而,由于语法检测和不同的打包格式的复杂性,interop默认可能会失败,导致我们得到如下结果:

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

在这种情况下,我们需要手动进行默认导出的interop:

// 静态导入
import { default as pkg } from 'cjs-pkg'

// 动态导入
import('cjs-pkg').then(m => m.default || m).then(console.log)

为了处理更复杂的情况并提供更高的安全性,我们建议在Nuxt 3中使用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 Worker中可用,而不需要Nitro的polyfill。
  • 使用新的exports字段和条件导出。 (了解更多)。
{
  "exports": {
    ".": {
      "import": "./dist/mymodule.mjs"
    }
  }
}