釋出包

所有提供的 package.json 配置(未特別標記為“不起作用”的)在 Node.js 12.22.x(v12 最新版,最舊的支援版本)和當時最新的 17.2.0 版本中都能正常工作1,並且有趣的是,也分別相容 webpack 5.53.0 和 5.63.0。這些示例可在以下連結找到:JakobJingleheimer/nodejs-module-config-examples

對於好奇的同學,我們是如何走到這一步的深入探究 提供了背景資訊和更深層次的解釋。

選擇你的解決方案

有兩個主要選項,它們幾乎涵蓋了所有用例。

  • 用 CJS 編寫原始碼併發布(你使用 require());CJS 可以被 CJS 和 ESM(在所有 Node.js 版本中)消費。跳轉到 CJS 原始碼和分發
  • 用 ESM 編寫原始碼併發布(你使用 import,且不使用頂層 await);ESM 可以被 ESM 和 CJS(在 Node.js 22.x 和 23.x 中;參見 require() 一個 ES 模組)消費。跳轉到 ESM 原始碼和分發

通常最好只發布一種格式,CJS ESM,而不是兩者都發布。釋出多種格式可能會導致雙重包危害以及其他缺點。

還有其他一些選項可用,主要用於歷史目的。

作為包作者,你編寫你的包的消費者用以下方式編寫他們的程式碼你的選項
使用 require() 的 CJS 原始碼ESM:消費者 import 你的包CJS 原始碼,僅 ESM 分發
CJS 和 ESM:消費者可以 require()import 你的包CJS 原始碼,同時分發 CJS 和 ESM
使用 import 的 ESM 原始碼CJS:消費者 require() 你的包(並且你使用頂層 awaitESM 原始碼,僅 CJS 分發
CJS 和 ESM:消費者可以 require()import 你的包ESM 原始碼,同時分發 CJS 和 ESM

CJS 原始碼和分發

最精簡的配置可能只需要 "name"。但越不晦澀越好:本質上只需透過 "exports" 欄位/欄位集宣告包的匯出。

工作示例cjs-with-cjs-distro

{
  "name": "cjs-source-and-distribution"
  // "main": "./index.js"
}

請注意,packageJson.exports["."] = filepathpackageJson.exports["."].default = filepath 的簡寫。

ESM 原始碼和分發

簡單、經過考驗且可靠。

請注意,自 Node.js v23.0.0 起,可以 require 靜態 ESM(不使用頂層 await 的程式碼)。詳情請參見 使用 require() 載入 ECMAScript 模組

這與上面的 CJS-CJS 配置幾乎完全相同,只有一個小區別:"type" 欄位。

工作示例esm-with-esm-distro

{
  "name": "esm-source-and-distribution",
  "type": "module"
  // "main": "./index.js"
}

請注意,ESM 現在可以“向後”相容 CJS:從 23.0.0 和 22.12.0 版本開始,CJS 模組現在可以require() 一個 ES 模組,而無需任何標誌。

CJS 原始碼,僅 ESM 分發

這需要一些技巧,但也相當直接。這可能是那些針對較新標準的老專案,或者只是偏愛 CJS 但為不同環境釋出程式碼的作者的選擇。

工作示例cjs-with-esm-distro

{
  "name": "cjs-source-with-esm-distribution",
  "main": "./dist/index.mjs"
}

.mjs 副檔名是一張王牌:它會覆蓋任何其他配置,檔案將被視為 ESM。使用這個副檔名是必要的,因為 packageJson.exports.import 並不表示檔案是 ESM(這與普遍的,甚至可以說是普遍的誤解相反),它只表示這是在包被匯入時要使用的檔案(ESM 可以匯入 CJS。請參見下面的注意事項)。

CJS 原始碼,同時分發 CJS 和 ESM

為了*直接*為兩種使用者群體提供支援(以便你的分發包在兩種環境中都能“原生”工作),你有幾個選項。

直接將命名匯出附加到 exports

這是一種經典方法,但需要一定的技巧和精細操作。這意味著在現有的 module.exports 上新增屬性(而不是整個重新賦值 module.exports)。

工作示例cjs-with-dual-distro (properties)

{
  "name": "cjs-source-with-esm-via-properties-distribution",
  "main": "./dist/cjs/index.js"
}

優點

  • 包體積更小
  • 簡單易行(如果你不介意遵守一些微小的語法規定,這可能是最省力的方法)
  • 避免了雙重包危害

缺點

  • 需要非常特定的語法(無論是在原始碼中還是透過構建工具的複雜操作)。

有時,一個 CJS 模組可能會將 module.exports 重新賦值為其他東西(可能是一個物件或一個函式),就像這樣:

const  = {
  () {},
  () {},
  () {},
};

. = ;

Node.js 透過尋找特定模式的靜態分析來檢測 CJS 中的命名匯出,而上面的示例避開了這種檢測。為了使命名匯出可被檢測到,請這樣做:

.. = function () {};
.. = function () {};
.. = function () {};

使用一個簡單的 ESM 包裝器

設定複雜,難以找到正確的平衡點。

工作示例cjs-with-dual-distro (wrapper)

{
  "name": "cjs-with-wrapper-dual-distro",
  "exports": {
    ".": {
      "import": "./dist/esm/wrapper.mjs",
      "require": "./dist/cjs/index.js",
      "default": "./dist/cjs/index.js"
    }
  }
}

優點

  • 包體積更小

缺點

  • 可能需要複雜的構建工具操作(我們沒有在 Webpack 中找到任何現有的自動化選項)。

當構建器生成的 CJS 輸出避開了 Node.js 中的命名匯出檢測時,可以使用一個 ESM 包裝器來為 ESM 消費者明確地重新匯出已知的命名匯出。

當 CJS 匯出一個物件時(該物件會被別名為 ESM 的 default),你可以在包裝器中將該物件的所有成員的引用儲存到本地,然後重新匯出它們,這樣 ESM 消費者就可以透過名稱訪問所有成員。

import  from '../cjs/index.js';

const { , ,  /* … */ } = ;

export { , ,  /* … */ };

但是,這會破壞即時繫結:對 cjs.a 的重新賦值不會反映在 esmWrapper.a 中。

兩個完整的分發包

塞進一堆東西,然後祈禱一切順利。這可能是 CJS 到 CJS 和 ESM 選項中最常見和最簡單的一種,但你需要為此付出代價。這很少是個好主意。

工作示例cjs-with-dual-distro (double)

{
  "name": "cjs-with-full-dual-distro",
  "exports": {
    ".": {
      "import": "./dist/esm/index.mjs",
      "require": "./dist/cjs/index.js",
      "default": "./dist/cjs/index.js"
    }
  }
}

優點

  • 簡單的構建器配置

缺點

  • 包體積更大(基本上是雙倍)
  • 容易受到雙重包危害的影響

或者,你可以使用 "default""node" 鍵,這不那麼反直覺:Node.js 總是會選擇 "node" 選項(這總是有效的),而非 Node.js 工具在配置為目標非 Node.js 環境時會選擇 "default"這樣可以避免雙重包危害。

{
  "name": "cjs-with-alt-full-dual-distro",
  "exports": {
    ".": {
      "node": "./dist/cjs/index.js",
      "default": "./dist/esm/index.mjs"
    }
  }
}

ESM 原始碼,僅 CJS 分發

我們不再是在堪薩斯了,託託。

配置(有兩種選項)與ESM 原始碼同時分發 CJS 和 ESM幾乎相同,只是排除了 packageJson.exports.import

💡 使用 "type": "module"2 並配合 .cjs 副檔名(用於 CommonJS 檔案)可以獲得最佳效果。有關原因的更多資訊,請參見下面的深入探究注意事項

工作示例esm-with-cjs-distro

ESM 原始碼,同時分發 CJS 和 ESM

當原始碼使用非 JavaScript 語言(例如 TypeScript)編寫時,由於需要使用該語言特定的副檔名(例如 .ts),並且可能沒有 .mjs 的等效副檔名,選項可能會受到限制。

CJS 原始碼,同時分發 CJS 和 ESM類似,你有相同的選項。

僅釋出帶有屬性匯出的 CJS 分發包

製作起來很棘手,需要好的原料。

此選項幾乎與上述CJS 原始碼,分發 CJS 和 ESM 的屬性匯出選項相同。唯一的區別在於 package.json 中:"type": "module"

只有一些構建工具支援生成這種輸出。Rollup 在目標為 commonjs 時可以直接生成相容的輸出。Webpack 從 v5.66.0+ 版本開始,透過新的 commonjs-static 輸出型別也支援此功能(在此之前,沒有 commonjs 選項可以生成相容的輸出)。目前無法使用 esbuild 實現(它會生成一個非靜態的 exports)。

下面的工作示例是在 Webpack 最新版本釋出之前建立的,所以它使用了 Rollup(我稍後會新增一個 Webpack 的選項)。

這些示例假設內部使用的 JavaScript 副檔名為 .jspackage.json 中的 "type" 控制了這些檔案如何被解釋。

"type":"commonjs" + .jscjs
"type":"module" + .jsmjs

如果你的檔案明確*全部*使用 .cjs 和/或 .mjs 副檔名(沒有使用 .js),那麼 "type" 欄位就是多餘的。

工作示例esm-with-cjs-distro

{
  "name": "esm-with-cjs-distribution",
  "type": "module",
  "main": "./dist/index.cjs"
}

💡 使用 "type": "module"2 並配合 .cjs 副檔名(用於 CommonJS 檔案)可以獲得最佳效果。有關原因的更多資訊,請參見下面的深入探究注意事項

釋出一個帶有 ESM 包裝器的 CJS 分發包

這裡面有很多複雜的東西,通常不是最佳選擇。

這與使用 ESM 包裝器的 CJS 原始碼和雙重分發也幾乎相同,但存在一些細微差別,例如 "type": "module" 以及 package.json 中的一些 .cjs 副檔名。

工作示例esm-with-dual-distro (wrapper)

{
  "name": "esm-with-cjs-and-esm-wrapper-distribution",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/esm/wrapper.js",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/cjs/index.cjs"
    }
  }
}

💡 使用 "type": "module"2 並配合 .cjs 副檔名(用於 CommonJS 檔案)可以獲得最佳效果。有關原因的更多資訊,請參見下面的深入探究注意事項

同時釋出完整的 CJS 和 ESM 分發包

塞進一堆東西(外加一個驚喜),然後祈禱一切順利。這可能是從 ESM 到 CJS 和 ESM 選項中最常見和最簡單的一種,但你需要為此付出代價。這很少是個好主意。

在包配置方面,有幾個選項,主要區別在於個人偏好。

將整個包標記為 ESM,並使用 .cjs 副檔名明確將 CJS 匯出標記為 CJS

這個選項對開發/開發者體驗的負擔最小。

這也意味著無論使用什麼構建工具,都必須生成帶有 .cjs 副檔名的分發檔案。這可能需要連結多個構建工具,或者新增一個後續步驟來移動/重新命名檔案以使其具有 .cjs 副檔名(例如 mv ./dist/index.js ./dist/index.cjs)。這可以透過新增一個後續步驟來移動/重新命名這些輸出檔案來解決(例如使用 Rollup一個簡單的 shell 指令碼)。

.cjs 副檔名的支援是在 12.0.0 版本中新增的,使用它可以讓 ESM 正確識別一個檔案為 CommonJS(import { foo } from './foo.cjs' 可以工作)。然而,require() 不會自動解析 .cjs,不像它對 .js 那樣,所以副檔名不能像在 CommonJS 中那樣省略:require('./foo') 會失敗,但 require('./foo.cjs') 可以工作。在包的匯出中使用它沒有缺點:packageJson.exports(以及 packageJson.main)無論如何都需要副檔名,而消費者透過你的 package.json 的 "name" 欄位來引用你的包(所以他們對此一無所知)。

工作示例esm-with-dual-distro

{
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

或者,你可以使用 "default""node" 鍵,這不那麼反直覺:Node.js 總是會選擇 "node" 選項(這總是有效的),而非 Node.js 工具在配置為目標非 Node.js 環境時會選擇 "default"這樣可以避免雙重包危害。

{
  "type": "module",
  "exports": {
    ".": {
      "node": "./dist/index.cjs",
      "default": "./dist/esm/index.js"
    }
  }
}

💡 使用 "type": "module"2 並配合 .cjs 副檔名(用於 CommonJS 檔案)可以獲得最佳效果。有關原因的更多資訊,請參見下面的深入探究注意事項

為所有原始碼檔案使用 .mjs(或等效)副檔名

此配置與CJS 原始碼,同時分發 CJS 和 ESM的配置相同。

非 JavaScript 原始碼:非 JavaScript 語言自身的配置需要識別/指定輸入檔案是 ESM。

12.22.x 之前的 Node.js

🛑 你不應該這樣做:12.x 之前的 Node.js 版本已經停止維護,現在容易受到嚴重的安全漏洞攻擊。

如果你是需要研究 v12.22.x 之前 Node.js 的安全研究人員,歡迎聯絡我們獲取配置幫助。

通用說明

語法檢測並*不能*替代正確的包配置;語法檢測並非萬無一失,並且會帶來顯著的效能開銷

當在 package.json 中使用 "exports" 時,通常最好包含 "./package.json": "./package.json",這樣它就可以被匯入(module.findPackageJSON 不受此限制影響,但 import 可能更方便)。

推薦使用 "exports" 而不是 "main",因為它能阻止外部訪問內部程式碼(這樣你就可以相對確定使用者不會依賴他們不應該依賴的東西)。如果你不需要這個功能,"main" 更簡單,可能對你來說是更好的選擇。

"engines" 欄位為包相容的 Node.js 版本提供了人類友好和機器友好的指示。根據使用的包管理器,當消費者使用不相容的 Node.js 版本時,可能會丟擲異常導致安裝失敗(這對消費者非常有幫助)。包含這個欄位可以為使用舊版本 Node.js 且無法使用該包的消費者省去很多麻煩。

深入探究

特別是在 Node.js 方面,有 4 個問題需要解決。

  • 確定原始碼檔案的格式(作者執行自己的程式碼)

  • 確定分發檔案的格式(程式碼消費者將收到的檔案)

  • 當代碼被 require() 時,公開分發程式碼(消費者期望 CJS)

  • 當代碼被 import 時,公開分發程式碼(消費者可能想要 ESM)

⚠️ 前兩點與後兩點是獨立的。

載入方法並不能決定檔案被解釋的格式

  • package.json 的 exports.require CJSrequire() 不會也不能盲目地將檔案解釋為 CJS;例如,require('foo.json') 會正確地將檔案解釋為 JSON,而不是 CJS。包含 require() 呼叫的模組當然必須是 CJS,但它載入的內容不一定也是 CJS。
  • package.json 的 exports.import ESM。同樣地,import 不會也不能盲目地將檔案解釋為 ESM;import 可以載入 CJS、JSON 和 WASM,以及 ESM。包含 import 語句的模組當然必須是 ESM,但它載入的內容不一定也是 ESM。

因此,當你看到引用或命名為 requireimport 的配置選項時,請抵制住認為它們是用於*確定* CJS 與 ES 模組的衝動。

⚠️ 在包的配置中新增 "exports" 欄位/欄位集,實際上會阻止對包內未在 exports 子路徑中明確列出的任何內容的深度路徑訪問。這意味著這可能是一個破壞性變更。

⚠️ 請仔細考慮是否同時分發 CJS 和 ESM:這會產生雙重包危害的潛在風險(尤其是在配置不當且消費者試圖耍小聰明的情況下)。這可能導致消費專案中出現極其令人困惑的錯誤,特別是當你的包配置不完美時。消費者甚至可能被一個使用你包的“另一種”格式的中間包所矇蔽(例如,消費者使用 ESM 分發版,而消費者使用的其他某個包本身使用了 CJS 分發版)。如果你的包在任何方面是有狀態的,同時消費 CJS 和 ESM 分發版將導致狀態並行(這幾乎肯定不是故意的)。

雙重包危害

當一個應用程式使用一個同時提供 CommonJS 和 ES 模組源的包時,如果包的兩個例項都被載入,就存在某些錯誤的風險。這種可能性源於這樣一個事實:由 const pkgInstance = require('pkg') 建立的 pkgInstance 與由 import pkgInstance from 'pkg'(或像 'pkg/module' 這樣的替代主路徑)建立的 pkgInstance 是不同的。這就是“雙重包危害”,即同一個包的兩個例項可以在同一個執行時環境中被載入。雖然一個應用程式或包不太可能有意直接載入兩個例項,但應用程式載入一個副本,而應用程式的依賴項載入另一個副本的情況是很常見的。這種危害的發生是因為 Node.js 支援 CommonJS 和 ES 模組的混合使用,並且可能導致意想不到和令人困惑的行為。

如果包的主匯出是一個建構函式,那麼對由兩個副本建立的例項進行 instanceof 比較會返回 false;如果匯出是一個物件,那麼新增到一個物件上的屬性(例如 pkgInstance.foo = 3)在另一個物件上是不存在的。這與在純 CommonJS 或純 ES 模組環境中 importrequire 語句的工作方式不同,因此對使用者來說是出乎意料的。這也與使用者在使用像 Babelesm 這樣的工具進行轉譯時所熟悉的行為不同。

我們是如何走到這一步的

CommonJS (CJS) 是在 ECMAScript 模組 (ESM) 出現*很久*之前建立的,當時 JavaScript 還處於發展初期——CJS 和 jQuery 的建立僅相隔 3 年。CJS 並非官方(TC39)標準,僅得到少數平臺的支援(最著名的是 Node.js)。ESM 作為一個標準已經發展了好幾年;它目前得到所有主流平臺(瀏覽器、Deno、Node.js 等)的支援,這意味著它幾乎可以在任何地方執行。隨著 ESM 實際上將取代 CJS(CJS 仍然非常流行和廣泛使用)的趨勢變得明朗,許多人試圖儘早採用,通常是在 ESM 規範的某個特定方面最終確定之前。因此,隨著更好的資訊(通常來自那些先行者的學習/經驗)的出現,這些做法也隨之改變,從最初的最佳猜測演變為與規範保持一致。

另一個複雜因素是打包工具(bundler),它們在歷史上管理了這方面的許多工作。然而,我們以前需要打包工具管理的許多功能現在已經成為原生功能;但打包工具對於某些事情仍然是(而且可能永遠是)必要的。不幸的是,打包工具不再需要提供的功能已經深深地根植於舊版打包工具的實現中,所以它們有時可能會幫倒忙,甚至在某些情況下成為反模式(打包一個庫通常不被打包工具的作者們自己推薦)。這其中的來龍去脈本身就可以寫成一篇文章。

注意事項

package.json 中的 "type" 欄位會改變 .js 副檔名的含義,使其分別表示 commonjs 或 ES module。在雙重/混合包(同時包含 CJS 和 ESM)中,這個欄位的使用非常容易出錯。

{
  "type": "module",
  "main": "./dist/CJS/index.js",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "default": "./dist/cjs/index.js"
    },
    "./package.json": "./package.json"
  }
}

這不起作用,因為 "type": "module" 會導致 packageJson.mainpackageJson.exports["."].requirepackageJson.exports["."].default 被解釋為 ESM(但它們實際上是 CJS)。

排除 "type": "module" 會產生相反的問題。

{
  "main": "./dist/CJS/index.js",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "default": "./dist/cjs/index.js"
    },
    "./package.json": "./package.json"
  }
}

這不起作用,因為 packageJson.exports["."].import 會被解釋為 CJS(但它實際上是 ESM)。

腳註

  1. 在 Node.js v13.0–13.6 中存在一個錯誤,即 packageJson.exports["."] 必須是一個數組,第一個元素是帶有詳細配置選項的物件,第二個元素是“預設”的字串。參見 nodejs/modules#446

  2. package.json 中的 "type" 欄位改變了 .js 副檔名的含義,類似於 HTML script 元素的 type 屬性 2 3 4