從零開始發佈自己的 NPM 包

在 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文件,我們經常會看到mainjsnext:mainmodulebrowser等字段,那麼這些字段都代表了什麼意思呢?其實這跟 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:mainmodule字段的意義是一樣的,都可以指定 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 包中,我們可以選擇哪些文件發佈到服務器中,比如只發布壓縮後的代碼,而過濾源代碼;我們可以通過配置文件來進行指定,可以分爲以下幾種情況:

ignore 相當於黑名單,files 字段就是白名單,那麼當兩者內容衝突時,以誰爲準呢?答案是files爲準,它的優先級最高。

我們可以通過npm pack命令進行本地模擬打包測試,在項目根目錄下就會生成一個 tgz 的壓縮包,這就是將要上傳的文件內容。

項目依賴

在 package.json 文件中,所有的依賴包都會在 dependencies 和 devDependencies 字段中進行配置管理:

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,每個字母代表的含義如下:

  1. 主版本號 (major):當你做了不兼容的 API 修改

  2. 次版本號 (minor):當你做了向下兼容的功能性新增

  3. 修訂號 (patch):當你做了向下兼容的問題修正

先行版本號是加到修訂號的後面,作爲版本號的延伸;當要發行大版本或核心功能時,但不能保證這個版本完全正常,就要先發一個先行版本。

先行版本號的格式是在修訂版本號後面加上一個連接號(-),再加上一連串以點(.)分割的標識符,標識符可以由英文、數字和連接號([0-9A-Za-z-])組成。例如:

1.0.0-alpha
1.0.0-alpha.1
1.0.0-0.3.7

常見的先行版本號有:

  1. alpha:不穩定版本,一般而言,該版本的 Bug 較多,需要繼續修改,是測試版本

  2. beta:基本穩定,相對於 Alpha 版已經有了很大的進步,消除了嚴重錯誤

  3. rc:和正式版基本相同,基本上不存在導致錯誤的 Bug

  4. release:最終版本

版本號

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

版本號報錯

當主版本號升級後,次版本號和修訂號需要重置爲 0,次版本號進行升級後,修訂版本需要重置爲 0。

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

命令行更新版本號

在 package.json 的一些依賴的版本號中,我們還會看到^~或者>=這樣的標識符,或者不帶標識符的,這都代表什麼意思呢?

  1. 沒有任何符號:完全百分百匹配,必須使用當前版本號

  2. 對比符號類的:>(大於)  >=(大於等於) <(小於) <=(小於等於)

  3. 波浪符號~:固定主版本號和次版本號,修訂號可以隨意更改,例如~2.0.0,可以使用 2.0.0、2.0.2 、2.0.9 的版本。

  4. 插入符號^:固定主版本號,次版本號和修訂號可以隨意更改,例如^2.0.0,可以使用 2.0.1、2.2.2 、2.9.9 的版本。

  5. 任意版本 *:對版本沒有限制,一般不用

  6. 或符號:|| 可以用來設置多個版本號限制規則,例如 >= 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 createstatic-serverpm2等命令,都是通過全局命令安裝的;那麼全局 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 node

if (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]

聊了這麼多 NPM 包的知識你學會如何發佈一個 NPM 包了嗎?關注我,帶你瞭解更多好玩的前端內容。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/nvamNOdRqFBn6hl94nJHSg