模組二|選型、語言遷移與地基(Day 6–11)
這個專案是一個活了六年的 Vue 2 後台,我花了兩年把它升到 Vue 3。升級的時候,有三件事是被迫一起換的:資料變動的偵測機制、狀態管理套件、還有一個在新版被直接移除的東西。
它們表面上互不相干。但我把三件事都搬完之後才發現,它們在解決同一個問題。這個結論我留到最後講。
this.$set先看一個數字。我在舊專案數了這個:
grep -rho 'Vue\.set\|this\.\$set' src --include='*.vue' --include='*.js' | wc -l
# → 232
232 次。 如果你沒寫過 Vue 2,可能不知道 this.$set 是什麼、為什麼需要它。
Vue 2 偵測資料變化的機制,是在你把資料交給它的那一刻,把物件上「當時已經存在」的每一個屬性都改造一遍(技術上叫 Object.defineProperty)。
改造過的屬性,被讀取或修改時 Vue 都會知道。
問題來了:那之後才新增的屬性呢?
Vue 沒有改造過它,所以完全偵測不到。你明明改了資料,畫面卻不動。
用一個場景理解:
你入住飯店,櫃台幫你登記了房間裡現有的每一件家具:一張床、一張桌子、兩張椅子。之後任何一件家具移動,櫃台都會知道。
然後你自己從外面搬了一張新椅子進房間。
櫃台不知道這張椅子存在。 你把它搬到哪、有沒有壞掉,系統上完全沒有紀錄,因為它不在入住時的清單裡。
所以飯店給了你一支電話:你自己搬進來的東西,要打去櫃台登記。
那支電話就是 this.$set。
舊專案裡典型的用法長這樣:
this.$set(group, 'disabled', groupDisabled)
this.$set(group, 'checked', groupChecked)
this.$set(group, 'indeterminate', groupIndeterminate)
翻譯:後端回傳的資料裡本來沒有 disabled 這些欄位,是前端算完之後才加上去的。而因為是後來加的,就必須用 $set 通知 Vue,否則畫面不會更新。
232 次,代表這件事在這個專案裡每天都在發生。
Vue 3 換了一套機制(Proxy),概念上像是:
飯店不再登記家具清單了,改成在房間門口裝了感應器。任何東西進出,系統自動知道,不管它是入住時就有的,還是你後來搬進來的。
所以那 232 次呼叫,在 Vue 3 全部不需要了。搬過去的時候直接刪掉,這是整個遷移過程中少數「純粹變輕鬆」的部分。
代價是另一組陷阱。Vue 3 提供兩種建立響應式資料的方式,而它們的行為不一樣,這是新手最容易踩的地方:
const state = reactive({ count: 0 })
const { count } = state // ← 這裡就斷了
count++ // 畫面不會更新
為什麼?用另一個場景:
reactive像一個裝了水的水壺,Vue 盯著這個水壺。解構(
const { count } = state)等於你從水壺倒了一杯水出來。杯子裡的水是真的,但它已經跟水壺沒有關係了。你把杯子的水喝掉,水壺不會知道。
我們團隊後來的做法是訂一條規則:
對外暴露的東西一律用
ref,reactive只用在明確不會被整包替換、也不會被解構的地方。
這條規則不是最優雅的(ref 在程式碼裡要寫 .value,在模板裡又不用,這個不一致本身也很煩),但它很好記,而且不容易出錯。
在團隊裡,「好記」常常比「最優雅」重要。 一條大家記得住的次佳規則,勝過一條沒人記得的最佳規則。
換狀態管理的時候,我發現了一個很有趣的東西。
舊專案的狀態管理有 12 個模組。我把它們按行數排出來:
| 模組 | 行數 |
|---|---|
| 訂單 | 707 |
| 參數設定 | 51 |
| 帳號 | 20 |
| 內容管理 | 19 |
| 管理者 | 15 |
| 首頁 | 11 |
| 結算 | 2 |
| 對帳 | 2 |
| 商品類型 | 2 |
| 監控 | 2 |
| 稽核 | 2 |
| 報表 | 2 |
最後六個是什麼?我打開來看,每一個都長這樣:
const settlement = {}
export default settlement
兩行。一個空物件。 沒有 state、沒有 mutations、沒有 actions,什麼都沒有。
但它們全部都被正式註冊進狀態管理了:
import settlement from './settlement'
import reconcile from './reconcile'
// ...
const modules = {
settlement,
reconcile,
// ...
}

我的推測是:當初建立專案結構時,有人決定「每個業務模組都要有一個對應的 store」,於是照著頁面目錄開了 12 個檔案。
其中六個後來根本沒用到,因為那幾個模組的資料是頁面自己抓的,不需要跨頁共享。但檔案已經建好了、已經註冊了,刪掉又要動好幾個地方,所以就留著。
這是一種很常見的技術債,我覺得可以叫它「對稱的儀式」:理由是「別的模組都有,這個沒有很奇怪」,不是因為需要。
而它的成本不是那 12 行程式碼,是每個新人打開這個目錄,都會以為「這個專案的慣例是每個模組都要有 store」,然後繼續複製這個習慣。
遷移的時候我們的處理很簡單:這六個直接不搬。 新專案最後有 9 個 store,每一個都是真的在用的。
坦白說,「API 比較簡潔」不是我們最有感的地方。真正有感的是兩件事:
一、少了一層儀式。 Vuex 要改狀態,得走 mutation;要做非同步,得走 action,action 再去 commit mutation。這套流程在大型應用裡有它的道理,但在後台系統裡,九成的情況是「打個 API、把結果存起來」,走這一整套只是在繞路。Pinia 把 mutation 這層拿掉了。
二、store 本身就是一般函式。 這代表它可以像昨天講的 composable 一樣被使用、被組合。狀態管理和邏輯抽取,變成同一套心智模型:你不用在腦中維護「這個要放 store、那個要放 composable」的分類法。
另外一個小但很有效的改變:資料持久化統一用外掛處理,而不是每個 store 各自去寫讀寫瀏覽器儲存的程式碼。這件事聽起來很小,但它消除了一整類「這個 store 存了、那個忘了存」的 bug。
Day 4 提過舊專案裡有一個「假裝不是全域的全域」。今天把它講完。
舊專案裡有這樣的東西:
// 在管理使用者狀態的檔案裡
import { EventEmitter } from 'events'
export const userEvent = new EventEmitter()
// 選單資料載入完成時,廣播出去
userEvent.emit('menuLoad', state.menu)
userEvent.emit('permissionLoad', state.permission)
然後在另外兩個檔案裡接:
userEvent.on('menuLoad', () => { /* 重新產生路由 */ })
userEvent.on('permissionLoad', data => { /* 更新按鈕權限 */ })
這是 Node.js 的事件機制被搬到瀏覽器裡,當成全域廣播站用。一邊喊、另一邊接。
而它接的是最核心的流程:路由怎麼產生、按鈕權限怎麼判斷,全靠它。

Vue 3 把元件實例上的事件機制拿掉了($on、$off 都不見了),所以這種寫法一定要處理。
最直覺的做法是:找一個第三方的事件匯流排套件,把 emit / on 原封不動搬過去。
我們沒有這樣做,改問了一個更前面的問題:
這個事件,到底在表達什麼?
拆開來看:
menuLoad:意思是「選單資料已經載入完成了」permissionLoad:意思是「權限資料已經載入完成了」這兩句話,說的都是「某個資料現在有值了」,不是「發生了一件事」。
那它就不該是事件,它應該是狀態。
所以新系統的做法是:把選單和權限放進 store,需要的地方直接讀那個 store 的狀態。「載入完成」變成 store 裡的一個狀態,而不是一個要靠字串接收的廣播。
用事件的時候,因果關係是藏在字串裡的。
你打開路由設定檔,看不出來「路由是在使用者資料載入完成之後才產生的」,因為那個因果關係寫在 'menuLoad' 這六個字母裡。要看懂整個啟動流程,你得同時打開三個檔案,然後在腦中把 emit 和 on 連起來。
換成狀態之後,依賴關係是寫出來的:這段程式碼讀了 store 的哪個值,一目了然。
有沒有覺得這個結論很耳熟?
今天講的三件事,表面上互不相干:響應式換底、狀態管理換套件、事件機制被移除。
但它們的改進方向完全一致:
| 舊的做法 | 問題 | 新的做法 |
|---|---|---|
this.$set 通知系統 |
你要記得通知,忘了就壞 | 系統自己知道 |
| 12 個 module,6 個是空的 | 目錄結構在假裝有東西 | 有才建,沒有就不建 |
emit('menuLoad') |
因果關係藏在字串裡 | 因果關係寫成 store 的狀態 |
昨天講 Composition API 的時候,結論是「讓依賴從隱性變顯性」。今天這三件事是同一件事的不同面向:
把「你要記得」的東西,換成「它寫在那裡」。
這也是我認為 Vue 3 這次大改版最有價值的地方:不是效能,是它把很多「靠人記住」的東西,變成「靠程式碼記住」。
而人是會忘記的,尤其是在六年、十個人輪流維護的專案裡。
一、ref 和 reactive 的不一致很煩。 在 <script> 裡要寫 .value,在模板裡不用。新人一定會踩,而且錯誤訊息不會告訴你這件事。我們是靠一條硬規則繞過去的,不是靠理解。
二、Pinia 少了一層儀式,也少了一層約束。 Vuex 的 mutation 雖然囉嗦,但它強制你把「改狀態」集中在一個地方。Pinia 讓你可以直接改,方便,但也代表要靠團隊自律。這跟 Day 7 講的「寫法自由帶來新的不一致」是同一類問題。
三、把事件改成狀態,不是萬用解。 有些東西本質上就是事件(例如「使用者按了送出」),硬塞進 store 會很扭曲。我們的判準是:如果它描述的是「某個東西現在是什麼樣子」,那是狀態;如果它描述的是「剛剛發生了什麼」,那才是事件。
$set 的正確答案是「刪掉」,不是「找對應寫法」。明天 Day 9 換一個戰場:換掉 UI 元件庫的真實代價。我會給一個數字(舊專案裡表格欄位出現了 3,354 次),然後講為什麼我們沒有做一對一的元件對應翻譯。