ES 模組
本指南將解釋什麼是 ES 模組,以及如何使 Nuxt 應用程式(或上游庫)與 ESM 相容。
背景
CommonJS 模組
CommonJS (CJS) 是 Node.js 引入的一種格式,允許在獨立的 JavaScript 模組之間共享功能(閱讀更多)。你可能已經熟悉這種語法。
const a = require('./a')
module.exports.a = a
像 webpack 和 Rollup 這樣的打包工具支援這種語法,並允許你在瀏覽器中使用用 CommonJS 編寫的模組。
ESM 語法
大多數情況下,當人們談論 ESM 與 CJS 時,他們指的是編寫程式碼的不同語法。modules.
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 會在該庫的 package.json
中查詢 exports
條目,如果 exports
未定義,則回退到 main
條目。
動態匯入也是如此,例如 const b = await import('sample-library')
。
Node 支援以下型別的匯入(參見文件):
- 以
.mjs
結尾的檔案 - 這些檔案應使用 ESM 語法 - 以
.cjs
結尾的檔案 - 這些檔案應使用 CJS 語法 - 以
.js
結尾的檔案 - 這些檔案應使用 CJS 語法,除非其package.json
包含"type": "module"
可能會出現什麼問題?
長期以來,模組作者一直在生成 ESM 語法的構建,但使用諸如 .esm.js
或 .es.js
的約定,並將它們新增到 package.json
中的 module
欄位中。這在之前一直不是問題,因為它們只被 webpack 等打包工具使用,而這些打包工具並不特別關心副檔名。
但是,如果你在 Node.js ESM 環境中嘗試匯入帶有 .esm.js
檔案的包,它將無法工作,你會收到類似以下的錯誤:
(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 語法構建中進行具名匯入,你也可能會收到此錯誤。
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.exports
或 exports
提供預設匯出。
module.exports = { test: 123 }
// or
exports.test = 123
如果我們 require
這樣的依賴項,這通常執行良好。
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 }
然而,由於語法檢測和不同打包格式的複雜性,總是存在 interop default 失敗的可能性,最終導致類似以下情況:
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)
為了處理更復雜的情況和更高的安全性,我們推薦並在 Nuxt 內部使用mlly,它可以保留具名匯出。
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 相容性問題相對簡單。主要有兩種選擇:
- 你可以將 ESM 檔案重新命名為以
.mjs
結尾。
這是推薦且最簡單的方法。你可能需要解決庫的依賴項問題以及你的構建系統問題,但在大多數情況下,這應該能為你解決問題。為了最大的明確性,還建議將 CJS 檔案重新命名為以.cjs
結尾。 - 你可以選擇使整個庫僅支援 ESM。.
這意味著在你的package.json
中設定"type": "module"
,並確保你的構建庫使用 ESM 語法。但是,你可能會遇到依賴項問題——而且這種方法意味著你的庫只能在 ESM 環境中使用。
遷移
從 CJS 到 ESM 的第一步是將所有 require
的用法更新為使用 import
。
module.exports = function () { /* ... */ }
exports.hello = 'world'
export default function () { /* ... */ }
export const hello = 'world'
const myLib = require('my-lib')
import myLib from 'my-lib'
// or
const dynamicMyLib = await import('my-lib').then(lib => lib.default || lib)
在 ESM 模組中,與 CJS 不同,require
、require.resolve
、__filename
和 __dirname
全域性變數不可用,應替換為 import()
和 import.meta.filename
。
const { join } = require('node:path')
const newDir = join(__dirname, 'new-dir')
import { fileURLToPath } from 'node:url'
const newDir = fileURLToPath(new URL('./new-dir', import.meta.url))
const someFile = require.resolve('./lib/foo.js')
import { resolvePath } from 'mlly'
const someFile = await resolvePath('my-lib', { url: import.meta.url })
最佳實踐
- 首選具名匯出而不是預設匯出。這有助於減少 CJS 衝突。(參見預設匯出部分)
- 儘可能避免依賴 Node.js 內建模組和僅限 CommonJS 或 Node.js 的依賴項,以使你的庫在瀏覽器和 Edge Workers 中無需 Nitro polyfills 即可使用。
- 使用帶有條件匯出的新
exports
欄位。(閱讀更多).
{
"exports": {
".": {
"import": "./dist/mymodule.mjs"
}
}
}