這篇記什麼
替一個既有的 Vue 元件庫補了 14 個元件、修了 17 個缺陷之後,整理出來的設計判準。大部分是設計約定,不是可證偽的事實——有 code 或測試佐證的會直接講是哪個元件、哪條測試,其餘是選擇,記下來是為了讓後續元件保持一致。
一、清單基準跟實作範例可以分開
這個庫的 API 對齊 Bootstrap(colors.ts 的色值就是 Bootstrap 5 色票),視覺與實作參考 Flowbite(Tailwind 生態的元件庫)。兩者分工清楚:
- Bootstrap 決定「有哪些元件、prop 怎麼命名」——它是最多人熟悉的一套詞彙
- Flowbite 決定「Tailwind 底下這個元件長什麼樣、class 怎麼組」
但 Bootstrap 的清單有時代落差:它沒有 DatePicker、Avatar、Rating、Sidebar、Stepper、Timeline,而這些現在幾乎是必備。所以清單要兩邊交叉,不能只看一邊。
二、元件不替呼叫端做決定
Table 的排序只 emit("sort", key, order),自己不動 rows。理由是:一旦表格自己排,接了後端排序或分頁的人就會發現畫面跟資料對不上——表格只看得到當前這一頁,它排出來的順序是錯的。
同一個原則的其他應用:
ToastContainer收到關閉時只送出 id,實際移除由持有清單的人做Tabs、Select、Radio都是受控的:目前選什麼由外面決定,元件只負責「送出使用者的意圖」
判準:元件只知道自己看得到的東西。凡是需要更大範圍的資訊才能做對的決定,就不要在元件裡做。
三、能用原生元素就用原生元素
Switch 外觀是滑動開關,骨子裡是 input[type=checkbox] 加 role="switch",視覺上用 sr-only 藏起來。這樣 Tab 聚焦、空白鍵切換、讀螢幕軟體讀成「開/關」全部是瀏覽器給的,不用自己重刻。
Select 用原生 <select>,鍵盤操作、行動裝置的原生選單、輔助軟體支援都免費。
DatePicker 也是同一個判斷:不自己刻日曆面板,用原生的日期輸入框,一個元件涵蓋 date/datetime-local/time/month/week 五種。鍵盤操作、行動裝置的原生選擇器、各地區的日期格式全部是瀏覽器給的。代價是外觀跨瀏覽器不一致、也沒辦法標記特定日期(例如「已額滿」)——需要那些的話才另外做有面板的版本。
反例在同一個 repo 裡:Accordion 的標題列原本是 div + @click,鍵盤 tab 不到、Enter/Space 沒反應;Navbar 的漢堡在 Login.vue 裡是 <Icon @click>,同樣的問題,而且 aria-expanded 沒地方掛。改成 button 之後這些全部不用自己寫。
注意 sr-only 跟 type="hidden" 的差別——後者會讓元素 tab 不到,等於白做。
原生標籤跟 ARIA 的分工(什麼時候原生就夠、什麼時候才真的需要 ARIA),另外整理在 ARIA 和原生語意:什麼時候該用哪個。
四、prop 要「跟著」,不能「抄一份」
這個 repo 裡同一個病灶出現四次,四種不同寫法:
| 寫法 | 元件 | 症狀 |
|---|---|---|
ref(props.validMessage) | Input | 非同步驗證回來的訊息顯示不出來 |
if (newContent) 漏掉空字串 | Editor | 外部清空內容時清不掉 |
const { list } = props.sectionData | Marquee | 換資料畫面不動 |
ref(props.modelValue) 類寫法 | 多處 | 外部更新後不同步 |
判準:任何從 prop 取值的地方,第一選擇是 computed。用 ref 存 prop 的初始值,就是在賭「這個 prop 不會變」——而它遲早會變。
五、鍵盤行為是元件的一部分,不是加分項
Tabs 的方向鍵移動、跳過停用項、走到底繞回、Home/End 跳頭尾,寫了 6 條測試。這些不是額外功能——少了它,這個元件只有滑鼠使用者能用。
同理:Drawer 打開時要把焦點移進面板(否則鍵盤使用者按 Tab 還停在背景頁面)、Tooltip 要綁 focusin(否則只有滑鼠使用者看得到說明)、Toast 的關閉圖示要能用 Enter/Space 按下去。
六、播報要分輕重
Toast 的 danger/warning 用 role="alert" + aria-live="assertive" 立刻打斷,其餘用 status + polite 等使用者手邊的事告一段落。全部都 assertive 的話,讀螢幕的人會被吵到沒辦法做事。
Spinner 的文字就算不顯示也要留在 DOM 裡——只有一個轉圈的圖形,聽的人不知道發生什麼事。
Progress 在進度未知時不報 aria-valuenow——報一個假的數字比不報更糟。
七、邊界值在元件裡夾好
Progress 把 value 夾回 0 到 max 之間:算出 120% 的寬度會把版面撐爆,負數會播報出不存在的進度。Badge 的數字超過上限顯示成「99+」,不然三位數以上會把版面撐開。
判準:呼叫端傳進來的數字,元件要假設它可能超出合理範圍。
八、事件命名要有一套並且只有一套
這個 repo 修之前混了三種風格:update:modelValue(6 個元件)、update(1 個,缺屬性名)、dropdownSelect(1 個,camelCase 加冗餘前綴)。新增的 14 個元件一律用 update:modelValue,因為那是 Vue 的 v-model 慣例,也是既有的多數。
Alert 還有一個更舊的風格:closeEvent: () => {} 用 callback prop 而不是 emit。
後來三個都改了:dropdownSelect → select(事件名不需要重複元件名,Vue 的慣例是 @select)、update → update:modelValue(連同 externalContent prop 改成 modelValue,v-model 才能直接用)、closeEvent callback prop → close emit。
九、破壞性的命名要趁沒有呼叫端時改
上面那三個改動,我一開始判斷「改了會破壞既有呼叫端,屬於要一起排的破壞性變更」而擱置。後來查證發現:六個前端專案沒有任何一個引用這個庫——當時的套件設定甚至讓它根本引用不了(為什麼引用不了、package.json 要回答哪幾個問題,見元件庫能不能被引用,看的是 package.json)。
也就是說,改名的協調成本在那個當下是零。一旦有第一個專案接上去,同樣的改動就要跟對方排時間、對版本、改呼叫端。
判準:元件庫的 API 一致性清理,成本隨著呼叫端數量線性上升。零個呼叫端的時候是最便宜的,而那通常也是最容易被說「之後再說」的時候。
反過來也成立——如果已經有呼叫端,就不該為了「比較一致」去改名,那時候該做的是新舊並存加上遷移期。
待驗 / 未解
- 三層結構(components / groups / layouts)的界線目前是憑感覺分的:
components是基礎元件、groups是組合(Form、Register、Article)、layouts是頁面骨架(Login)。但groups與layouts共 973 行完全沒有測試,這個分層是否站得住還沒驗證。 - 元件該不該內建動畫:目前
Drawer、Toast都沒有進出場動畫,靠呼叫端自己包<Transition>。這是省事還是缺漏,沒有結論。