把重複貼的 console banner 抽成一個 npm 套件:hey-console 開發全記錄
今天把兩個網站裡複製貼上的 console banner 彩蛋,抽成一個真正發布到 npm 的套件,順手記錄一份能發布、能被安裝的最小可用套件需要準備哪些東西。
前幾天幫這個部落格加了一個小彩蛋:打開瀏覽器 devtools 的 console,會印出一個金色的 ASCII art banner。靈感其實是抄自己的——我朋友的代購網站 COME.ANC13 之前就做過一模一樣的東西,只是印的是她的名字。兩個網站,一模一樣的邏輯,複製貼上了兩份。
今天做了一件事:把這段邏輯抽成一個真正的 npm 套件 hey-console,發布出去,然後把這個部落格自己也改成用 npm install 裝這個套件,而不是留著手刻的那份。這篇記錄的是「一個能真正發布、真正被安裝的套件」最少需要準備什麼——不是理論,是今天實際走過一遍的流程。
先想清楚:套件要做什麼
在寫任何程式碼之前,先把功能定義寫清楚:
- 印一個 ASCII art banner,顏色可以自訂
- 內建幾個「預設值」(preset),用名字就能叫出來,例如
logConsoleBanner("tommy") - 也要能讓不在預設清單裡的人,自己丟一整包設定進來
- 純邏輯不該綁死在 React 上——這段程式碼未來可能被用在任何網站,不是每個網站都是 Next.js
最後一點決定了整個專案的骨架:核心邏輯要是「框架無關」的,React 只是一層薄薄的、可選的外殼。
專案骨架:三個檔案就夠
src/
index.ts # 框架無關的核心邏輯
presets.ts # 內建的 banner 設定,一個名字對一個設定
react.tsx # React 專用的薄封裝,選用
presets.ts 就是一個查表用的物件:
export interface BannerConfig {
art: string;
color?: string;
subColor?: string;
message?: string;
url?: string;
}
export const presets: Record<string, BannerConfig> = {
tommy: {
art: TOMMY_ART,
color: "#FFD700",
subColor: "#888",
message: "Thanks for peeking under the hood",
url: "https://www.huangyanming.com",
},
nana: {
art: NANA_ART,
color: "#FFD700",
subColor: "#888",
message: "Contact us",
url: "https://anc-13.com/contact",
},
};
index.ts 是核心函式,接受「預設名字」或「一整包自訂設定」:
export function logConsoleBanner(
nameOrConfig: string | BannerConfig,
overrides: Partial<BannerConfig> = {},
): void {
const base =
typeof nameOrConfig === "string" ? presets[nameOrConfig] : nameOrConfig;
if (!base) {
console.warn(`[hey-console] Unknown preset "${nameOrConfig}"...`);
return;
}
const config: BannerConfig = { ...base, ...overrides };
console.log(`%c${config.art}`, `color: ${config.color ?? "#FFD700"}; font-weight: bold;`);
const line = [config.message, config.url].filter(Boolean).join(" — ");
if (line) console.log(`%c${line}`, `color: ${config.subColor ?? "#888"};`);
}
react.tsx 只是把它包成一個丟進 useEffect 的元件,方便 Next.js/React 專案直接用:
"use client";
export function ConsoleBanner({ name = "tommy", config, enabled }: ConsoleBannerProps): null {
useEffect(() => {
const shouldRun = enabled ?? process.env.NODE_ENV === "production";
if (!shouldRun) return;
typeof name === "string" ? logConsoleBanner(name, config) : logConsoleBanner(name);
}, []);
return null;
}
三個檔案,邏輯清楚分層:資料(presets)、邏輯(index)、框架整合(react)。這是這個套件唯一稱得上「架構決策」的地方——其他都只是照著慣例填欄位。
package.json:真正燒腦的部分
寫套件的程式碼其實簡單,package.json 裡每個欄位在幹嘛、為什麼要那樣寫,才是真正容易搞混的地方。
{
"name": "hey-console",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./react": {
"types": "./dist/react.d.ts",
"import": "./dist/react.js",
"require": "./dist/react.cjs"
}
},
"files": ["dist"],
"sideEffects": false,
"peerDependencies": {
"react": ">=17"
},
"peerDependenciesMeta": {
"react": { "optional": true }
}
}
一個一個拆:
main/module/types:分別是 CommonJS 世界、ESM 世界、TypeScript 找型別定義時各自認得的入口,三個都留著是為了相容還在用舊工具鏈的專案。exports:現代 Node/打包工具真正看的是這個欄位,main/module只是給舊工具的備援。這裡做了兩件事:同時提供 ESM(import)跟 CJS(require)兩種格式;另外開了一個"./react"子路徑,讓import { ConsoleBanner } from "hey-console/react"這種寫法成立。- 子路徑 export 為什麼要另開:因為不是每個用這個套件的人都在寫 React。如果把 React 的程式碼直接混進主要的
index.ts,框架無關的使用者也會被迫載入 React 相關的東西。拆成獨立的./react入口,沒用到 React 的人完全不會碰到它。 peerDependencies+peerDependenciesMeta.optional:react放在peerDependencies(而不是dependencies)代表「如果你要用,版本要自己對得上,我不幫你鎖版本、不幫你多裝一份」;optional: true則是告訴 npm「沒裝 react 也沒關係,不要因此裝不起來」——畢竟框架無關的使用者根本不需要 react。files: ["dist"]:npm publish預設會把整個資料夾打包出去(扣掉.gitignore/.npmignore排除的),明確寫files白名單,確保只有建置產物會被發布,src/、設定檔這些開發用的東西不會混進使用者的node_modules裡。
建置:一份 tsup 設定
沒有自己手刻 tsconfig + esbuild 設定,直接用 tsup——它是專門給「寫套件」用的建置工具,一份設定就能同時吐出 ESM、CJS、跟型別定義檔:
import { defineConfig } from "tsup";
export default defineConfig({
entry: {
index: "src/index.ts",
react: "src/react.tsx",
},
format: ["esm", "cjs"],
dts: true,
clean: true,
sourcemap: true,
external: ["react"],
});
entry 對應到前面 exports 裡的兩個入口;external: ["react"] 呼應 peerDependencies——不要把 react 打包進產物裡,交給使用者自己的專案提供。跑 npm run build,dist/ 裡就會長出 index.js、index.cjs、index.d.ts、react.js、react.cjs、react.d.ts 這一組檔案。
發布:真的推到 npm 上
npm login # 沒登入過的話先登入
npm run build # 先建置,確保 dist/ 是最新的
npm publish --dry-run # 不會真的發布,只是印出「如果發布,會打包哪些檔案」
npm publish # 真的發布
--dry-run 這一步不要跳過——它讓你在真的發布前,先確認 files 欄位設對了、dist/ 裡沒有多包進不該出貨的東西。npm 的版本號是一次性的:同一個版本號發布過一次,就永遠不能再發布第二次(就算你 unpublish 了也一樣,那個版本號報銷),所以每次發布前都要照語意化版本(semver)把 version 往上加,不能省。
裝回自己的專案,證明流程真的通
套件發布出去不代表工作結束,還要證明「別人真的能裝、真的能用」。我把這個部落格自己手刻的 lib/console-banner.ts 整個刪掉,改成:
npm install hey-console
// app/layout.tsx
import { ConsoleBanner } from "hey-console/react";
// ...
<ConsoleBanner name="tommy" />
跑一次 production build(console.log 這種東西故意設計成只在 NODE_ENV === "production" 才印,開發模式不洗版),打開 devtools 確認 banner 長得跟改版之前一模一樣——這一步很重要,因為它驗證的不是「套件的程式碼對不對」,而是「這個套件從 npm 上真的能被別的專案裝進去、跑起來」,跟自己專案內部 import 自己寫的檔案是完全不同的兩件事。
小結
一個能發布的最小可用套件,真正需要想清楚的不是邏輯本身(邏輯往往是專案裡最簡單的部分),而是這幾件事:
- 核心邏輯要不要跟某個框架綁死?如果不用,就把框架整合拆成獨立、選用的入口
package.json的exports/peerDependencies/files決定了「別人裝了你的套件之後,會拿到什麼、载入什麼」,這些欄位錯了,功能可能還是對的,但使用體驗會出問題(例如硬拉了使用者用不到的依賴)- 發布前用
--dry-run檢查一次打包內容,比事後發現漏東西再補一個版本號划算 - 真的把它裝進另一個專案跑一次,才算完整驗證過