iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Modern Web

一個活了六年的 Vue 2 後台,升級到 Vue 3 的那兩年系列 第 3

Day 3|兩套系統,20 個一模一樣的檔案,和幾個正在長歪的

  • 分享至 

  • xImage
  •  

模組一|現況盤點與立案(Day 1–5)

我手上有兩套後台管理系統:一套給內部營運用,一套給外部合作方用。它們原本是同一份程式碼——當年為了趕時間,有人把第一套整包複製一份、改個名字,就成了第二套。兩個 repo、兩套 git 歷史、兩組人各自維護,然後各自跑了六年。

這個系列寫的是這套活了六年的 Vue 2 後台,怎麼在兩年裡升級成 Vue 3。而今天這篇處理的,是動手之前必須先確認的一件事:六年過去,這兩份「同一份程式碼」現在還有多像?

我寫了一小段 diff 腳本量了一次,答案讓我在螢幕前坐了一會兒——而且真正危險的,不是那些長得一模一樣的檔案。

如果你手上也有一套被整包複製出去的專案,這篇要給你三樣可以直接拿去用的東西:一段三十秒跑完的量測腳本、兩個「兩邊已經長歪」的真實案例,以及合併時最卡的那一題——兩份都在正式環境跑著,你要以哪一份為準。

先講怎麼問這個問題

先講量法。這段可以直接抄去用,用到的指令全部是 Unix 內建的。

diff 是 Unix 內建的指令,功能就是比較兩個檔案哪裡不一樣。它會把差異的行印出來,開頭是 <(左邊檔案有的)或 >(右邊檔案有的)。所以「差異有幾行」就是數這些行有幾條。

底下我把內部營運那套叫 A 專案、外部合作方那套叫 B 專案。做法是:把 A 專案 src(放前端原始碼的目錄)裡的每個檔案,去 B 專案找同樣路徑的檔案,兩兩比對,統計差異行數。

A=/path/to/專案A/src
B=/path/to/專案B/src

cd "$A"
find . -type f \( -name '*.js' -o -name '*.vue' \) | while read f; do
  # 只比對「兩邊都有同樣路徑」的檔案
  if [ -f "$B/$f" ]; then
    d=$(diff "$A/$f" "$B/$f" | grep -c '^[<>]')   # 差異行數
    l=$(wc -l < "$A/$f")                          # 檔案總行數
    echo "$d|$l|$f"
  fi
done | sort -t'|' -n | head -30

最後 sort -t'|' -n 是按第一欄(差異行數)由小到大排。排在最前面的,就是兩邊最像的檔案。

跑完大概三十秒。結果分成很清楚的三層。

第一層:20 個檔案,差異是 0 行

一個字都不差。

統計 數字
A 專案 src 底下的檔案 434
兩邊都有同樣路徑的檔案 172
差異 = 0 行的檔案 20 個,合計 1,091 行
差異 ≤ 10 行的檔案 33 個

也就是說,兩邊路徑相同的檔案裡,每 8 個就有 1 個是一字不差的複本,再加上一批差不到十行的。

而且不是只有那種三五行的小工具檔。清單裡有 187 行的共用下拉選單元件、151 行的樣式共用邏輯、120 行的圖示清單、103 行的儲存工具、75 行的版面路由設定。

這些檔案的共同點很明顯:它們是基礎建設,不是業務功能。路由設定、儲存工具、共用元件、圖示表,正因為跟業務無關,所以兩邊都不需要改,於是六年來原封不動地並存著。

https://ithelp.ithome.com.tw/upload/images/20260808/20183479Ja3ChzEvJc.png
這張圖要注意的是三段的相對寬度:紅色那段(完全複本)看起來不多,但它是 20 個完整檔案、1,091 行;橘色那段才是真正的地雷區:差幾行的檔案沒有任何工具會警告你,卻已經開始各自演化。藍色那 119 個反而最安全,因為它們早就明顯不同,沒人會誤以為改一邊就夠。

這 1,091 行,是同一份程式碼被維護了兩次

第二層:差幾行的,才是真的可怕

第一層其實還好。真正讓我坐直的是第二層:那些只差幾行的檔案。

因為「差 0 行」代表沒人動過,「差 5 行」代表有人動過其中一邊,而且另一邊沒跟上。

我挑兩個實際的例子。

例子一:一邊修好了,另一邊還壞著

https://ithelp.ithome.com.tw/upload/images/20260808/20183479eyYlZSQt9N.png
兩邊都有一個檔案負責路由守衛,就是使用者每次切換頁面時會先跑一次的檢查邏輯(有沒有登入?有沒有權限?順便把這頁記進上方的多頁籤)。

兩邊的差異只有十幾行,集中在「把這頁記進多頁籤」那一段:

// B 專案(比較早的版本):直接把當前網址參數存進頁籤
store.commit('ADD_TAG', {
  label: title,
  value: to.name,
  params: to.params,   // ← 就用現在網址上的參數
})
// A 專案(後來被改過):先去暫存裡撈之前存過的參數
let tagItem = {
  label: title,
  value: to.name,
  params: getStore,
}
const params = getStore({ name: to.name }) || {}
tagItem.params = params          // ← 用「上次存過的」蓋掉
store.commit('ADD_TAG', tagItem)

翻成白話:A 專案有人修好了「切回舊分頁時,之前輸入的搜尋條件會不見」這個問題,他改成從暫存裡把上次的條件撈回來。

而 B 專案沒有這段。所以在 B 專案裡,這個 bug 到今天還在

我特別想強調這件事的荒謬之處:這不是兩個團隊各自實作了不同需求,是同一個 bug 只被修了一次。 修的人大概根本不知道另一邊也有同一份檔案、同一個 bug。而且沒有任何工具會告訴他:兩個 repo、兩套 CI、兩份程式碼審查,沒有任何一個環節會說「欸,隔壁那份也要改」。

例子二:被註解掉的那三行

第二個例子更微妙。兩邊都有一個把後端選單資料轉成路由的工具檔(選單是後端給的,前端要把它翻成一張可以跳轉的路由表),差異只有三行——那三行負責的判斷是:如果選單給的是 http 開頭的外部網址,就改走內嵌頁面的路徑。

// B 專案:這段是活的
if (src.includes('http') || src.includes('https')) {
  result = `/myiframe/urlPath?${objToform(params)}`
}
// A 專案:同樣三行,被註解掉了
// if (src.includes('http') || src.includes('https')) {
//   result = `/myiframe/urlPath?${objToform(params)}`
// }

有人在 A 專案把這段關掉了。可能是它造成了某個 bug、可能是需求變了、也可能只是當時在 debug 忘了打開。

而現在,沒有人知道答案。

  • 沒有註解說明為什麼關掉
  • git 紀錄裡當然找得到是誰、哪一天關的,但找不到「為什麼」
  • 更要命的是:沒有人知道 B 專案該不該也關掉

於是這三行就永遠卡在那裡。要動它的人得先花半天考古,然後多半會選擇「算了不要碰」。這就是技術債複利的樣子:它不是讓你多寫程式碼,是讓你不敢改程式碼。

所以真正的問題不是「重複」

到這裡可以把結論收起來了。

大家講到複製貼上,直覺想到的成本是「程式碼變多了」。1,091 行重複,聽起來也還好,一個大型專案十萬行,這佔比不到 1%。

但真正的成本不在行數,在這三件事:

  1. 同一個需求要做兩次。 每次都要記得「另一邊也要改」,而這件事完全靠人的記憶力。
  2. 同一個 bug 可能只修一次。 而你不會知道漏了哪一次,沒有人在盤點兩邊的差異,除非有人像我這樣去跑 diff
  3. 兩份會越來越不一樣。 這是最致命的。第一年兩份是 99% 相同,第三年變成 90%,等你想合併的時候,你面對的是兩個各自演化過、都有人在用、誰也不敢說哪個是對的版本,不是「複製貼上」。

換句話說:「兩份一模一樣的程式碼」不可怕,可怕的是「兩份正在變得不一樣的程式碼」。 而後者是前者的必然結果,只是時間問題。

這件事有個名字:DRY,但它常被誤讀

DRY = Don't Repeat Yourself,「不要重複自己」。這大概是每個工程師學到的第一條原則。

但它常被簡化成「不要複製貼上」,而漏掉了真正的重點。DRY 要消除的是「同一份知識存成兩份」,不是「兩段程式碼長得像」。

差別用一個場景就清楚了:

情況 A:你家和辦公室各貼了一張 Wi-Fi 密碼便條紙。密碼改了,你只改了家裡那張。
 → 這是同一份知識存了兩份,而且已經不一致。這種重複該消除。

情況 B:你家貼了一張 Wi-Fi 密碼,辦公室也貼了一張 Wi-Fi 密碼。它們本來就是不同的密碼,只是格式長得一樣。
 → 這不是重複。硬要「統一管理」反而會出事。

今天講的這 20 個檔案是情況 A:同一個決定、同一段邏輯、同一個 bug,只是被存成了兩份。

但情況 B 完全不同:兩段程式碼現在剛好長得很像,未來卻會往不同方向長。太早合併會比留著兩份還痛,你會開始在裡面塞 if,塞到最後沒人敢動它,而且改它會同時影響兩個產品。

這件事我們踩過:曾經刻意先寫兩份、觀察了幾個月、確認它們真的一樣,才合併。 Day 29 那篇專門處理「共用層的界線」——什麼該抽出去只留一份、什麼該讓它繼續各寫一份,完整的判斷準則和真實案例都在那裡。

重複本身不是罪,「不知道自己在重複」才是。

那 20 個檔案的問題從來不是「它們一樣」,是沒有人知道它們一樣。

我在會議上沒有秀程式碼,只講那個路由守衛的故事:同一個 bug,一邊修了、一邊沒修,而且修的人不知道有另一邊。 這個故事所有人都聽得懂,包括不寫程式的人。

最難的部分,是決定「以哪一份為準」

兩個同型號的鐘走出不同時間,我拿著螺絲起子伸到一半就停住了
講完現象,講一下實際處理起來最卡的地方。

當你決定要把這兩份合併成一份的時候,會立刻撞上一個問題:兩份已經各自演化了六年,其中一份修過 bug、另一份沒有,你要以哪一份為準?

這個問題沒有技術答案。沒有工具能幫你判斷「哪個版本才是對的」,因為兩邊都在正式環境跑著、都有人在用、而且各自的行為都被使用者習慣了。

我們最後是靠人工判讀,一行一行看,分成三種處置:

  1. 明確是修好的(像那個搜尋條件的例子)→ 以修好的版本為準
  2. 明確是需求不同 → 保留差異,但要寫下來為什麼不同
  3. 看不出來為什麼不一樣(像那被註解掉的三行)→ 先照舊、標記起來、之後再問

第三類最多。而「先照舊、標記起來」聽起來很消極,但我後來覺得這是對的,在你不確定為什麼的時候,維持現狀是風險最低的選項。真正的錯誤是「看不懂所以順手改成我覺得對的樣子」。

這個發現決定了新架構的方向

盤到這裡,新系統的一個設計目標就自己浮出來了:共用的東西只能有一份

而那 20 個 diff = 0 的檔案,其實已經把「哪些東西該共用」的答案直接寫在臉上了:它們全都是跟業務無關的基礎建設。這不是我推理出來的,是六年的實際使用行為幫我做的分類:會被兩邊原封不動共用的,本來就是共用層該有的東西。

不過這裡要先誠實說一件事,免得你以為我們立刻就去抽共用套件了:我們沒有。

新系統剛開始的時候,兩個專案還是各自獨立的 repo,只有建置設定、程式碼風格規則這類「絕對不會分岔」的東西被抽出來共用。業務元件、頁面、API 定義,我們一個都沒共用。

原因、判斷界線、以及「什麼時候才可以抽」,會在 Day 29(共用層界線那篇)專門講。這裡先給一句結論:在遷移最不穩定的時候鎖定共用介面,是把風險放大兩倍的做法。

代價

這個 diff 實驗有它的極限,講清楚免得你高估它。

一、它只看得到「同路徑同檔名」的檔案。 如果某個工具函式在 A 專案叫 utils/date.js、在 B 專案被搬到 helpers/time.js,我的腳本完全抓不到。所以那 20 個一字不差的檔案是下限,不是全貌,真實的重複只會更多。

要抓出改過名的重複,得用內容相似度比對(例如逐檔算雜湊、或用專門的重複程式碼偵測工具)。我沒有做,因為第一輪的結果已經足夠說服人了。但如果你要拿這個數字去談預算,記得補一句「這是保守估計」。

二、它不會告訴你「哪一份才是對的」。 這是今天最花時間的部分,腳本三十秒就跑完,但判讀花了好幾天,而且判讀結果沒辦法自動化,只能人工一行一行看。

三、「先照舊、標記起來」會累積成新的債。 那些看不出為什麼不一樣的差異,我們選擇維持現狀並記下來。這在當下是對的,但如果沒有人回頭處理那張清單,它就只是把問題往後推,而我必須承認,我們那張清單到現在也沒有清完。

如果你也有姊妹專案

給三個今天就能做的事:

  1. 先跑一次 diff 統計。 三十秒的事,但你會第一次知道「兩邊到底有多像」。這個數字在任何討論裡都比感覺有用。
  2. 把差異 1–10 行的檔案挑出來人工看一遍。 差 0 行的不急,差幾行的才是正在流血的傷口,那代表有人動過一邊。
  3. 把「另一邊也要改」寫進 checklist。 在還沒能力合併之前,至少讓它變成一個明文步驟,而不是靠某個資深同事的記憶。

明天 Day 4,講另一種更難抓的重複:不是檔案被複製,是東西被掛到全域。我會用一行 this.baseUrl 開場:這個變數在專案裡搜不到定義,但每個元件都能用。


上一篇
Day 2|先量體重再談減肥:把「這 code 很爛」翻譯成一份清單
下一篇
Day 4|`this.baseUrl` 從哪來?全域魔法與那些沒人敢刪的東西
系列文
一個活了六年的 Vue 2 後台,升級到 Vue 3 的那兩年5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言