iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0

模組四|核心系統改寫(Day 17–21)

這篇的主軸,是被我自己的實測推翻的。

我原本要寫的是「權限從路由級長到按鈕級」:舊系統只能控制「你能不能進這一頁」,新系統才做到「你能進來看,但不能匯出」。

然後我去數了一下,發現完全不是這樣。

舊系統早就有按鈕級權限了。而且它有兩套。

舊系統的按鈕級權限 使用次數 分布檔案
一個自訂指令 101 42
一個掛在全域的判斷函式 557 133
兩套都用的檔案 20

所以真正的問題不是「有沒有」,是「為什麼有兩個」。

兩種寫法長什麼樣

寫法 A:自訂指令

<a-button v-permission:edit-disabled="'/order/export'">匯出</a-button>

冒號後面那個參數決定「沒權限的時候要怎麼表現」:是隱藏、還是變成不可點擊。

寫法 B:全域判斷函式

<a-button v-if="$hasPermission('/order/export')">匯出</a-button>

自己用 v-if 決定顯示邏輯。

兩者的權限碼都是 API 路徑字串,這點是一致的。

關鍵發現:它們共用同一個底層

一開始我以為這是兩套獨立實作,後來讀了原始碼才發現——它們定義在同一個檔案裡,而且呼叫的是同一個判斷函式。

指令的內部長這樣:

// 指令被掛上元素時
const hp = hasPermission(permission)(value)   // ← 同一個函式
if (!hp) { /* 隱藏或禁用 */ }

而那個全域函式就是:

Vue.prototype.$hasPermission = hasPermission(permission)   // ← 同一個函式

所以:這不是兩套邏輯,是同一套邏輯的兩個入口。

判斷結果永遠一致,不會有「指令說可以、函式說不行」的矛盾。從正確性來說,它沒有 bug。

那問題在哪?

「兩個入口」的成本,不在正確性

手上這根接穗要接哪一根?兩根枝條長得不一樣,底下卻是同一個根

問題在於每個要用它的人,都得先做一個不必要的決定。

你要在一個按鈕上加權限判斷。

你打開旁邊的檔案參考,看到用的是指令。
再打開另一個檔案,看到用的是函式。

哪一個才是這個專案的慣例?

沒有人知道。因為兩個都有人用,而且用量都不小。

於是每個人的做法是:照抄離自己最近的那個檔案。

這解釋了為什麼會有 20 個檔案兩套都用,那不是誰的錯,是不同時期複製了不同來源的結果。

我覺得這件事有個更普遍的形式:

當一個團隊裡「同一件事有兩種正確做法」,真正的成本不是選錯,是每個人都要重新選一次。

而且這個成本是複利的:兩套並存越久,兩邊的用量都越大,之後想統一的代價就越高。舊系統的 101 對 557,就是六年複利的結果。

順帶挖到的兩個東西

讀那個檔案的時候,我還發現兩件更值得講的事。

一、有一個環境變數可以把整個權限系統關掉

if (process.env.APP_PERMISSION !== 'on') {
  // 判斷函式永遠回傳 true
  $hasPermission = () => true
  // 指令變成什麼都不做
  directive('permission', { inserted() {} })
}

這顯然是為了本機開發方便,不用真的登入拿權限,畫面上所有按鈕都看得到。

我理解這個需求。但它的意思是:整個系統的按鈕權限,取決於一個環境變數。

如果那個變數在某個環境設錯了,所有按鈕會全部開放,而且畫面上完全看不出異常,因為「按鈕都看得到」正是它該有的樣子。

這種開關的危險之處在於:它失效的方式是「看起來很正常」。

如果要保留這種便利,我認為至少要做兩件事:在非開發環境啟用時要噴出明顯的警告,以及讓建置流程直接擋掉錯誤的組合。「靠設對」是不夠的。

二、權限函式會在執行期被換掉

我踩下去的時候那塊板還沒到位,同一個位置,早一秒就是空的

// 權限資料載入完成的時候,重新綁一次
userEvent.on('permissionLoad', data => {
  permission = data
  $hasPermission = hasPermission(permission)   // ← 整個換掉
})

$hasPermission 這個全域函式,在執行期會被重新賦值。

也就是說:同一個 $hasPermission('/order/export') 呼叫,在權限載入前後行為不同。載入前權限資料是空的,函式直接回傳 false

這在多數情況下沒問題(畫面通常在權限載入後才渲染),但它代表這個函式的行為跟時間有關——而這正是最難除錯的那類問題。

順帶一提,那個 permissionLoad 就是 Day 4 講的「假的 EventBus」。三篇文章講的三個問題,最後都指向同一個檔案。

新系統怎麼收斂

新系統只留一個入口:一個判斷函式,864 次、分布 169 個檔案。

另外兩種替代方案幾乎沒有人用:一個指令用了 1 次、一個包裝元件用了 3 次。這代表收斂是真的成功,不是「訂了規範但大家各寫各的」。

而 864 次這個數字,順便回答了 Day 4 留下的問題。

回到那個我沒把握的取捨

Day 4 我批評舊系統把一堆東西掛在全域,然後坦承新系統還是保留了兩個全域掛載,其中一個就是這個權限判斷函式。

當時我說「這條線是憑感覺畫的」。現在有數字了:169 個檔案。

要求 169 個檔案各自寫一行 import,是另一種折磨,而且它會讓「加權限判斷」這件事多一道摩擦,而摩擦會讓人省略它。權限這種東西,你不會希望它變得比較麻煩。

所以我現在對這個取捨的看法是:它是對的,但理由不是「方便」,是「我們希望這件事沒有阻力」

這跟舊系統掛那 11 個全域的差別在於:

  • 舊的:11 個裡有 8 個沒人用,而且沒有任何文件
  • 新的:只有 2 個、有型別、寫進了專案守則、而且用量證明它們是必需品

但我還是要說:我們沒有加任何機制阻止它長到第三個、第四個。 這條線目前只靠人的自覺守著。

代價

一、收斂到一個入口,代表失去了指令那種「自動處理」的便利。 舊系統的指令可以指定「沒權限時要隱藏還是禁用」,那個行為是寫在指令參數裡的。改成純函式之後,每個地方都要自己寫 v-if:disabled,寫法統一了,但重複的判斷變多了。

二、權限碼用 API 路徑字串,是一個我們繼承下來、沒有改的設計。 它的好處是不用另外維護一套權限碼;壞處是後端改路徑就會弄壞前端權限。我們沒動它,因為它同樣長進了組織流程(跟 Day 17 一樣的理由)。

三、統一是一次性的,維持統一不是。 目前 864 比 1 比 3 很漂亮,但沒有 lint 規則擋著,這跟 Day 15 的命名規範是同一個問題:文件擋不住下一個人。

帶走什麼

  1. 「同一件事有兩種正確做法」,成本不在選錯,在每個人都要重新選一次。 而且這個成本會複利。
  2. 看到兩套並存,先確認它們是不是共用同一個底層。 如果是,那是「介面重複」而非「邏輯重複」,統一的難度低很多,但也因此更沒有人覺得急著處理。
  3. 會「靜靜地全部放行」的開關最危險。 它失效的樣子跟正常的樣子一模一樣。這類機制要能噴警告、要能被建置流程擋住。
  4. 會在執行期被重新賦值的全域函式,行為跟時間有關。 這類東西除錯成本極高,能避則避。
  5. 保留全域掛載的理由應該是「我們希望這件事沒有阻力」,不是「這樣比較方便」。 前者能解釋為什麼權限可以、其他東西不行。

明天 Day 19 講請求層。舊系統把 token、語系、錯誤處理、載入狀態、甚至「要打哪一套後端」全部塞進一個 182 行的檔案、兩個攔截器、16 個 if 分支裡。新系統拆成七個檔案——而我會講為什麼「可以關掉」比「功能完整」更重要。


上一篇
Day 17|重寫完,路由的程式碼從 328 行變成 704 行
下一篇
Day 19|一個 182 行的檔案,決定了全站每一次請求的命運
系列文
一個活了六年的 Vue 2 後台,升級到 Vue 3 的那兩年19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言