問題

一個 Vue 元件庫有 31 個元件、457 個測試、67% 覆蓋率、31 頁 Storybook 文件。看起來很完整。

但六個前端專案(vue/react/svelte/angular 和兩個 astro)沒有任何一個引用它。查下去發現不是「還沒排時間接」——是當時的設定讓它不可能被引用。

這篇是 設計一個 UI 元件庫 的配套:那篇講元件本身怎麼設計,這篇講元件庫怎麼「交得出去」。

以下的套件 scope 一律寫成 @your-scope,代表「某個自訂的 scope/alias」,不是真實名稱。

五個問題,每一個都足以讓引用失敗

一、沒有 build,main 直接指向原始碼。

main / module / types → ./src/index.ts
scripts → test / type-check / storybook / lint(沒有 build)

消費端拿到的是未編譯的 TypeScript 與 .vue 原始碼。它必須自己有能力編譯 .vue、自己處理 TS——而且必須用相容的建置設定。

二、@your-scope alias 外洩,這是致命的那一個。

80 個檔案的 import 寫成 @your-scope/utils/colors 這種形式。這個 alias 只定義在這個 repo 自己的 vitest.config.ts 和 tsconfig.json 裡。

因為沒有 build 把 alias 解析掉,消費端會拿到一堆它解不開的 @your-scope/...。除非每個使用這個庫的專案都在自己的 vite 設定裡複製一份同名 alias——而那會跟它自己同名的 alias(如果有的話)打架。

這一條單獨就足以讓任何引用失敗。

三、exports 的子路徑指向不存在的檔案。

"./components/*": "./src/components/*/index.vue"

實際檔名是 Button/Button.vue,index.vue 一個都不存在。Node.js 文件明講 exports 子路徑裡的 * 只是字串替換,不會幫你找檔案。想 import Button from "@your-scope/vue-ui-lib/components/Button" 的人會拿到解析失敗,31 個元件全部如此。

另一個容易忽略的副作用:一旦定義了 exports,沒列出的子路徑一律被封裝,引用會拋 ERR_PACKAGE_PATH_NOT_EXPORTED;而且兩個都有時 exports 優先於 main。所以 exports 寫錯不是「多一條沒用的路徑」,是主動把路堵死。

四、vue 放在 dependencies 而不是 peerDependencies。

peerDependencies 的用途就是宣告「我跟哪個宿主函式庫相容」,而不是自己帶一份。放在 dependencies,消費端的 Vue 版本範圍對不上時就會裝到第二份 Vue(未查證:範圍相容時套件管理器通常會去重,所以這是「可能」而不是「必然」)。兩份 Vue 實例在同一個應用裡,provide/inject 會失效、響應式系統各自為政、instanceof 判斷出錯。這類 bug 極難查——症狀是「元件單獨看沒問題,放進專案就怪怪的」。Vite 的 library mode 文件也明講,vue、react 這類依賴要 externalize,不要打進庫裡。

同一個檔案裡 @storybook/vue(Vue 2 專用)也在 dependencies,意思是任何安裝這個庫的專案都會被迫下載一個它用不到的 Vue 2 Storybook。

五、沒有 files,也沒有宣告發布意圖。

沒有 files 欄位,預設等於 ["*"],所有檔案都打包(.gitignore/.npmignore 排除的除外)——包含 tests/、*.stories.ts、.storybook/、設定檔。沒有 private: true(設了 npm 會拒絕發布)也沒有 publishConfig(發布時用的 registry、access 等設定),看不出它是要發到私有 registry、走 workspace,還是根本不打算發布。

這個 scope 也沒有在公開的 npm registry 註冊,只能走私有 registry 或 monorepo workspace。

補救做了什麼(已完成)

以下六項已實作並驗證:

  1. 加 build 步驟(Vite library mode 加 vite-plugin-dts),產出編譯後的 js/d.ts,並讓 build 把 @your-scope alias 解析成相對路徑
  2. main/module/types/exports 全部指向 build 產物,不是 src
  3. exports 的子路徑對齊實際檔名,或替每個元件加一個真的 index.ts
  4. vue 移到 peerDependencies(同時保留在 devDependencies 供自己開發用)
  5. 加 files,只發布 build 產物與型別
  6. 明確宣告發布方式:私有 registry、workspace 協定(workspace:*)、或 git 依賴——三選一,寫進 README

驗收方式是建一個消費端專案 import 這個庫,再 build 一次——842 個模組轉譯通過,產出 1.1MB 的 bundle(修前根本無法引用)。另外 grep 過 dist/index.js 與全部 .d.ts,@your-scope 殘留數為 0;既有 457 個測試全過,type-check 與 lint 也都過。

只驗「產物裡沒有 alias 字串」是不夠的,那只證明字串被換掉了,沒證明整包解得開。

實作過程另外撞到四件事:

  1. docker compose run 每次都是新容器,node_modules 是匿名 volume,上一個容器 pnpm install 裝的東西隨容器一起消失——要重建 image 才有用。
  2. host 專案目錄下的 .pnpm-store 會被掛進容器,跟容器自己的 store 打架,pnpm 會拒絕動作。比照 node_modules 用匿名 volume 隔離掉。
  3. vite-plugin-dts 的 rollupTypes: true(把所有宣告合併成單一檔)會讓 api-extractor 解不開 @vueup/vue-quill 的 QuillOptionsStatic,整個 build 失敗。這個外掛的 README 也提到 rollupTypes 會撞上 api-extractor 的解析限制。改用 beforeWriteFile hook 自己把型別檔裡的 @your-scope 換成相對路徑。
  4. vite 沒讀到新建的 vite.config.ts(載到的是 vitest.config.ts 的內容),build script 先明確帶 --config 擋著。

未查明:第 4 點的根因沒有查出來,目前靠 --config 繞過。

為什麼會走到這一步

這個庫一直是「自己開自己的 Storybook 看」的狀態。在那個用法底下,main 指向 src、alias 只在自家設定裡、沒有 build——全部都不會出問題,因為執行的永遠是自家的 vite。

問題只在「第一次有別人要引用」的那一刻才會全部同時爆出來。 而只要沒人引用,就永遠不會爆。

這跟 寫了但沒接上 是同一個家族:exports 那條路徑、dependencies 裡的錯誤套件,都是宣告了但沒有下游在檢查。差別是這次的規模更大——整個元件庫就是那個沒接線的宣告。

還沒解決的

  • vite 為什麼不讀 vite.config.ts:根因未查明,現在靠 --config 繞過。
  • vite.config.ts 沒有守門:它 import 的 vite-plugin-dts 只裝在容器裡(這個 repo 的規矩是套件一律在容器內安裝),host 跑 pre-commit 會找不到,所以把它排除在 type-check 與 lint 之外。改這個檔案要自己在容器內 build 驗一次。
  • 只有單一入口,沒有 @your-scope/vue-ui-lib/components/Button 這種子路徑;quill 與 swiper 留在 dependencies,只用 Button 也會裝到它們。
  • 該走 npm 私有 registry 還是 monorepo workspace:還沒決定。