如何規範地發佈一個現代化的 NPM 包?
大家好,我是三元同學。
今天給大家分享一篇 JS
庫打包的參考指南,如果你也在維護一些 JS
庫,可以參考一下~
本指南旨在提供一些大多數庫都應該遵循的一目瞭然的建議。以及一些額外的信息,用來幫助你瞭解這些建議被提出的原因,或幫助你判斷是否不需要遵循某些建議。這個指南僅適用於 「庫(libraries)」,不適用於應用(app)。
要強調的是,這只是一些**「建議」**,並不是所有庫都必須要遵循的。每個庫都是獨特的,它們可能有充足的理由不採用本文中的任何建議。
最後,這個指南不針對某一個特定的打包工具 —— 已經有許多指南來說明如何在配置特定的打包工具。相反我們聚焦於每個庫和打包工具(或不用打包工具)都適用的事項。
輸出 esm
、cjs
和 umd
格式
esm
是 “EcmaScript module” 的縮寫。
cjs
是 “CommonJS module” 的縮寫。
umd
是 “Universal Module Definition” 的縮寫,它可以在 <script>
標籤中執行、被 CommonJS
模塊加載器加載、被 AMD
模塊加載器加載。
esm
被認爲是 “未來”,但 cjs
仍然在社區和生態系統中佔有重要地位。esm
對打包工具來說更容易正確地進行 treeshaking,因此對於庫來說,擁有這種格式很重要。或許在將來的某一天,你的庫只需要輸出 esm
。
你可能已經注意到,umd
已經與 CommonJS 模塊加載器兼容 —— 所以爲什麼還要同時具備 cjs
和 umd
輸出呢?一個原因是,與 umd
文件相比,CommonJS 文件在對依賴進行條件導入時通常表現更好;例如:
if (process.env.NODE_ENV === "production") {
module.exports = require("my-lib.production.js");
} else {
module.exports = require("my-lib.development.js");
}
上面的例子,當使用 CommonJS 模塊時,只會引入 production
或 development
包中的一個。但是,對於 UMD 模塊,最終可能會將兩個包全部引入。有關更多信息,請參閱此討論。
最後還需要注意是,開發者可能會在其應用中同時使用 cjs
和 esm
,發生雙包危險。dual package hazard 一文介紹了一些緩解該問題的方法,利用 package.json#exports
進行 package exports 也可以幫助防止這種情況的發生。
輸出多文件
通過保留文件結構更好地支持 treeshaking
如果你對你的庫使用了打包工具或編譯器,可以對其進行配置以保留源文件目錄結構。這樣可以更容易地對特定文件進行 side effects 標記,有助於開發者的打包工具進行 threeshaking。
一個例外是,如果你要創建一個不依賴任何打包工具可以直接在瀏覽器中使用的產出(通常是 umd
格式,但也可能是現代的 esm
格式)。在這種情況下,最好讓瀏覽器請求一個大文件,而不是請求多個小文件。此外,你應該進行代碼壓縮併爲其創建 sourcemap。
要不要壓縮代碼
你可以將一些層面的代碼壓縮應用到你的庫中,這取決於你對你的代碼最終通過開發者的打包工具後的大小的追求程度。
例如,大多數編譯器已經配置了刪除空白符等其他簡單的優化,即使是來自 NPM 模塊的代碼(在這裏指的是你的庫)。使用 terser —— 一個流行的 JavaScript 代碼壓縮工具 —— 這類壓縮工具可以將包的最終大小減少 95%。在某些情況下,你可能會對這些優化感到滿意,且不需要你來付出任何努力。
但如果在發佈前對你的庫進行代碼壓縮,這可以得到一些額外的好處,但需要深入瞭解壓縮工具的配置和副作用。壓縮工具通常不會將這類壓縮用於 NPM 模塊,因此,如果你不自己來做的話,你會錯過這些節省。請參閱這個 issue 瞭解更多信息。
最後,如果你正創建一個不依賴任何打包工具可以直接在瀏覽器中使用的產出(通常是 umd
格式,但也可以是現代的 esm
格式)。在這種情況下,你應該對代碼進行壓縮,並創建 sourcemap,並輸出到一個單文件。
創建 sourcemap
對源代碼進行任何形式的編譯,都將導致未來某個異常的位置,無法與源碼對應起來。爲了幫助未來的自己,創建 sourcemap,即使只進行了很少的編譯工作。
創建 TypeScript 類型
隨着使用 TypeScript 的開發者數量不斷增長,將類型內置到你的庫中將有助於改善開發體驗 (DX)。此外,不使用 TypeScript 的開發者在使用支持類型的編輯器(例如 VSCode,它使用類型來支持其 Intellisense 功能)時也會獲得更好的 DX。
但是,創建類型並不意味着你必須使用 TypeScript 來編寫你的庫。
一種選擇是繼續在源代碼中使用 JavaScript,然後通過 JSDoc 註釋來支持類型。然後,你可以將 TypeScript 配置爲僅從你的 JavaScript 源代碼中構建類型文件。
另一種選擇是直接在 index.d.ts
文件中編寫 TypeScript 類型文件。
獲得類型文件後,請確保設置了 package.json#exports
和 package.json#types
字段.
外置框架
不要將 React、Vue 等框架打包在你的庫中
當構建的庫依賴某個框架(例如 React、Vue 等),或是作爲另一個庫的插件,你可能需要將框架配置到 “externals” 中。這可以使你的庫引用這個框架,但不會將其打包到最終的產出中。這會避免產生一些 bug,並減少庫的體積。
你應該還需要將框架添加到庫的 package.json
的 peer dependencies 中,這將幫助開發者發現你依賴於某個框架。
面向現代瀏覽器
使用現代的新特性,如果有需要,讓開發者支持舊的瀏覽器這篇 web.dev 上的文章提供了一個很好的案例,並提供了相關的指導原則:
-
當使用你的庫時,能夠讓開發者去支持老版本的瀏覽器。
-
輸出多個產出來支持不同版本的瀏覽器。
舉個例子,如果你使用 TypeScript,你可以創建兩個版本的包代碼:
-
通過在
tsconfig.json
中設置"target"="esnext"
,生成一個用現代 JavaScript 的esm
版本 -
通過在
tsconfig.json
中設置"target"="es5"
生成一個兼容低版本 JavaScript 的umd
版本
有了這些設置,大多數用戶將獲得現代版本的代碼,但那些使用老的打包工具配置或使用 <script>
加載代碼的用戶,將獲得進行了額外編譯來支持老版本瀏覽器的版本。
必要的編譯
編譯 TypeScript、將 JSX 轉換爲函數調用
如果庫的源碼是需要進行編譯的形式,如 TypeScript、React 或 Vue 組件等,那麼你庫需要輸出的是編譯後的代碼。
例如:
-
你的 TypeScript 代碼應該輸出爲 JavaScript。
-
你的 React 組件,例如
<Example />
,應該在輸出中使用jsx()
或createElement()
來替換 JSX 語法。
進行這樣的編譯時,請確保同時也創建 sourcemap
維護 changelog
記錄更新和變更
只要能讓開發者瞭解到有哪些變更和對他們的影響,至於是通過自動化工具還是通過親自動手的方式來處理,這都無關緊要。理想情況下,庫的每次版本變更都應該在 changelog 中進行相應的更新。
拆分出你的 CSS 文件
讓開發者能夠按需引入 CSS
如果你正在創建一個 CSS 庫(如 Bootstrap、Tailwind 等),最簡單的方式就是提供單一文件,包含庫的所有功能。然而,在這種情況下,你的 CSS 產出最終可能會變得很大,影響開發者網站的性能。爲了避免這種情況,庫通常會提供自定義生成 CSS 產出的功能,讓產出中只包含開發者正在使用的必要 CSS(例如,參考 Bootstrap 和 Tailwind 是怎麼做的)。
如果 CSS 只是你的庫的一部分(例如,具有默認樣式的組件庫),那麼最好將 CSS 按組件分離單獨構建產出,在使用相應的組件時按需導入。這方面的一個例子是 react-component。
配置 package.json
package.json
中有許多重要的配置字段值得討論;我在這裏將着重討論其中最爲重要的一些,這還有很多額外的字段,你同樣可以進行配置。
設置 name
字段
給你的庫取一個名
name
字段將決定你的包在 npm
上的名字,開發者可以通過這個名字去安裝並使用你的庫。
注意,庫的命名是有限制的,如果你的代碼庫屬於某個組織,你還可以創建一個命名空間。更多細節可以參考 name docs on npm。
name
和 version 的組合爲庫每次迭代創建一個唯一標識。
設置 version
字段
通過更改 version 來對你的庫發佈更新
正如 name 部分所說,name
和 version
的組合爲你的庫在 npm 上創建一個唯一標識。當你更新庫中的代碼時,你可以更新 version
字段併發布以允許開發者獲取該新代碼。
推薦使用 semver 版本控制策略,但要注意的是有些庫選擇 calver 或使用他們自己特有的版本控制策略。無論你選擇使用哪種策略,都應該記錄下來,以便開發者瞭解你的庫是如何進行版本控制的。
你還應該在 changelog 中記錄你的更改。
定義你的 exports
exports 爲你的庫定義公共 API
package.json
中的 exports
字段 - 有時被稱爲 “package exports” - 是一個非常有用的補充,儘管它確實引入了一些複雜性。它做的最重要的兩件事是:
-
定義哪些東西可以從你的庫中導入,哪些則不可以,以及可導入的內容的名字。如果沒有在
exports
中被列出,那麼開發者就不可以import
或require
它們。換句話說,exports
的表現像是給你的庫用戶查看的公共 API,幫助定義哪些是外部的哪些是內部的。 -
允許你根據不同的條件(你可以定義)去選擇那個文件是被導入的,例如 “文件是被
import
還是被require
?開發人員需要的是development
版本的庫還是production
版本等等。
關於這部分的內容 NodeJS 團隊和 Webpack 團隊提供了一些很優秀的文檔。在此我列出一個涵蓋大部分常見場景的例子:
{
"exports": {
".": {
"types": "index.d.ts",
"module": "index.js",
"import": "index.js",
"require": "index.cjs",
"default": "index.js"
},
"./package.json": "./package.json"
}
}
讓我們深入瞭解這些字段的含義以及我選擇這個例子的原因:
-
"."
表示你的庫的默認入口 -
解析過程是**「從上往下」**的,並在找到匹配的字段後立即停止;所以入口的順序是非常重要的
-
types
字段應始終放在第一位,幫助 TypeScript 查找類型文件 -
module
是一個 “非官方” 字段,它被 Webpack 和 Rollup 等打包工具所支持。它應該被放在import
和require
之前,並且指向esm
格式的產出 -- 如果你的源代碼是純esm
的,它也可以指向你的源代碼。正如在格式部分中指出的那樣,它旨在幫助打包工具只包含你的庫的一個副本,無論它是通過import
還是require
方式引入的。 -
import
用於當有人通過import
使用你的庫時 -
require
用於當有人通過require
使用你的庫時 -
default
字段用於兜底,在沒有任何條件匹配時使用。雖然目前可能並不會匹配到它,但爲了面對 “未知的未來場景”,使用它是好的
當一個打包工具或者運行時支持 exports
字段的時候,那麼 package.json
中的頂級字段 main、types、module 還有 browser 將被忽略,被 exports
取代。但是,對於尚不支持 exports
字段的工具或運行時來說,設置這些字段仍然很重要。
如果你有一個 "development" 和一個 "production" 的產出(例如,你有一些警告在 development 產出中有但在 production 產出中沒有),那麼你可以通過在 exports
字段中 "development"
和 "production"
來設置它們。注意一些打包工具例如 webpack
和 vite
將會自動識別這些導出條件,而 Rollup 也可以通過配置來識別它們,你需要提醒開發者在他們自己打包工具的配置中去做這些事。
列出要發佈的 files
files
定義你的 NPM 包中要包含哪些文件
files
決定 npm
CLI 在打包庫時哪些文件和目錄包含到最終的 NPM 包中。
例如,如果你將代碼從 TypeScript 編譯爲 JavaScript,你可能就不想在 NPM 包中包含 TypeScript 的源代碼。(相反,你應該包含 sourcemap)。
files
可以接受一個字符串數組(如果需要,這些字符串可以包含類似 glob 的語法),例如:
{
"files": ["dist"]
}
注意,文件數組不接受相對路徑表示;"files": ["./dist"]
將無法正常工作。
驗證你已正確設置 files
的一種好方法是運行 npm publish --dry-run
,它將根據此設置列出將會包含的文件。
爲你的 JS 文件設置默認的模塊 type
type
規定你的 .js
文件使用哪個模塊系統
運行時和打包工具需要一種方法來確定你的 .js
文件採用哪種模塊系統 —— ESM 還是 CommonJS。因爲 CommonJS 首先出現,所以它被打包工具視爲默認的 - 但你可以通過在你的 package.json
中添加 "type"
來控制這種行爲。
你可以選擇 "type":"module"
或 "type":"commonjs"
,也可以不添加該字段(默認爲 CommonJS),但仍強烈建議你進行設置,顯式地聲明你正在使用哪一個。
請注意,你可以通過幾個技巧在項目中混用模塊類型:
-
.mjs
文件總是 ESM 模塊,即使你的package.json
有"type": "commonjs"
(或者沒有type
) -
.cjs
文件總是 CommonJS 模塊,即使你的package.json
有"type": "module"
-
你可以在子目錄下添加其他
package.json
文件;運行時和打包工具將向上遍歷文件目錄,直到尋找到最近的package.json
。這意味着你可以有兩個不同的文件夾,都使用.js
文件,但每個文件夾都有自己的package.json
並設置爲不同的type
以獲得基於 CommonJS 和 ESM 的文件夾。
列出哪些模塊有 sideEffects
設置 sideEffects
來允許 treeshaking
創建一個 “純模塊” 帶來的優點與創建一個純函數十分類似;打包工具能夠對你的庫更好的進行 treeshaking。
通過設置 sideEffects
讓打包工具知道你的模塊是否是 “純” 的。不設置這個字段,打包工具將不得不假設你**「所有」**的模塊都是有副作用。
sideEffects
可以設爲 false
,表示沒有任何模塊具有副作用,也可以設置爲字符串數組來列出哪些文件具有副作用。例如:
{
// 所有模塊都是“純”的
"sideEffects": false
}
或
{
// 除了 "module.js",所有模塊都是“純”的
"sideEffects": ["module.js"]
}
所以,什麼讓一個模塊具有副作用?例如修改一個全局變量,發送 API 請求,或導出 CSS,而且開發人員不需要做任何事情這些動作就會被執行。例如:
// 具有副作用的模塊
export const myVar = "hello";
window.example = "testing";
導入 myVar
時,你的模塊自動設置 window.example
。
例如:
import { myVar } from "library";
console.log(window.example);
// 打印 "testing"
在某些情況下,如 polyfill,這種行爲是有意的。然而,如果我們想讓這個模塊是 “純” 的,我們可以將對 window.example
的賦值移動到一個函數中。例如:
// 一個“純”模塊
export const myVar = "hello";
export function setExample() {
window.example = "testing";
}
現在這是一個 “純” 模塊。注意,從開發者的角度來看會有不同:
import { myVar, setExample } from "library";
console.log(window.example);
// 打印 "undefined"
setExample();
console.log(window.example);
// 打印 "testing"
設置 main
字段
main
定義 CommonJS 入口
main
是一個當打包工具或運行時不支持 package.json#exports
時的兜底方案;如果打包工具或運行時支持 package exports,則不會使用 main
。
main
應該指向一個兼容 CommonJS 格式的產出;它應該與 package exports 中的 require
保持一致。
設置 module
字段
module
定義 ESM 入口
module
是一個當打包工具或運行時不支持 package.json#exports
時的兜底方案;如果打包工具或運行時支持 package exports,則不會使用 module
。
module
應該指向一個兼容 ESM 格式的產出;它應該與 package exports 中的 module
或 import
保持一致。
設置給 CDN 使用的附加字段
支持 CDN,例如 unpkg
和 jsdelivr
爲讓你的庫在 CDN 上 “以默認的方式正常工作”,如 unpkg 和 jsdelivr,你可以設置它們的特定字段指向你的 umd
產出。例如:
{
"unpkg": "./dist/index.umd.js",
"jsdelivr": "./dist/index.umd.js"
}
設置 browser
字段
browser
指向能在瀏覽器中工作的產出
browser
是一個當打包工具或運行時不支持 package.json#exports
時的兜底方案;如果打包工具或運行時支持 package exports, 則不會使用 browser
。
browser
應該指向能在瀏覽器中工作的 esm
產出。但是,只有在爲瀏覽器和服務器(等其他非瀏覽器環境)創建不同的產出時,才需要設置該字段。如果你沒有爲多個環境創建多個產出,或者你的產出是 “純 JavaScript” 或“通用”的,可以在任何 JavaScript 環境中運行,那麼你就不需要設置 browser
字段。
如果你確實需要設置該字段,這裏有一個優秀的指南,介紹了配置它的不同方法。
注意,browser
字段不應該指向 umd
產出,因爲那樣的話,你的庫就不會被打包工具(如 Webpack)進行 treeshaking,這些打包工具會優先考慮這個字段,而不是其他字段,比如 module 和 main。
設置 types
字段
types
定義 TypeScript 類型
types
是一個當打包工具或運行時不支持 package.json#exports
時的兜底方案;如果打包工具或運行時支持 package exports,則不會使用 types
。
types
應該指向你的 TypeScript 入口文件,例如 index.d.ts
;它應該與 package exports 中的 types
字段指向同一個文件。
列出 peerDependencies
如果你依賴別的框架或庫,將它設置爲 peer dependency
你應該外置框架。然而,這樣做後,你的庫只有在開發人員自行安裝你需要的框架後才能工作。設置 peerDependencies
讓他們知道他們需要安裝的框架。- 例如,如果你在創建一個 React 庫:
{
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
你應該以書面形式來體現這些依賴;例如,npm v3-v6
不安裝 peer dependencies,而 npm v7+
將自動安裝 peer dependencies。
說明你的庫使用哪個許可證
保護你自己和其他的貢獻者
開源許可證用於保護貢獻者和用戶。沒有這種保護,企業和有經驗的開發者不會使用該項目。
上述引用自 Choose a License,這也是一篇很好的文章,幫助你來決定哪個許可證適合你的項目。
當你決定了許可證,關於許可證的 npm 文檔中描述了許可證字段的格式。例如:
{
"license": "MIT"
}
除此之外,你可以在項目的根目錄下創建一個 LICENSE.txt
文件,並將許可證的文本複製到這裏。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/5_2zEMLjNhDlZIdcLtORFg