跳至主要内容

[Guide] 發佈 npm 套件 - 從手動到自動(0):專案與套件建置篇

keywords: deploy, publish, release, CLI, npm

Imgur

對於 JavaScript 的開發者來說要發布 JavaScript 套件到 npm 上面,只要對於 npm 的專案架構本身已經有基本的理解,並不是太困難的事。在這一系列的文章中,會說明如何從專案開發到發佈 npm 套件的這個流程,從手動透過 npm 的指令發佈到自動化持續整合(Continuous Integration, CI)的過程做一個整理。

這裡,就讓我們先從手動開始。在這篇文章中會談到:

  • 建立隸屬於某 scope 底下的 npm 套件
  • 撰寫一個範例套件
  • 透過 rollup 打包範例套件
  • 透過 Jest 測試範例套件

這篇文章主要是建立一個範例的 npm 套件,以供後續說明發佈使用,並且說明套件中通常會包含打包和測試的流程,若想要直接看最後套件完整的程式碼,可以看此 Github 上的 Commit

⚠️ 撰寫文章時使用的 npm 版本為 6.13.7。

註冊 npm 帳號

首先要請你到 npm 的官網上註冊自己的帳號。

建立 npm (scoped) 專案

一般的情況下,會習慣直接使用 npm init 這樣的指令來啟動 npm 的專案,但如果你是要發佈到 npm 上的話,就需要留意專案的名稱在 npm 上是不能夠重複的,為了避免專案重複的問題,在 npm v2 以後開始支援 scopes 的用法,也就是讓專案在名稱上就可以看出是隸屬於某個使用者或某個組織底下,特色是專案名稱會用 @ 開頭,如此避免專案名稱經常重複的問題。

舉例來說,JavaScrip 中經常用來支援語法相容性的 Babel 工具,現在就會隸屬於 @babel 下:

Imgur

在建立專案時,你也可以考慮是否要讓它隸屬於個人或組織底下(scope)。在這裡因為只是示範用的專案,因此我就把這個專案直接隸屬於個人帳號底下,如此避免和他人的名稱相碰。

要建立 scope 的專案,只需在該專案資料夾中,初始化專案時加上 --scope=@<username> 的語法。例如:

$ mkdir function-benchmarker     # 建立一個名為 function-benchmarker 的專案資料夾
$ cd function-benchmarker # 進入該資料夾中
$ npm init --scope=@pjchender # 建立隸屬於個人帳號底下的 npm 套件

執行後 npm cli 會問你一些專案設定的問題,這裡我們先直接使用預設值就好,所以就一路 enter 到底即可。如果想要直接全部採用預設值,不要一一按下 enter 的話,可以直接在 npm init 的最後加上 -y 即可,像是這樣:

$ npm init --scope=@pjchender -y

如此就初始化好一個 npm 的專案:

Imgur

⚠️ 注意:scoped 的套件預設會是私人的(private),後續會再說明如何透過 npm 指令將其設為公開的套件。

撰寫套件

這裡主要是用來示範套件的發佈,就來寫一個簡單可以用來檢測函式效能方法。在根目錄新增一個 src 目錄,並在裡面新增一支 index.js

// ./src/index.js
const benchmarker = (testFunction, times = 1000000) => {
if (typeof testFunction !== 'function') {
throw new Error('Did not provide a valid function for test.');
}

const startTime = new Date().getTime();

let i = 0;
while (i < times) {
i++;
testFunction();
}

const endTime = new Date().getTime();

return endTime - startTime;
};

// 匯出函式
module.exports = benchmarker;

這裡的 benchmarker 方法,主要是把傳入的函式(testFunction)預設執行一百萬次(times)後,最後回傳所花的時間(ms)。

這裡因為是要開放給其他開發者可以使用的套件,所以最後會把 benchmarker 這個方法透過 CommonJS 的語法匯出(module.exports)。

打包套件(可略過)

💡 提醒:多數套件都會經過打包的動作,以縮小檔案體積、增加語法相容性等等,但如何打包並不是這篇文章的重點,因此你只需要知道有打包這個動作,並且打包後的檔案會被放到 dist 資料夾中

npm 套件再發布前通常會經過「打包(bundle)」的動作,除了縮小檔案體積外,也可以讓套件同時支援在 CommonJS 或 ESModules 下使用,或者透過 Babel 等工具讓套件支援較舊的 JS 語法。打包的工具有許多,像是最常聽到的 webpackrollupjsParcel、或過去常使用的 Gulp 等等,你可以選擇自己習慣的。

打包套件的過程不是這系列文章的重點,所以不會詳細說明設定檔中各欄位的意思,你只需要知道打包後會把原本放在 src 資料夾中原始碼,進行打包的動作後放到 dist 資料夾中。

這裡我們選擇使用 rollupjs 進行打包,首先先安裝 rollup 和相關 plugin 到專案中:

$ npm install rollup rollup-plugin-terser -D

接著在專案根目錄新增一支名為 rollup.config.js 的設定檔:

// rollup.config.js
import { terser } from 'rollup-plugin-terser';

export default [
{
input: 'src/index.js',
output: {
file: 'dist/index.min.js',
format: 'cjs',
sourcemap: true,
},
plugins: [terser()],
},
];

這個設定檔簡單來說,就是把 src/index.js 的檔案打包後匯出到 dist/index.min.js,而 terser 這個外掛是用來壓縮(minify)打包後的檔案。

回到 package.json 中:

  • main 欄位改成 dist/index.js ,這是他人在使用我們套件時的載入點
  • scripts 欄位中加入指令來執行 rollup
// package.json
{
"main": "dist/index.min.js",
"scripts": {
"dev": "rollup -c -w",
"build": "rm -rf dist/ && rollup -c",
"test": "echo \"Error: no test specified\" && exit 1"
}
}

現在當我們執行 npm run build 時,dist 資料夾中就會產生打包並壓縮好的套件:

Imgur

測試撰寫的套件(可略過)

💡 提醒:多數套件都會經過測試(test)的動作,以確認每次程式碼修改後並不會破壞原有的功能,但如何測試並不是這篇文章的重點,因此你只需要知道有測試這個動作,測試完成後可以得到測試覆蓋率

以肉眼檢查程式

在還沒有安裝其他專門用來執行套件的工具前,我們先來手動執行看看套件運作是否正常。首先在 src 資料夾中建立一支名為 index.test.js 的檔案,這裡我們就寫一個名為 jsonStringify 的函式,看看當這個函式跑一百萬次需要多少時間:

// ./src/index.test.js
// 匯入剛剛寫的套件
const benchmarker = require('./index');

// 定義一個想要檢驗花費時間的函式
function jsonStringify() {
JSON.stringify({
foo: 'bar',
});
}

// 執行 benchMarker 這個方法,並把想檢驗的函式放進去
const costTime = benchmarker(jsonStringify);
console.log('costTime', costTime);

撰寫好後,可以透過 node 指令執行該程式,看看是否能正常運作:

Imgur

從上圖中可以看到,執行 jsonStringify 一百萬次會需要耗費 264ms 的時間。

使用 Jest 讓程式檢查程式

上面這裡是用「肉眼」來進行測試,但實際上的測試很少會用「肉眼」,而是會用程式來檢查程式,這裡我們使用 Jest 進行比較正式測試。

首先安裝 Jest:

$ npm install jest -D

預設情況下,__tests__ 資料夾內的 .js, .jsx, .ts, .tsx.test.spec 結尾的檔案,例如,因此這裡的 index.test.js 也會被 Jest 解析到。

修改一下 index.test.js 的內容,變成不用使用肉眼檢查,而是讓程式碼自己檢查執行結果:

// ./src/index.test.js
const benchmarker = require('./index');

describe('test function-benchmarker', () => {
// 檢驗函式執行了 100 萬次(預設),而且回傳的 costTime 是數值格式
it('run default times (1000000)', () => {
const mockFn = jest.fn();
const costTime = benchmarker(mockFn);

expect(mockFn).toHaveBeenCalledTimes(1000000);
expect(costTime).toEqual(expect.any(Number));
});

// 檢驗函式執行了 1000 次,而且回傳的 costTime 是數值格式
it('run with 1000 times', () => {
const mockFn = jest.fn();
const costTime = benchmarker(mockFn, 1000); // 設定執行的次數

expect(mockFn).toHaveBeenCalledTimes(1000);
expect(costTime).toEqual(expect.any(Number)); // 回傳的 costTime 會是數字
});

// 檢驗當參數不是函數時會拋出錯誤
it('throw error when not provide a function', () => {
const mockFn = 'THIS_IS_NOT_A_FUNCTION';
expect(() => benchmarker(mockFn)).toThrow();
});
});

接著一樣修改 package.json 中的 scripts 欄位,方便我們執行 Jest 進行測試:

// package.json
{
"scripts": {
"dev": "rollup -c -w",
"build": "rm -rf dist/ && rollup -c",
"test": "jest"
}
}

執行 npm test,就可以看到測試的結果,若想要看到測試的覆蓋率,則可以輸入 npm test -- --coverage

Imgur

可以看到所有測試的項目都有通過,而且覆蓋率達 100%。

發佈上 Github

到這裡就把專案大致建置起來了,透過 git 把它推上 github,後續的幾篇都會根據這個專案架構進行調整,從手動到自動。

範例程式碼

本篇完整的範例程式碼放到 Github 可供參考:

參考