問題
一個 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。
補救做了什麼(已完成)
以下六項已實作並驗證:
- 加 build 步驟(Vite library mode 加
vite-plugin-dts),產出編譯後的 js/d.ts,並讓 build 把@your-scopealias 解析成相對路徑 main/module/types/exports全部指向 build 產物,不是srcexports的子路徑對齊實際檔名,或替每個元件加一個真的index.tsvue移到peerDependencies(同時保留在devDependencies供自己開發用)- 加
files,只發布 build 產物與型別 - 明確宣告發布方式:私有 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 字串」是不夠的,那只證明字串被換掉了,沒證明整包解得開。
實作過程另外撞到四件事:
docker compose run每次都是新容器,node_modules是匿名 volume,上一個容器pnpm install裝的東西隨容器一起消失——要重建 image 才有用。- host 專案目錄下的
.pnpm-store會被掛進容器,跟容器自己的 store 打架,pnpm 會拒絕動作。比照node_modules用匿名 volume 隔離掉。 vite-plugin-dts的rollupTypes: true(把所有宣告合併成單一檔)會讓 api-extractor 解不開@vueup/vue-quill的QuillOptionsStatic,整個 build 失敗。這個外掛的 README 也提到rollupTypes會撞上 api-extractor 的解析限制。改用beforeWriteFilehook 自己把型別檔裡的@your-scope換成相對路徑。- 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:還沒決定。