大家好,我是若川。持續組織了6個月源碼共讀活動,感興趣的可以點此加我微信 ruochuan02?參與,每周大家一起學習200行左右的源碼,共同進步。同時極力推薦訂閱我寫的《學習源碼整體架構系列》?包含20余篇源碼文章。歷史面試系列
在Verdaccio搭建npm私有服務器中,我們介紹了如何搭建一個Npm私有服務器;服務器搭建完成后,我們本文來學習一下如何上傳我們自己的npm包。
前端模塊化作為前端必備的一個技能,已經在前端開發中不可或缺;而模塊化帶來項目的規模不斷變大,項目的依賴越來越多;隨著項目的增多,如果每個模塊都通過手動拷貝的方式無異于飲鴆止渴,我們可以把功能相似的模塊或組件抽取到一個npm包中;然后上傳到私有npm服務器,不斷迭代npm包來更新管理所有項目的依賴。
npm包的基本了解
首先我們來了解一下實現一個npm包需要包含哪些內容。
打包
通常,我們把打包好的一些模塊文件放在一個目錄下,便于統一進行加載;是的,npm包也是需要進行打包的,雖然也能直接寫npm包模塊的代碼(并不推薦),但我們經常會在項目中用到typescript、babel、eslint、代碼壓縮等等功能,因此我們也需要對npm包進行打包后再進行發布。
在深入對比Webpack、Parcel、Rollup打包工具中,我們總結了,rollup相比于webpack更適合打包一些第三方的類庫,因此本文主要通過rollup來進行打包。
npm域級包
隨著npm包越來越多,而且包名也只能是唯一的,如果一個名字被別人占了,那你就不能再使用這個名字;假設我想要開發一個utils包,但是張三已經發布了一個utils包,那我的包名就不能叫utils了;此時我們可以加一些連接符或者其他的字符進行區分,但是這樣就會讓包名不具備可讀性。
在npm的包管理系統中,有一種scoped packages
機制,用于將一些npm包以@scope/package
的命名形式集中在一個命名空間下面,實現域級的包管理。
域級包不僅不用擔心會和別人的包名重復,同時也能對功能類似的包進行統一的劃分和管理;比如我們用vue腳手架搭建的項目,里面就有@vue/cli-plugin-babel
、@vue/cli-plugin-eslint
等等域級包。
我們在初始化項目時可以使用命令行來添加scope:
npm?init?--scope=username
相同域級范圍內的包會被安裝在相同的文件路徑下,比如node_modules/@username/
,可以包含任意數量的作用域包;安裝域級包也需要指明其作用域范圍:
npm?install?@username/package
在代碼中引入時同樣也需要作用域范圍:
require("@username/package")
加載規則
在npm包中的package.json
文件,我們經常會看到main
、jsnext:main
、module
、browser
等字段,那么這些字段都代表了什么意思呢?其實這跟npm包的工作環境
有關系,我們知道,npm包分為以下幾種類型的包:
只能在瀏覽器端使用的
只能在服務器端使用的
瀏覽器/服務器端都可使用
假如我們現在開發一個npm包,既要支持瀏覽器端,也要支持服務器端(比如axios、lodash等),需要在不同的環境下加載npm包的不同入口文件,只通過一個字段已經不能滿足需求。
首先我們來看下main
字段,它是nodejs默認文件入口, 支持最廣泛,主要使用在引用某個依賴包的時候需要此屬性的支持;如果不使用main
字段的話,我們可能需要這樣來引用依賴:
import('some-module/dist/bundle.js')
所以它的作用是來告訴打包工具,npm包的入口文件是哪個,打包時讓打包工具引入哪個文件;這里的文件一般是commonjs(cjs)模塊化的。
有一些打包工具,例如webpack或rollup,本身就能直接處理import導入的esm模塊,那么我們可以將模塊文件打包成esm模塊,然后指定module
字段;由包的使用者來決定如何引用。
jsnext:main
和module
字段的意義是一樣的,都可以指定esm模塊的文件;但是jsnext:main是社區約定的字段,并非官方,而module則是官方約定字段,因此我們經常將兩個字段同時使用。
在Webpack配置全解析中我們介紹到,mainFields
就是webpack用來解析模塊的,默認會按照順序解析browser、module、main字段。
有時候我們還想要寫一個同時能夠跑在瀏覽器端和服務器端的npm包(比如axios),但是兩者在運行環境上還是有著細微的區別,比如瀏覽器請求數據用的是XMLHttpRequest,而服務器端則是http或者https;那么我們要怎樣來區分不同的環境呢?
除了我們可以在代碼中對環境參數進行判斷(比如判斷XMLHttpRequest是否為undefined),也可以使用browser
字段,在瀏覽器環境來替換main字段。browser的用法有以下兩種,如果browser為單個的字符串,則替換main成為瀏覽器環境的入口文件,一般是umd模塊的:
{"browser":?"./dist/bundle.umd.js"
}
browser還可以是一個對象,來聲明要替換或者忽略的文件;這種形式比較適合替換部分文件,不需要創建新的入口。key是要替換的module或者文件名,右側是替換的新的文件,比如在axios的packages.json中就用到了這種替換:
{"browser":?{"./lib/adapters/http.js":?"./lib/adapters/xhr.js"}
}
打包工具在打包到瀏覽器環境時,會將引入來自./lib/adapters/http.js
的文件內容替換成./lib/adapters/xhr.js
的內容。
在有一些包中我們還會看到types
字段,指向types/index.d.ts
文件,這個字段是用來包含了這個npm包的變量和函數的類型信息;比如我們在使用lodash-es
包的時候,有一些函數的名稱想不起來了,只記得大概的名字;比如輸入fi就能自動在編譯器中聯想出fill或者findIndex等函數名稱,這就為包的使用者提供了極大的便利,不需要去查看包的內容就能了解其導出的參數名稱,為用戶提供了更加好的IDE支持。
發布哪些文件
在npm包中,我們可以選擇哪些文件發布到服務器中,比如只發布壓縮后的代碼,而過濾源代碼;我們可以通過配置文件來進行指定,可以分為以下幾種情況:
存在
.npmignore
文件,以.npmignore
文件為準,在文件中的內容都會被忽略,不會上傳;即使有.gitignore
文件,也不會生效。不存在
.npmignore
文件,以.gitignore
文件為準,一般是無關內容,例如.vscode等環境配置相關的。不存在
.npmignore
也不存在.gitignore
,所有文件都會上傳。package.json
中存在files字段,可以理解為files為白名單。
ignore相當于黑名單,files字段就是白名單,那么當兩者內容沖突時,以誰為準呢?答案是files
為準,它的優先級最高。
我們可以通過npm pack
命令進行本地模擬打包測試,在項目根目錄下就會生成一個tgz的壓縮包,這就是將要上傳的文件內容。
項目依賴
在package.json文件中,所有的依賴包都會在dependencies和devDependencies字段中進行配置管理:
dependencies:表示生產環境下的依賴管理,--save 簡寫 -S;
devDependencies:表示開發環境下的依賴管理,--save-dev 簡寫 -D;
dependencies
字段指定了項目上線后運行所依賴的模塊,可以理解為我們的項目在生產環境運行中要用到的東西;比如vue、jquery、axios等,項目上線后還是要繼續使用的依賴。
devDependencies
字段指定了項目開發所需要的模塊,開發環境會用到的東西;比如webpack、eslint等等,我們打包的時候會用到,但是項目上線運行時就不需要了,所以放到devDependencies中去就好了。
除了dependencies和devDependencies字段,我們在一些npm包中還會看到peerDependencies
字段,沒有寫過npm插件的童鞋可能會對這個字段比較陌生,它和上面兩個依賴有什么區別呢?
假設我們的項目MyProject,有一個依賴PackageA,它的package.json中又指定了對PackageB的依賴,因此我們的項目結構是這樣的:
MyProject
|-?node_modules|-?PackageA|-?node_modules|-?PackageB
那么我們在MyProject中是可以直接引用PackageA的依賴的,但如果我們想直接使用PackageB,那對不起,是不行的;即使PackageB已經被安裝了,但是node只會在MyProject/node_modules
目錄下查找PackageB。
為了解決這樣問題,peerDependencies
字段就被引入了,通俗的解釋就是:如果你安裝了我,你最好也安裝以下依賴。比如上面如果我們在PackageA的package.json中加入下面代碼:
{"peerDependencies":?{"PackageB":?"1.0.0"}
}
這樣如果你安裝了PackageA,那會自動安裝PackageB,會形成如下的目錄結構:
MyProject
|-?node_modules|-?PackageA|-?PackageB
我們在MyProject項目中就能愉快的使用PackageA和PackageB兩個依賴了。
比如,我們熟悉的element-plus組件庫,它本身不可能單獨運行,必須依賴于vue3環境才能運行;因此在它的package.json中我們看到它對宿主環境的要求:
{"peerDependencies":?{"vue":?"^3.2.0"},
}
這樣我們看到它在組件中引入的vue的依賴,其實都是宿主環境提供的vue3依賴:
import?{?ref,?watch,?nextTick?}?from?'vue'
許可證
license
字段使我們可以定義適用于package.json
所描述代碼的許可證。同樣,在將項目發布到npm注冊時,這非常重要,因為許可證可能會限制某些開發人員或組織對軟件的使用。擁有清晰的許可證有助于明確定義該軟件可以使用的術語。
借用知乎上Max Law的一張圖來解釋所有的許可證:

版本號
npm包的版本號也是有規范要求的,通用的就是遵循semver語義化版本規范,版本格式為:major.minor.patch,每個字母代表的含義如下:
主版本號(major):當你做了不兼容的API修改
次版本號(minor):當你做了向下兼容的功能性新增
修訂號(patch):當你做了向下兼容的問題修正
先行版本號是加到修訂號的后面,作為版本號的延伸;當要發行大版本或核心功能時,但不能保證這個版本完全正常,就要先發一個先行版本。
先行版本號的格式是在修訂版本號后面加上一個連接號(-),再加上一連串以點(.)分割的標識符,標識符可以由英文、數字和連接號([0-9A-Za-z-])組成。例如:
1.0.0-alpha
1.0.0-alpha.1
1.0.0-0.3.7
常見的先行版本號有:
alpha:不穩定版本,一般而言,該版本的Bug較多,需要繼續修改,是測試版本
beta:基本穩定,相對于Alpha版已經有了很大的進步,消除了嚴重錯誤
rc:和正式版基本相同,基本上不存在導致錯誤的Bug
release:最終版本

每個npm包的版本號都是唯一的,我們每次更新npm包后,都是需要更新版本號,否則會報錯提醒:

?當主版本號升級后,次版本號和修訂號需要重置為0,次版本號進行升級后,修訂版本需要重置為0。
?
但是如果每次都要手動來更新版本號,那可就太麻煩了;那么是否有命令行能來自動更新版本號呢?由于版本號的確定依賴于內容決定的主觀性的動作,因此不能完全做到全自動化更新,誰知道你是改了大版本還是小版本,因此只能通過命令行實現半自動操作;命令的取值和語義化的版本是對應的,會在相應的版本上加1:

在package.json的一些依賴的版本號中,我們還會看到^
、~
或者>=
這樣的標識符,或者不帶標識符的,這都代表什么意思呢?
沒有任何符號:完全百分百匹配,必須使用當前版本號
對比符號類的:>(大于) ?>=(大于等于) <(小于) <=(小于等于)
波浪符號
~
:固定主版本號和次版本號,修訂號可以隨意更改,例如~2.0.0
,可以使用 2.0.0、2.0.2 、2.0.9 的版本。插入符號
^
:固定主版本號,次版本號和修訂號可以隨意更改,例如^2.0.0
,可以使用 2.0.1、2.2.2 、2.9.9 的版本。任意版本*:對版本沒有限制,一般不用
或符號:||可以用來設置多個版本號限制規則,例如 >= 3.0.0 || <= 1.0.0
npm包開發
通過上面對package.json
的介紹,相信各位小伙伴已經對npm包有了一定的了解,現在我們就進入代碼實操階段,開發并上傳一個npm包。
工具類包
相信不少童鞋在業務開發時都會遇到重復的功能,或者開發相同的工具函數,每次遇到時都要去其他項目中拷貝代碼;如果一個項目的代碼邏輯有優化的地方,需要同步到其他項目,則需要再次挨個項目的拷貝代碼,這樣不僅費時費力,而且還重復造輪子。
我們可以整合各個項目的需求,開發一個適合自己項目的工具類的npm包,包的結構如下:
hello-npm
|--?lib/(存放打包后的文件)
|--?src/(源碼)
|--?package.json
|--?rollup.config.base.js(rollup基礎配置)
|--?rollup.config.dev.js(rollup開發配置)
|--?rollup.config.js(rollup正式配置)
|--?README.md
|--?tsconfig.json
首先看下package.json
的配置,rollup根據開發環境區分不同的配置:
{"name":?"hello-npm","version":?"1.0.0","description":?"我是npm包的描述","main":?"lib/bundle.cjs.js","jsnext:main":?"lib/bundle.esm.js","module":?"lib/bundle.esm.js","browser":?"lib/bundle.browser.js","types":?"types/index.d.ts","author":?"","scripts":?{"dev":?"npx?rollup?-wc?rollup.config.dev.js","build":?"npx?rollup?-c?rollup.config.js?&&?npm?run?build:types","build:types":?"npx?tsc",},"license":?"ISC"
}
然后配置rollup的base config
文件:
import?typescript?from?"@rollup/plugin-typescript";
import?pkg?from?"./package.json";
import?json?from?"rollup-plugin-json";
import?resolve?from?"rollup-plugin-node-resolve";
import?commonjs?from?"@rollup/plugin-commonjs";
import?eslint?from?"@rollup/plugin-eslint";
import?{?babel?}?from?'@rollup/plugin-babel'
const?formatName?=?"hello";
export?default?{input:?"./src/index.ts",output:?[{file:?pkg.main,format:?"cjs",},{file:?pkg.module,format:?"esm",},{file:?pkg.browser,format:?"umd",name:?formatName,},],plugins:?[json(),commonjs({include:?/node_modules/,}),resolve({preferBuiltins:?true,jsnext:?true,main:?true,brower:?true,}),typescript(),eslint(),babel({?exclude:?"node_modules/**"?}),],
};
這里我們將打包成commonjs、esm和umd三種模塊規范的包,然后是生產環境的配置,加入terser和filesize分別進行壓縮和查看打包大小:
import?{?terser?}?from?"rollup-plugin-terser";
import?filesize?from?"rollup-plugin-filesize";import?baseConfig?from?"./rollup.config.base";export?default?{...baseConfig,plugins:?[...baseConfig.plugins,?terser(),?filesize()],
};
然后是開發環境的配置:
import?baseConfig?from?"./rollup.config.base";
import?serve?from?"rollup-plugin-serve";
import?livereload?from?"rollup-plugin-livereload";export?default?{...baseConfig,plugins:?[...baseConfig.plugins,serve({contentBase:?"",port:?8020,}),livereload("src"),],
};
環境配置好后,我們就可以打包了
#?測試環境
npm?run?dev
#?生產環境
npm?run?build
全局包
還有一類npm包比較特殊,是通過npm i -g [pkg]
進行全局安裝的,比如常用的vue create
、static-server
、pm2
等命令,都是通過全局命令安裝的;那么全局npm包如何開發呢?
我們來實現一個全局命令的計算器功能,新建一個項目然后運行:
cd?my-calc
npm?init?-y
在package.json中添加bin
屬性,它是一個對象,鍵名是告訴node在全局定義一個全局的命令,值則是執行命令的腳本文件路徑,可以同時定義多個命令,這里我們定義一個calc命令
:
{"name":?"my-calc","version":?"1.0.0","description":?"","main":?"index.js","scripts":?{"test":?"echo?\"Error:?no?test?specified\"?&&?exit?1"},"bin":?{"calc":?"./src/calc.js",},"license":?"ISC",
}
命令定義好了,我們來實現calc.js中的內容:
#!/usr/bin/env?nodeif?(process.argv.length?<=?2)?{console.log("請輸入運算的數字");return;
}let?total?=?process.argv.slice(2).map((el)?=>?{let?parseEl?=?parseFloat(el);return?!isNaN(parseEl)???parseEl?:?0;}).reduce((total,?num)?=>?{total?+=?num;return?total;},?0);console.log(`運算結果:${total}`);
需要注意的是,文件頭部的#!/usr/bin/env node
是必須的,告訴node這是一個可執行的js文件,如果不寫會報錯;然后通過process.argv.slice(2)
來獲取執行命令的參數,前兩個參數分別是node的運行路徑和可執行腳本的運行路徑,第三個參數開始才是命令行的參數,因此我們在命令行運行來看結果:
calc?1?2?3?-4
如果我們的腳本比較復雜,想調試一下腳本,那么每次都需要發布到npm服務器,然后全局安裝后才能測試,這樣比較費時費力,那么有沒有什么方法能夠直接運行腳本呢?這里就要用到npm link命令
,它的作用是將調試的npm模塊鏈接到對應的運行項目中去,我們也可以通過這個命令把模塊鏈接到全局。
在我們的項目中運行命令:
npm?link
可以看到全局npm目錄下新增了calc文件,calc命令就指向了本地項目下的calc.js文件,然后我們就可以盡情的運行調試;調試完成后,我們又不需要將命令指向本地項目了,這個時候就需要下面的命令進行解綁操作
:
npm?unlink
解綁后npm會把全局的calc文件刪除,這時候我們就可以去發布npm包然后進行真正的全局安裝了。
vue組件庫
在Vue項目中,我們在很多項目中也會用到公共組件,可以將這些組件提取到組件庫,我們可以仿照element-ui來實現一個我們自己的ui組件庫;首先來構建我們的項目目錄:
|-?lib
|-?src|-?MyButton|-?index.js|-?index.vue|-?index.scss|-?MyInput|-?index.js|-?index.vue|-?index.scss|-?main.js
|-?rollup.config.js
我們構建MyButton和MyInput兩個組件,vue文件和scss不再贅述,我們來看下導出組件的index.js:
import?MyButton?from?"./index.vue";MyButton.install?=?function?(Vue)?{Vue.component(MyButton.name,?MyButton);
};
export?default?MyButton;
組件導出后在main.js
中統一組件注冊:
import?MyButton?from?"./MyButton/index.js";
import?MyInput?from?"./MyInput/index";
import?{?version?}?from?"../package.json";import?"./MyButton/index.scss";
import?"./MyInput/index.scss";const?components?=?[MyButton,?MyInput];const?install?=?function?(Vue)?{components.forEach((component)?=>?{Vue.component(component.name,?component);});
};
if?(typeof?window?!==?"undefined"?&&?window.Vue)?{install(window.Vue);
}
export?{?MyButton,?MyInput,?install?};
export?default?{?version,?install?};
然后配置rollup.config.js:
import?resolve?from?"rollup-plugin-node-resolve";
import?vue?from?"rollup-plugin-vue";
import?babel?from?"@rollup/plugin-babel";
import?commonjs?from?"@rollup/plugin-commonjs";
import?scss?from?"rollup-plugin-scss";
import?json?from?"@rollup/plugin-json";const?formatName?=?"MyUI";
const?config?=?{input:?"./src/main.js",output:?[{file:?"./lib/bundle.cjs.js",format:?"cjs",name:?formatName,exports:?"auto",},{file:?"./lib/bundle.js",format:?"iife",name:?formatName,exports:?"auto",},],plugins:?[json(),resolve(),vue({css:?true,compileTemplate:?true,}),babel({exclude:?"**/node_modules/**",}),commonjs(),scss(),],
};
export?default?config;
這里我們打包出commonjs和iife兩個模塊規范,一個可以配合打包工具使用,另一個可以直接在瀏覽器中script引入。我們通過rollup-plugin-vue
插件來解析vue文件,需要注意的是5.x版本解析vue2,最新的6.x版本解析vue3,默認安裝6.x版本;如果我們使用的是vue2,則需要切換老版本的插件,還需要安裝以下vue的編譯器:
npm?install?--save-dev?vue-template-compiler
打包成功后我們就能看到lib
目錄下的文件了,我們就能像element-ui一樣,愉快的使用自己的ui組件了,在項目中引入我們的UI:
/*?全局引入?main.js?*/
import?MyUI?from?"my-ui";
//?引入樣式
import?"my-ui/lib/bundle.cjs.css";Vue.use(MyUI);/*?在組件中按需引入?*/
import?{?MyButton?}?from?"my-ui";
export?default?{components:?{MyButton}
}
如果想要在本地進行調試,也可以使用link
命令創建鏈接,首先在my-ui目錄下運行npm link
將組件掛載到全局,然后在vue項目中運行下面命令來引入全局的my-ui:
npm?link?my-ui
我們會看到下面的輸出表示vue項目中my-ui模塊已經鏈接到my-ui項目了:
D:\project\vue-demo\node_modules\my-ui?
->?
C:\Users\XXXX\AppData\Roaming\npm\node_modules\my-ui
->?
D:\project\my-ui
npm包發布
我們的npm包完成后就可以準備發布了,首先我們需要準備一個賬號,可以使用--registry
來指定npm服務器,或者直接使用nrm來管理:
npm?adduser
npm?adduser?--registry=http://example.com
然后進行登錄,輸入你注冊的賬號密碼郵箱:
npm?login
還可以用下面命令退出當前賬號
npm?logout
如果不知道當前登錄的賬號可以用who命令查看身份:
npm?who?am?i
登錄成功就可以將我們的包推送到服務器上去了,執行下面命令,會看到一堆的npm notice:
npm?publish
如果某版本的包有問題,我們還可以將其撤回
npm?unpublish?[pkg]@[version]
·················?若川簡介?·················
你好,我是若川,畢業于江西高校。現在是一名前端開發“工程師”。寫有《學習源碼整體架構系列》20余篇,在知乎、掘金收獲超百萬閱讀。
從2014年起,每年都會寫一篇年度總結,已經寫了7篇,點擊查看年度總結。
同時,最近組織了源碼共讀活動,幫助3000+前端人學會看源碼。公眾號愿景:幫助5年內前端人走向前列。
識別上方二維碼加我微信、拉你進源碼共讀群
今日話題
略。分享、收藏、點贊、在看我的文章就是對我最大的支持~