目錄
想象一下你正在開發一個 npm 包……
術語
什么是exports領域?
exports好處
保護內部文件
多格式包
將子路徑映射到dist目錄
子路徑導出
單一入口點
多個入口點
公開軟件包文件的子集
有條件出口
設置使用條件
默認條件
句法
針對 Node.js 和瀏覽器
想象一下你正在開發一個 npm 包……
您希望提供多個入口點,但同時限制對內部文件的訪問。您需要同時支持 CJS 和 ESM,包含類型定義,甚至可能還要確保瀏覽器兼容性。您如何管理所有這些需求?
在早期版本的 Node.js 中,包使用main
字段 in?package.json
來定義單個入口點。這種方法雖然簡單,但存在局限性:它只允許一個入口點,并且包中的所有文件都可訪問,無法保護內部文件。隨著生態系統的發展(尤其是 ESM 的興起和對多格式包的需求),這種方法很快就顯得力不從心。
術語
-
ECMAScript 模塊(ESM):JavaScript 使用原生
import
&export
語法的標準化模塊格式。 -
CommonJS (CJS):Node.js 的遺留模塊格式,用于
require()
導入和module.exports
導出。 -
包入口點:訪問包的入口路徑(例如
pkg-a
或pkg-a/file
)。 -
包子路徑:包名稱后面的路徑(例如,
/this/is/subpath.js
在pkg-a/this/is/subpath.js
)。
什么是exports
領域?
該字段在Node.js v12.7.0(2019 年 7 月)中引入,通過兩個核心功能滿足了這些需求:exports package.json
-
子路徑導出:包可以定義多個入口點,只允許公開特定文件,同時阻止對包內部的訪問。
-
條件導出:包可以切換入口點,以針對不同的環境(例如,Node.js 與瀏覽器)和模塊類型(例如,CJS 與 ESM)解析不同的文件。
從那時起,exports
它得到了主要 JavaScript 工具和構建系統的廣泛支持,例如 TypeScript、Deno、Vite、Webpack、esbuild 等。
exports
好處
保護內部文件
以前,用戶可以導入軟件包中的任何文件,甚至是內部文件。這導致軟件包維護者難以更新或重構軟件包,因為他們無法判斷用戶是否依賴這些內部文件。現在exports
,維護者可以明確定義哪些文件可以訪問,從而建立清晰的公共 API,并防止意外導入內部文件。這有助于維護者管理更新,而不會給用戶帶來損壞的風險。
您可以使用子路徑模式 ( ) 使軟件包中的所有文件均可訪問*
。此模式會捕獲子路徑(包括嵌套路徑)中的任何字符串,并將其映射到目標文件路徑。使用此設置,用戶可以通過引用路徑來導入軟件包中的任何文件。
*
? 匹配一切該
*
字符的行為與 glob 語法不同。它會捕獲嵌套路徑,并可能暴露比您預期更多的文件。
{"name": "pkg-a","exports": {"./*": "./*" // 公開所有文件,包括嵌套路徑}
}
盡可能避免暴露所有文件
允許用戶導入任何文件意味著即使對界面進行微小的更改(包括您不希望用戶訪問的文件(例如,捆綁塊))也會成為重大更改,并且需要進行重大的 semver 更新。
import foo from 'pkg-a' // 🚫 已阻止(入口點未定義)
import { name } from 'pkg-a/package.json' // ?
多格式包
如今,軟件包經常面臨支持多種環境的挑戰——Node.js、瀏覽器、ESM、CJS 和 TypeScript 定義。exports
中的字段package.json
允許您為每個環境和模塊格式指定不同的文件。這確保了兼容性,并通過僅包含與每個目標相關的內容來優化導入。
為了讓您的包同時支持 ESM 和 CommonJS 使用者,您可以根據包的導入方式指定需要加載的文件。這樣,Node.js 和 TypeScript 就能在各自的上下文中解析正確的代碼 (?import?
vs?require
) 和合適的類型定義 (?.d.mts
vs?.d.cts
)。
{"name": "pkg-a","exports": {"require": {"types": "./dist/index.d.cts", // Types for require('pkg-a')"default": "./dist/index.cjs" // Code for require('pkg-a')},"import": {"types": "./dist/index.d.mts", // Types for import 'pkg-a'"default": "./dist/index.mjs" // Code for import 'pkg-a'}}
}
將其與子路徑導出相結合,您可以為每個入口點導出不同的類型,同時仍然支持 ESM 和 CommonJS 消費者:
{"name": "pkg-a","exports": {".": {"require": {"types": "./dist/index.d.cts", // Types for require('pkg-a')"default": "./dist/index.cjs" // Code for require('pkg-a')},"import": {"types": "./dist/index.d.mts", // Types for import 'pkg-a'"default": "./dist/index.mjs" // Code for import 'pkg-a'}},"./feature": {"require": {"types": "./dist/feature.d.cts", // Types for require('pkg-a/feature')"default": "./dist/feature.cjs" // Code for require('pkg-a/feature')},"import": {"types": "./dist/feature.d.mts", // Types for import 'pkg-a/feature'"default": "./dist/feature.mjs" // Code for import 'pkg-a/feature'}}}
}
常問問題
-
我是否需要為
require
和提供單獨的類型文件import
?是的。TypeScript 使用文件的擴展名
.d.ts
來推斷其描述的模塊格式。一個.d.cts
文件代表一個 CommonJS.cjs
文件,一個.d.mts
文件代表一個 ESM.mjs
文件。如果將兩個文件放在同一個.d.ts
文件里,TypeScript 會錯誤地解釋模塊格式,并可能導致代碼在運行時失敗。請參閱 Andrew Branch (TypeScript 核心團隊) 在類型錯誤嗎?中解釋這種不匹配→ 🎭 偽裝成 CJS。
-
每個條件塊內的鍵的順序重要嗎?
是的。該
types
字段必須放在前面default
,TypeScript 才能正確識別。如果放在后面,TypeScript 會忽略它。 -
消費者需要哪些 TypeScript 設置?
它們必須在其 中設置?
moduleResolution?
為Node16
、NodeNext
或。這些模式啟用條件導出解析和正確的模塊格式檢測。Bundler,
tsconfig.json
要了解更多信息,請參閱TypeScript 文檔。其中深入介紹了配置exports
TypeScript 的其他方法(例如,跨 TypeScript 版本導出不同類型)。
將子路徑映射到dist
目錄
JavaScript 項目經常將目錄中的代碼編譯src
到 中dist
,從而產生類似 的導入import foo from 'pkg-a/dist/util'
。包作者可能不希望dist
在導入路徑中包含 ,以獲得更簡單的 API,但將文件輸出到包根目錄需要復雜的發布步驟,這可能會污染開發環境。
通過該exports
字段,包子路徑可以直接映射到dist
目錄內部,從而允許消費者使用更清潔的導入,而import foo from 'pkg-a/util'
無需為維護者提供復雜的發布腳本。
該exports
字段的 subpaths 對象允許您將任意子路徑定義為映射到包中文件路徑的鍵。這使您可以使用更簡單、更短的子路徑來公開深度嵌套的路徑。
{"name": "pkg-a","exports": {"./deep-file": "./dist/deep/deep/file.js", // 直接映射到文件"./*": "./dist/*" // 在根級別公開 dist 中的所有內容}
}
import foo from 'pkg-a' // 🚫 已阻止(入口點未定義)
import bar from 'pkg-a/deep-file' // ? - 解析為 dist/deep/deep/file.js
import baz from 'pkg-a/file.js' // ? - 解析為 dist/file.js
子路徑導出
子路徑導出允許您定義包的入口點并將它們映射到包內的文件路徑。
要定義多個入口點,exports
可以將該字段設置為子路徑對象,其中每個鍵都以 開頭.
。.
鍵表示主包入口,子路徑以 開頭./
。鍵可以映射到包內的文件路徑,也可以映射到條件對象(我們稍后會討論)。
單一入口點
該字段最簡單的用法exports
是指向包入口文件的字符串。雖然它與 字段類似main
,但有一個顯著的區別:一旦使用exports
,它就會將您的包黑框起來。這意味著除非明確指定,否則默認情況下任何子路徑(甚至package.json
)都無法訪問。
{"name": "pkg-a","exports": "./index.js" // Package entry point
}
import foo from 'pkg-a' // ? Resolves to pkg-a/index.js
import { name } from 'pkg-a/package.json' // 🚫 Blocked
多個入口點
要定義多個入口點,請設置exports
為一個子路徑對象——該對象的每個鍵都以 開頭.
,值是包內某個文件的相對路徑。如上所述,.
鍵表示主包入口,子路徑以 開頭./
。
{"name": "pkg-a","exports": {".": "./index.js", // Package entry point"./package.json": "./package.json" // Allow importing pkg-a/package.json}
}
import foo from 'pkg-a' // ?
import { name } from 'pkg-a/package.json' // ?
公開軟件包文件的子集
要僅公開特定目錄,請將子路徑模式放置在該子目錄中。此方法允許使用者僅從指定目錄導入文件。此外,您還可以通過將子路徑映射到 來阻止對子路徑的訪問null
。
{"name": "pkg-a","exports": {"./dist/*": "./dist/*", // Only expose the dist directory"./dist/internal/*": null // Blocks access to dist/internal}
}
import foo from 'pkg-a' // 🚫 Blocked (entry point not defined)
import bar from 'pkg-a/dist/file.js' // ?
import baz from 'pkg-a/dist/dir/file.js' // ?
import qux from 'pkg-a/dist/internal/file.js' // 🚫 Blocked
import quux from 'pkg-a/dist/internal/dir/file.js' // 🚫 Blocked
?
有條件出口
條件導出是一個非常強大的功能。它使你的包能夠根據使用者提供的條件動態加載不同的文件。利用此功能,你可以針對各種環境優化你的包。
舉個簡單的例子,假設你希望你的包入口點在兩個不同的文件之間切換。為此,請在你的 字段中設置一個條件導出對象:exports package.json
{"name": "pkg-a","exports": {// Ordered by priority"condition-a": "./file-a.js","condition-b": "./file-b.js"}
}
導入此包時,加載的文件取決于運行時提供的條件:
import foo from 'pkg-a' // ? 根據提供的條件可以是file-a.js或file-b.js
設置使用條件
node
現在你已經為你的包設置了條件和入口點,那么如何在用戶端切換它們呢?這取決于誰在解析導入。
-
如果您使用的是 Node.js,則可以使用標志指定條件。例如,這將加載,因為我們指定了:--conditions, -C
file-a.js?
condition-a
$ node --conditions=condition-a ./load-pkg-a.js
-
如果您使用捆綁器,則可以在配置中傳入條件。例如,使用 Vite 時,您可以傳入條件(下面列出了支持條件的工具的文檔)。resolve.conditions
-
如果沒有提供條件,則將無法解決并引發錯誤,因為沒有
default
定義條件。
vite
?包的情景模式
// package.json"exports": {".": {"custom": "./index.custom.js","import": "./index.mjs","require": "./index.cjs"}}
配置?
// vite.config.tsresolve: {conditions: ['custom'],},
?
?
默認條件
每個運行時/解析器通常設置自己的默認條件(這些不是按順序排列的):
- Node.js:
node
,,,import?
?require
??default
- Vite:
import
,,,,,,或require
???default?
?module
??browser?
production?
development
- esbuild:
import
,,,,,,require?
?default
??browser?
?node
??module
句法
與子路徑對象相反,條件導出對象是exports
字段內的任何對象,其鍵并非全部以 開頭.
。
條件(對象鍵)按優先級排序,并解析為第一個匹配的條目。(這可能感覺不直觀,因為 JavaScript 中的對象在技術上是無序的。)對象也可以嵌套,以指定解析文件所需的條件組合。
條件鍵的順序很重要
由于解析器具有默認條件,并且返回其匹配的第一個條件,因此應始終首先指定您的自定義條件(例如,無法達到
require
、import
、以下的任何內容)。default
"exports": {"import": "./prod.mjs","require": "./prod.cjs",// 這將永遠不會匹配,因為它低于默認條件"this-will-never-match": "./dev.ts"}
}
針對 Node.js 和瀏覽器
該exports
字段可以定義一個適應 Node.js 或瀏覽器環境的入口點。在 Node.js 運行時中,用于解析的默認條件包括node
、default
和導入類型(import
對于 ESM 為 ,require
對于 CJS 為 )。
條件的優先級取決于包的條件導出對象中的關鍵順序。
{"name": "pkg-a","exports": {"node": "./dist/for-node.js", // Resolved by Node.js"default": "./dist/for-browsers.js" // Resolved by other environments}
}
該default
條件適用于非 Node 環境。或者,您可以使用browser?
Vite、Webpack 和 Parcel 等 Web 應用打包工具能夠識別的條件。