iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

模組二|選型、語言遷移與地基(Day 6–11)

這個專案是一個活了六年的 Vue 2 後台,我花了兩年把它升到 Vue 3。升級的時候,有三件事是被迫一起換的:資料變動的偵測機制、狀態管理套件、還有一個在新版被直接移除的東西。

它們表面上互不相干。但我把三件事都搬完之後才發現,它們在解決同一個問題。這個結論我留到最後講。

一、響應式換底:那 232 次 this.$set

先看一個數字。我在舊專案數了這個:

grep -rho 'Vue\.set\|this\.\$set' src --include='*.vue' --include='*.js' | wc -l
# → 232

232 次。 如果你沒寫過 Vue 2,可能不知道 this.$set 是什麼、為什麼需要它。

為什麼 Vue 2 需要這個東西

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 把電話拆了

Vue 3 換了一套機制(Proxy),概念上像是:

飯店不再登記家具清單了,改成在房間門口裝了感應器。任何東西進出,系統自動知道,不管它是入住時就有的,還是你後來搬進來的。

所以那 232 次呼叫,在 Vue 3 全部不需要了。搬過去的時候直接刪掉,這是整個遷移過程中少數「純粹變輕鬆」的部分

但它長出了新的坑

代價是另一組陷阱。Vue 3 提供兩種建立響應式資料的方式,而它們的行為不一樣,這是新手最容易踩的地方:

const state = reactive({ count: 0 })
const { count } = state      // ← 這裡就斷了

count++                       // 畫面不會更新

為什麼?用另一個場景:

reactive 像一個裝了水的水壺,Vue 盯著這個水壺。

解構(const { count } = state)等於你從水壺倒了一杯水出來。杯子裡的水是真的,但它已經跟水壺沒有關係了。你把杯子的水喝掉,水壺不會知道。

我們團隊後來的做法是訂一條規則:

對外暴露的東西一律用 refreactive 只用在明確不會被整包替換、也不會被解構的地方。

這條規則不是最優雅的(ref 在程式碼裡要寫 .value,在模板裡又不用,這個不一致本身也很煩),但它很好記,而且不容易出錯。

在團隊裡,「好記」常常比「最優雅」重要。 一條大家記得住的次佳規則,勝過一條沒人記得的最佳規則。

二、Vuex → Pinia:六個只有兩行的 module

換狀態管理的時候,我發現了一個很有趣的東西。

舊專案的狀態管理有 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,每一個都是真的在用的

Pinia 實際好在哪

坦白說,「API 比較簡潔」不是我們最有感的地方。真正有感的是兩件事:

一、少了一層儀式。 Vuex 要改狀態,得走 mutation;要做非同步,得走 action,action 再去 commit mutation。這套流程在大型應用裡有它的道理,但在後台系統裡,九成的情況是「打個 API、把結果存起來」,走這一整套只是在繞路。Pinia 把 mutation 這層拿掉了。

二、store 本身就是一般函式。 這代表它可以像昨天講的 composable 一樣被使用、被組合。狀態管理和邏輯抽取,變成同一套心智模型:你不用在腦中維護「這個要放 store、那個要放 composable」的分類法。

另外一個小但很有效的改變:資料持久化統一用外掛處理,而不是每個 store 各自去寫讀寫瀏覽器儲存的程式碼。這件事聽起來很小,但它消除了一整類「這個 store 存了、那個忘了存」的 bug。

三、那個消失的 EventBus

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' 這六個字母裡。要看懂整個啟動流程,你得同時打開三個檔案,然後在腦中把 emiton 連起來。

換成狀態之後,依賴關係是寫出來的:這段程式碼讀了 store 的哪個值,一目了然。

有沒有覺得這個結論很耳熟?

三件事,同一個方向

今天講的三件事,表面上互不相干:響應式換底、狀態管理換套件、事件機制被移除。

但它們的改進方向完全一致:

舊的做法 問題 新的做法
this.$set 通知系統 你要記得通知,忘了就壞 系統自己知道
12 個 module,6 個是空的 目錄結構在假裝有東西 有才建,沒有就不建
emit('menuLoad') 因果關係藏在字串裡 因果關係寫成 store 的狀態

昨天講 Composition API 的時候,結論是「讓依賴從隱性變顯性」。今天這三件事是同一件事的不同面向:

把「你要記得」的東西,換成「它寫在那裡」。

這也是我認為 Vue 3 這次大改版最有價值的地方:不是效能,是它把很多「靠人記住」的東西,變成「靠程式碼記住」。

而人是會忘記的,尤其是在六年、十個人輪流維護的專案裡。

代價

一、refreactive 的不一致很煩。 在 <script> 裡要寫 .value,在模板裡不用。新人一定會踩,而且錯誤訊息不會告訴你這件事。我們是靠一條硬規則繞過去的,不是靠理解。

二、Pinia 少了一層儀式,也少了一層約束。 Vuex 的 mutation 雖然囉嗦,但它強制你把「改狀態」集中在一個地方。Pinia 讓你可以直接改,方便,但也代表要靠團隊自律。這跟 Day 7 講的「寫法自由帶來新的不一致」是同一類問題。

三、把事件改成狀態,不是萬用解。 有些東西本質上就是事件(例如「使用者按了送出」),硬塞進 store 會很扭曲。我們的判準是:如果它描述的是「某個東西現在是什麼樣子」,那是狀態;如果它描述的是「剛剛發生了什麼」,那才是事件。

帶走什麼

  1. 框架升級時,先問「這件事還需要做嗎」,而不是「這在新版怎麼寫」。 那 232 次 $set 的正確答案是「刪掉」,不是「找對應寫法」
  2. 看到「空的但被註冊了」的東西,那是訊號。 它代表當初建立它的理由是結構對稱,不是實際需求,而且它會被新人當成慣例複製下去。
  3. 遇到事件,先問它在表達狀態還是動作。 很多 EventBus 其實是狀態管理的替代品,而且是比較差的那個。
  4. 在團隊裡,好記的次佳規則勝過沒人記得的最佳規則。

明天 Day 9 換一個戰場:換掉 UI 元件庫的真實代價。我會給一個數字(舊專案裡表格欄位出現了 3,354 次),然後講為什麼我們沒有做一對一的元件對應翻譯。


上一篇
Day 7|一個檔名拼錯六年的 mixin,和 `this` 消失的意義
下一篇
Day 9|換掉 UI 元件庫:8,023 個標籤,和一個我沒預料到的結果
系列文
一個活了六年的 Vue 2 後台,升級到 Vue 3 的那兩年14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言