者進(jìn)階指南:從零到一發(fā)布專業(yè)npm包)
1. 從“使用者”到“創(chuàng)造者”為什么前端開發(fā)者需要發(fā)布自己的npm包如果你是一個(gè)前端開發(fā)者你幾乎每天都在和npm打交道。npm install react、npm run dev這些命令熟悉得就像呼吸一樣自然。我們享受著社區(qū)帶來的便利lodash幫我們處理數(shù)據(jù)axios幫我們發(fā)起請求element-plus或antd為我們提供現(xiàn)成的組件。但不知道你有沒有想過這些被我們頻繁使用的工具它們是怎么來的它們是如何從一個(gè)想法變成一行命令就能安裝的“包”的發(fā)布自己的npm包聽起來像是一個(gè)只有庫作者或大廠團(tuán)隊(duì)才會(huì)做的事離普通業(yè)務(wù)開發(fā)者很遠(yuǎn)。但事實(shí)并非如此。我最初接觸發(fā)布npm包是因?yàn)橐粋€(gè)非常具體的業(yè)務(wù)需求我們團(tuán)隊(duì)有多個(gè)項(xiàng)目都需要用到一套相同的、基于公司設(shè)計(jì)規(guī)范的按鈕和表單校驗(yàn)邏輯。最開始我們用的是最笨的方法——復(fù)制粘貼。A項(xiàng)目寫好了復(fù)制到B項(xiàng)目B項(xiàng)目改了點(diǎn)東西又得同步回A項(xiàng)目。沒過多久版本就混亂了修一個(gè)bug得改三四個(gè)地方苦不堪言。這時(shí)發(fā)布一個(gè)私有的npm包就成了最優(yōu)雅的解決方案。我們把公共邏輯抽離出來封裝成一個(gè)獨(dú)立的包。各個(gè)項(xiàng)目通過npm install my-org/ui-utils來引入。任何更新只需要發(fā)布這個(gè)包的新版本然后在各個(gè)項(xiàng)目里執(zhí)行npm update即可。這不僅僅是解決了代碼復(fù)用的問題更重要的是建立了清晰的依賴關(guān)系和版本管理流程。所以發(fā)布npm包的核心價(jià)值遠(yuǎn)不止于“向全世界分享你的代碼”。對于團(tuán)隊(duì)內(nèi)部它是工程化提效和代碼資產(chǎn)沉淀的關(guān)鍵手段。你可以將業(yè)務(wù)中通用的工具函數(shù)、組件、配置如Webpack插件、Babel預(yù)設(shè)、ESLint規(guī)則打包發(fā)布形成團(tuán)隊(duì)的技術(shù)資產(chǎn)。對于個(gè)人開發(fā)者它是一個(gè)絕佳的技術(shù)名片。一個(gè)維護(hù)良好、解決特定問題的npm包能直觀地體現(xiàn)你的工程化思維、代碼設(shè)計(jì)能力和文檔水平其說服力遠(yuǎn)勝于簡歷上蒼白的“精通JavaScript”。從“使用者”轉(zhuǎn)變?yōu)椤皠?chuàng)造者”這個(gè)視角的轉(zhuǎn)換會(huì)讓你對前端生態(tài)的理解更深一層。你會(huì)開始關(guān)注版本號semver背后的約定思考API設(shè)計(jì)如何才更友好理解package.json里每一個(gè)字段的真正含義。這個(gè)過程是前端開發(fā)者能力進(jìn)階中非常扎實(shí)的一步。2. 解剖一個(gè)npm包package.json的每一個(gè)字段都不是擺設(shè)在動(dòng)手創(chuàng)建包之前我們必須徹底理解它的“身份證”和“說明書”——package.json。這個(gè)文件定義了包的一切很多人只是機(jī)械地使用npm init -y生成然后改改name和version就完事了。但要想發(fā)布一個(gè)專業(yè)、易用的包你必須掌控其中幾個(gè)關(guān)鍵字段。2.1 核心標(biāo)識字段name,version,main/module/exportsname(包名)這是你的包在全球npm倉庫中的唯一標(biāo)識。取名有講究唯一性發(fā)布前務(wù)必去 npm官網(wǎng) 搜索一下確保名字沒被占用。作用域包如果你擔(dān)心重名或者想管理組織內(nèi)的包可以使用作用域。格式是scope/package-name例如babel/core、vue/cli。發(fā)布作用域包默認(rèn)是私有的如果需要公開發(fā)布需要在發(fā)布時(shí)加上--access public參數(shù)。命名規(guī)范全部小寫可以使用連字符-不能有空格、下劃線或特殊符號。version(版本號)遵循語義化版本規(guī)范Semantic Versioning, SemVer格式為主版本號.次版本號.修訂號例如1.2.3。MAJOR(主版本)當(dāng)你做了不兼容的 API 變更時(shí)遞增。比如移除了一個(gè)公開的函數(shù)或者改變了函數(shù)的行為導(dǎo)致現(xiàn)有代碼可能出錯(cuò)。MINOR(次版本)當(dāng)你以向后兼容的方式添加了新功能時(shí)遞增。比如增加了一個(gè)新的API但原有的所有API都工作正常。PATCH(修訂號)當(dāng)你做了向后兼容的問題修復(fù)時(shí)遞增。比如修復(fù)了一個(gè)bug沒有新增任何功能也沒有破壞現(xiàn)有功能。 嚴(yán)格遵守這個(gè)約定你的用戶才能放心地使用^允許更新次版本和修訂號或~只允許更新修訂號來定義依賴版本而不用擔(dān)心項(xiàng)目突然崩潰。入口文件字段這決定了當(dāng)用戶import或require你的包時(shí)到底加載哪個(gè)文件。mainCommonJS模塊的入口文件通常是index.js或lib/index.js。這是最傳統(tǒng)、兼容性最廣的字段。moduleES Module模塊的入口文件通常是esm/index.js或src/index.js?,F(xiàn)代打包工具如Webpack、Rollup會(huì)優(yōu)先使用這個(gè)字段以實(shí)現(xiàn)更好的tree-shaking。exports(Node.js 12): 這是一個(gè)更現(xiàn)代、更強(qiáng)大的入口定義方式。它可以替代main并且能定義條件導(dǎo)出和子路徑導(dǎo)出功能更精細(xì)。{ exports: { .: { import: ./dist/index.mjs, // ES Module require: ./dist/index.cjs, // CommonJS default: ./dist/index.cjs }, ./styles.css: ./dist/styles.css // 允許直接導(dǎo)入子路徑 } }對于新包我強(qiáng)烈建議使用exports字段來同時(shí)提供ESM和CJS支持這是目前的最佳實(shí)踐。2.2 依賴管理字段dependencies,peerDependencies,devDependencies這是最容易混淆的地方用錯(cuò)了會(huì)導(dǎo)致包體積臃腫或安裝沖突。dependencies(生產(chǎn)依賴)你的包直接依賴的、在運(yùn)行時(shí)必須的第三方包。當(dāng)用戶安裝你的包時(shí)這些包也會(huì)被自動(dòng)安裝。例如你的工具函數(shù)包依賴了lodash來深拷貝那么lodash就應(yīng)該放在這里。注意這里有一個(gè)常見的坑。如果你的包只是一個(gè)對Vue或React的插件它本身并不直接依賴vue或react而是需要用戶項(xiàng)目提供。那么vue或react就絕對不能放在dependencies里否則你的包會(huì)強(qiáng)制安裝一個(gè)特定版本的Vue/React很可能和用戶項(xiàng)目本身的版本沖突導(dǎo)致重復(fù)打包甚至運(yùn)行錯(cuò)誤。peerDependencies(對等依賴)上面說的場景正是peerDependencies的用武之地。它聲明了你的包需要某個(gè)宿主環(huán)境通常是用戶的項(xiàng)目提供這些依賴但你自己不會(huì)去安裝它。它只是做一個(gè)版本范圍的聲明和提示。{ peerDependencies: { vue: 3.0.0 } }這表示“我的這個(gè)Vue插件需要在一個(gè)安裝了Vue 3.0版本的項(xiàng)目中運(yùn)行。” 當(dāng)用戶安裝你的包時(shí)npm會(huì)給出警告如果用戶項(xiàng)目里沒有安裝或版本不符但不會(huì)自動(dòng)安裝。這避免了重復(fù)安裝和版本沖突是開發(fā)框架插件、組件庫時(shí)的標(biāo)準(zhǔn)做法。devDependencies(開發(fā)依賴)只在開發(fā)你的包時(shí)需要而用戶安裝你的包時(shí)完全不需要的依賴。例如構(gòu)建工具webpack,rollup、編譯器typescript、測試框架jest,mocha、代碼檢查工具eslint,prettier等。這些必須放在這里以減小你發(fā)布的包體積。2.3 元信息與腳本字段description,keywords,scriptsdescriptionkeywords這是你在npm官網(wǎng)搜索時(shí)的“廣告語”。清晰、準(zhǔn)確的描述和關(guān)鍵詞能極大提高你的包被發(fā)現(xiàn)的機(jī)會(huì)。description用一句話說清楚包是干什么的keywords放幾個(gè)相關(guān)的技術(shù)標(biāo)簽比如[“vue”, “plugin”, “utility”]。scripts定義一系列npm腳本是項(xiàng)目自動(dòng)化的核心。除了常見的start、test對于發(fā)包流程我通常會(huì)配置{ scripts: { build: rollup -c, // 構(gòu)建生產(chǎn)代碼 prepublishOnly: npm run build npm test, // 在npm publish前自動(dòng)執(zhí)行構(gòu)建和測試 version: npm run build git add -A dist, // 在npm version命令后自動(dòng)構(gòu)建并提交dist目錄 pub:beta: npm publish --tag beta, // 發(fā)布一個(gè)beta測試版 pub:next: npm publish --tag next // 發(fā)布一個(gè)next版 } }prepublishOnly鉤子非常有用它能確保每次npm publish時(shí)發(fā)布的都是最新構(gòu)建好的、經(jīng)過測試的代碼避免把源碼或者未構(gòu)建的代碼發(fā)上去。3. 從零到一手把手構(gòu)建并發(fā)布一個(gè)工具函數(shù)包理論說得再多不如親手做一遍。讓我們來創(chuàng)建一個(gè)最簡單的工具函數(shù)包my-awesome-utils它提供一個(gè)深拷貝函數(shù)和一個(gè)格式化日期函數(shù)并發(fā)布到npm官方倉庫。3.1 項(xiàng)目初始化與結(jié)構(gòu)搭建首先創(chuàng)建一個(gè)新的目錄并初始化項(xiàng)目mkdir my-awesome-utils cd my-awesome-utils npm init -y這會(huì)生成一個(gè)默認(rèn)的package.json。我們需要根據(jù)上一節(jié)的知識來修改它。規(guī)劃項(xiàng)目目錄結(jié)構(gòu)一個(gè)清晰的結(jié)構(gòu)有利于長期維護(hù)。我推薦如下結(jié)構(gòu)my-awesome-utils/ ├── src/ # 源代碼目錄 │ ├── index.js # 主入口匯集所有模塊 │ ├── deepClone.js │ └── formatDate.js ├── dist/ # 構(gòu)建輸出目錄由構(gòu)建工具生成應(yīng)被.gitignore忽略 ├── tests/ # 測試文件目錄 ├── .gitignore # Git忽略文件 ├── .npmignore # npm發(fā)布忽略文件可選如果沒有則使用.gitignore ├── rollup.config.js # 或 webpack.config.js, 構(gòu)建配置文件 └── package.json配置.gitignore和.npmignore.gitignore忽略node_modules,dist, 日志文件IDE配置文件等。.npmignore這個(gè)文件決定了哪些文件不會(huì)被發(fā)布到npm。如果你沒有這個(gè)文件npm會(huì)默認(rèn)使用.gitignore。但通常我們希望在Git中保留源碼src/和構(gòu)建配置但只發(fā)布構(gòu)建后的dist/目錄給用戶。因此我們需要?jiǎng)?chuàng)建.npmignoresrc/ tests/ rollup.config.js .gitignore .npmignore *.log這樣發(fā)布到npm的包只包含dist目錄、package.json和README.md等必要文件非常干凈。3.2 編寫源碼與選擇構(gòu)建工具在src/deepClone.js中/** * 一個(gè)簡單的深拷貝函數(shù)使用JSON方法適用于不含函數(shù)、Symbol等特殊類型的對象 * param {any} obj - 需要拷貝的對象 * returns {any} 深拷貝后的新對象 */ export function deepClone(obj) { if (obj null || typeof obj ! object) return obj; return JSON.parse(JSON.stringify(obj)); } /** * 一個(gè)更健壯的深拷貝函數(shù)處理循環(huán)引用和更多數(shù)據(jù)類型示例非生產(chǎn)級 * param {any} obj - 需要拷貝的對象 * param {WeakMap} hash - 用于存儲(chǔ)已拷貝對象的WeakMap解決循環(huán)引用 * returns {any} */ export function deepCloneAdvanced(obj, hash new WeakMap()) { if (obj null || typeof obj ! object) return obj; if (hash.has(obj)) return hash.get(obj); // 解決循環(huán)引用 let cloneTarget Array.isArray(obj) ? [] : {}; hash.set(obj, cloneTarget); for (let key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { cloneTarget[key] deepCloneAdvanced(obj[key], hash); } } return cloneTarget; }在src/formatDate.js中/** * 格式化日期時(shí)間 * param {Date|string|number} date - 日期對象、時(shí)間戳或日期字符串 * param {string} format - 格式字符串默認(rèn) YYYY-MM-DD HH:mm:ss * returns {string} 格式化后的日期字符串 */ export function formatDate(date new Date(), format YYYY-MM-DD HH:mm:ss) { const d new Date(date); if (isNaN(d.getTime())) { throw new Error(Invalid date input); } const pad (n) n.toString().padStart(2, 0); const replacements { YYYY: d.getFullYear(), MM: pad(d.getMonth() 1), DD: pad(d.getDate()), HH: pad(d.getHours()), mm: pad(d.getMinutes()), ss: pad(d.getSeconds()), }; return format.replace(/YYYY|MM|DD|HH|mm|ss/g, (match) replacements[match]); }在src/index.js中我們統(tǒng)一導(dǎo)出所有模塊export { deepClone, deepCloneAdvanced } from ./deepClone.js; export { formatDate } from ./formatDate.js;現(xiàn)在我們需要一個(gè)構(gòu)建工具將src下的ES Module代碼打包成同時(shí)支持ESM和CommonJS的格式并輸出到dist目錄。這里我選擇Rollup因?yàn)樗渲煤唵螌SM支持好打包出的代碼更干凈。安裝Rollup及相關(guān)插件npm install rollup rollup/plugin-node-resolve rollup/plugin-commonjs rollup/plugin-terser --save-devrollup/plugin-node-resolve: 讓Rollup能夠解析node_modules中的第三方模塊。rollup-plugin-terser: 用于代碼壓縮。創(chuàng)建rollup.config.jsimport resolve from rollup/plugin-node-resolve; import commonjs from rollup/plugin-commonjs; import terser from rollup/plugin-terser; import pkg from ./package.json assert { type: json }; // 注意Node.js版本低版本可能需要require export default { input: src/index.js, // 入口文件 output: [ { file: pkg.main, // 對應(yīng) package.json 中的 main 字段 format: cjs, // CommonJS 格式 sourcemap: true, }, { file: pkg.module, // 對應(yīng) package.json 中的 module 字段 format: esm, // ES Module 格式 sourcemap: true, }, ], plugins: [ resolve(), // 解析 node_modules 中的模塊 commonjs(), // 將 CommonJS 模塊轉(zhuǎn)換為 ES6 terser(), // 壓縮代碼 ], };然后更新package.json指定入口文件和構(gòu)建腳本{ name: my-awesome-utils, version: 1.0.0, description: A collection of awesome utility functions for JavaScript., main: dist/index.cjs.js, module: dist/index.esm.js, exports: { .: { import: ./dist/index.esm.js, require: ./dist/index.cjs.js, default: ./dist/index.cjs.js } }, scripts: { build: rollup -c, prepublishOnly: npm run build }, files: [ dist ], keywords: [utils, deepClone, formatDate, javascript], author: Your Name, license: MIT, devDependencies: { // ... 上面安裝的 rollup 插件 } }注意這里新增了files: [dist]字段這是一個(gè)白名單明確告訴npm只發(fā)布dist目錄下的文件這是比.npmignore更推薦的做法。運(yùn)行npm run build你會(huì)在dist目錄下看到生成的兩個(gè)文件index.cjs.js(CommonJS) 和index.esm.js(ES Module)。3.3 測試、登錄與發(fā)布在發(fā)布前寫點(diǎn)簡單的測試是必要的。我們可以使用Node.js自帶的assert模塊在tests/目錄下寫個(gè)簡單的測試文件并在package.json中添加test: node tests/index.js。更正式的項(xiàng)目會(huì)用Jest或Mocha。接下來是發(fā)布流程注冊npm賬號如果你還沒有去 npm官網(wǎng) 注冊一個(gè)。本地登錄在終端執(zhí)行npm login。你會(huì)被要求輸入用戶名、密碼和郵箱。登錄成功后憑證會(huì)保存在本地。踩坑提示如果你之前配置過淘寶鏡像npm config set registry https://registry.npmmirror.com/發(fā)布前必須切回官方源否則會(huì)發(fā)布到淘寶鏡像這是不對的。執(zhí)行npm config set registry https://registry.npmjs.org/切換回來。發(fā)布完成后可以再切回去。執(zhí)行發(fā)布在項(xiàng)目根目錄執(zhí)行npm publish。如果是第一次發(fā)布作用域包如yourname/package需要加上--access public參數(shù)npm publish --access public。發(fā)布成功如果一切順利終端會(huì)顯示包名和版本。稍等片刻你就可以在npm官網(wǎng)搜索到你的包了版本更新當(dāng)你修復(fù)了bug或增加了新功能需要發(fā)布新版本。不要手動(dòng)修改package.json里的版本號。使用npm命令npm version patch修復(fù)bug、npm version minor新增功能、npm version major不兼容更新。這個(gè)命令會(huì)自動(dòng)修改package.json的版本號并創(chuàng)建一個(gè)git tag。然后再次執(zhí)行npm publish即可。4. 進(jìn)階實(shí)踐與避坑指南讓你的包更專業(yè)、更易用發(fā)布一個(gè)能用的包只是第一步。要讓你的包在社區(qū)中脫穎而出或者能在團(tuán)隊(duì)內(nèi)部穩(wěn)定運(yùn)行還需要注意很多細(xì)節(jié)。4.1 類型支持擁抱TypeScript在今天的前端生態(tài)中TypeScript幾乎成了標(biāo)配。為你的JavaScript包提供類型聲明.d.ts文件能極大提升開發(fā)體驗(yàn)。有兩種主要方式使用JSDoc注釋在.js文件中使用詳細(xì)的JSDoc注釋然后通過TypeScript的allowJs和declaration配置讓TS編譯器自動(dòng)生成.d.ts文件。這種方式對純JS項(xiàng)目友好。直接使用TypeScript開發(fā)這是更徹底的方式。用.ts編寫源碼通過tsc編譯生成JS文件和對應(yīng)的.d.ts聲明文件。你需要配置tsconfig.json并將package.json中的types字段指向生成的聲明文件入口如types: dist/index.d.ts。即使你不打算用TS重寫也強(qiáng)烈建議為你的公共API添加JSDoc注釋這本身就是一種良好的文檔。4.2 質(zhì)量保障單元測試與持續(xù)集成一個(gè)沒有測試的包就像沒有質(zhì)檢的產(chǎn)品。為你的核心功能編寫單元測試。使用Jest、Mocha等框架。在package.json中配置好test腳本。更進(jìn)一步配置持續(xù)集成CI比如GitHub Actions。每次代碼推送到倉庫或發(fā)起Pull Request時(shí)自動(dòng)運(yùn)行測試和構(gòu)建確保主分支的代碼始終是健康的。一個(gè)常見的.github/workflows/test.yml配置可以包括安裝依賴、運(yùn)行l(wèi)int、運(yùn)行測試、構(gòu)建檢查等步驟。4.3 文檔與示例降低使用門檻再好的包如果別人看不懂怎么用也是白搭。README.md是你的門面必須包含清晰的標(biāo)題和簡介一句話說清楚干嘛的。安裝說明npm install your-package。快速開始一個(gè)最簡單的、能立刻跑起來的代碼示例。詳細(xì)API文檔每個(gè)函數(shù)/組件的參數(shù)、返回值、示例。常見問題FAQ。貢獻(xiàn)指南如何參與開發(fā)。許可證通常是MIT。如果可能提供一個(gè)在線的示例工程比如通過CodeSandbox或StackBlitz鏈接讓用戶能零成本體驗(yàn)這是最好的推廣。4.4 發(fā)布流程與版本管理策略使用npm version如前所述永遠(yuǎn)使用npm version命令來更新版本號它比手動(dòng)修改更規(guī)范且會(huì)自動(dòng)創(chuàng)建git tag。使用dist-tag管理測試版正式版本默認(rèn)使用latest標(biāo)簽。在發(fā)布新特性供內(nèi)部或早期用戶測試時(shí)可以使用npm publish --tag beta發(fā)布一個(gè)beta版。用戶可以通過npm install your-packagebeta來安裝。等測試穩(wěn)定后再用npm dist-tag add your-package1.1.0-beta.1 latest將其標(biāo)記為正式版。prepublishOnly鉤子再次強(qiáng)調(diào)一定要用這個(gè)鉤子來確保發(fā)布的是構(gòu)建后的代碼。我見過不止一個(gè)開發(fā)者忘了npm run build就直接publish把一堆源碼和配置文件發(fā)上去了。關(guān)于.npmignore和files字段優(yōu)先使用files字段白名單它比.npmignore黑名單更明確、更安全能防止不小心把無關(guān)文件如.env密鑰文件發(fā)布出去。4.5 私有包與鏡像源管理對于公司內(nèi)部項(xiàng)目你可能需要發(fā)布私有包。npm官方提供付費(fèi)的私有倉庫。也可以搭建免費(fèi)的私有倉庫方案如Verdaccio。它是一個(gè)輕量級的私有npm代理注冊表可以在內(nèi)網(wǎng)搭建團(tuán)隊(duì)成員可以像使用官方源一樣發(fā)布和安裝私有包。鏡像源沖突是最大的坑。很多開發(fā)者因?yàn)榫W(wǎng)絡(luò)問題配置了淘寶鏡像npmmirror.com但在發(fā)布時(shí)忘記切回官方源npmjs.org導(dǎo)致發(fā)布失敗或發(fā)布到了錯(cuò)誤的地方。一個(gè)建議是使用nrmnpm registry manager這樣的工具來快速切換源npm install -g nrm nrm ls # 列出所有源 nrm use npm # 使用官方源 nrm use taobao # 使用淘寶源或者在發(fā)布腳本中顯式指定注冊表npm publish --registryhttps://registry.npmjs.org/。發(fā)布自己的npm包從一個(gè)想法到一行npm install命令這個(gè)過程打通了前端開發(fā)中模塊化、工程化、協(xié)作和分享的完整鏈條。它不只是一個(gè)技術(shù)操作更是一種思維方式的轉(zhuǎn)變——從消費(fèi)代碼到生產(chǎn)代碼從項(xiàng)目思維到產(chǎn)品思維。當(dāng)你精心設(shè)計(jì)的API被他人引用當(dāng)你修復(fù)的bug幫助到社區(qū)里的陌生人這種成就感是單純完成業(yè)務(wù)需求難以比擬的。現(xiàn)在就從封裝你項(xiàng)目里的第一個(gè)工具函數(shù)開始吧。