iT邦幫忙

2024 iThome 鐵人賽

DAY 25
0
Modern Web

與 AI 一起開發 Side Project 吧!系列 第 25

Day25 — 青出於藍,重構的小步前進,一次一小步 Part2

  • 分享至 

  • xImage
  •  

前情提要

腳有點麻了…

上次小步地重構了兩個部分,腳已經開始有點酸麻。雖然都是請 AI 幫忙寫,但還是沒有很輕鬆,因為要找問題點在哪,而且最後的解方也是我想的,看這次有沒有更好的重構法囉?

謎之聲:叫你阿罵來囉

小步快跑

好了,之前留下來的 Todo,還剩下 2 個。

  • [ ] 狀態管理複雜:在 App 共有 7 個狀態(變 6 個了),有點太多,不好理解
  • [ ] 缺乏錯誤處理:handleInput 部分沒有處理阻擋不能輸入的部分

同樣按部就班,一個個解決掉 Todo ,分別是重構和功能這兩項,那當然是先做重構囉。

跟 AI 一起跑

好了,該重構的項目剩下一個(如果忘記為什麼是一個的,請看上一篇),這個有點難一下子解決,到底有什麼辦法可以削減 state 的數量呢?

那有什麼比較通用的原則,可以作為削減 state 的依據呢?有的,我們可以用「不變數據」這個數據原則做延伸:「不需要狀態變化的,就不要用 state 存起來」

換句話說,如果只是計算值而已,那不用特地用 useState 來儲存值的變化。

啊!這樣說來,有看到一個 ! 就是這個 total 的值

const [total, setTotal] = useState<number>(0);

//...

useEffect(() => {
    const newTotal = history.reduce((sum, item) => sum + item.amount, 0);
    setTotal(newTotal);
}, [history]);

從以上表達式來看,我根本沒必要特地存一個 history 的派生變化值(Derived Value),直接寫成查詢(query)即可,這樣就可以消滅掉一個 state 了!輸入指令請 AI 重構後,total 直接從 history 即時計算出來即可。(備註:可參考「重構:改善既有的程式」一書中的 查詢取代派生

跑一下測試,很好,一樣也是測試都通過了。

const total = useMemo(() => {
    return history.reduce((sum, item) => sum + item.amount, 0);
}, [history]);

下一個動的是 currentAmountpendingAmount 這兩個 state,看起來是沒必要分兩種 amount 去儲存,為了驗證我的猜想,我快速請 AI 幫我重構看看:

可以幫我把 App 中的 state 給簡化嗎? 尤其是 currentAmount 和 pendingAmount 看起來用途相當接近耶

咻地一下子,AI 幫我做好了重構,快速看了一下,看起來沒什麼大問題…才對。於是乎,也是一樣先跑個測試驗證一下,哎?

測試派去了… 🤯 壞在 allows editing of an existing record by double-clicking 這項測試,也就是說,沒有辦法正常編輯已經添加上去的帳目。

跟 AI 跑有比較快嗎?

先不說有沒有跑比較快,測試被弄壞了,既然是只有「編輯」這一項測試壞掉,那應該就是出錯在 handleEdit 這一塊,但也看不出什麼所以然… 🤔 只好 undo 退回上一步,把 Apply 的結果給叫出來,如以下所示:

- const handleEdit = (id: number, amount: number, category: string) => {
-         setEditingItem({ id, amount, category });
-         setPendingAmount(amount);
-         setCurrentAmount(amount.toString());
        
+ const handleEdit = (id: number, editAmount: number, category: string) => {
+        setEditingItem({ id, amount: editAmount, category });
+        setAmount(editAmount.toString());
+        setShowCategorySelector(true);
+    };

看了一下,原來是 AI 偷偷幫我「加料」,把 setShowCategorySelector(true); 加上去,壞!偷偷來都不揪的!

那把這一行砍掉,照理說應該就行了,再次跑了測試,沒問題,很好!

true / false 不好嗎?

經歷了以上的重構,App 的狀態銳減剩下 4 個。而且重點是「功能不變」,程式碼的複雜度大幅降低,清爽很多了 👍。

還想提一個可以重構的地方,是 showCategorySelector 這個 true / false 狀態

const [showCategorySelector, setShowCategorySelector] = useState<boolean>(false);

//...

{showCategorySelector ? (
    <CategorySelector
        onSelectCategory={handleSelectCategory}
        onCancel={handleCancelCategorySelection}
        pendingAmount={parseFloat(amount)}
        initialCategory={editingItem ? editingItem.category : null}
    />
) : (
    <Keypad
        onInput={handleInput}
        onClear={handleClear}
        onBackspace={handleBackspace}
        onOk={handleOk}
        currentAmount={amount}
    />
)}

你可能會想說,這個有什麼問題嗎?不就是個顯示的「開關」而已?

但到底哪個是開,哪個是關,難道不能用 Keypad 是否顯示作為開關嗎? 當然可以。如此說來,這邊根本就不是「開關」,而是個「多選一」的條件渲染,只是到目前為止,這部分「剛好」只有 2 個條件而已。

就順手提供了指令:

幫我把 showCategorySelector 改成「多種」的狀態
- 新增 enum 來儲存要顯示的 pad 組件
- 同步修改下面的渲染邏輯

於是 AI 幫我新增了 enum 和重構相關邏輯,變得更好理解了些。至於最後的三元表達式渲染,是可以調整一下,但…目前 Force 還不夠,所以先這樣就好 👍。

enum PadState {
    Keypad,
    CategorySelector,
}

//...

const [currentPad, setCurrentPad] = useState<PadState>(PadState.Keypad);

//...

{currentPad === PadState.CategorySelector ? (
    <CategorySelector
        onSelectCategory={handleSelectCategory}
        onCancel={handleCancelCategorySelection}
        pendingAmount={parseFloat(amount)}
        initialCategory={editingItem ? editingItem.category : null}
    />
) : (
    <Keypad
        onInput={handleInput}
        onClear={handleClear}
        onBackspace={handleBackspace}
        onOk={handleOk}
        currentAmount={amount}
    />
)}

最後還是要提醒一下,如果光是要理解,就有困難了,下次再回頭看還是會忘記 😴

寫完,收工

重構的部分都好了,剩下最後一個算是功能的調整:

  • [ ] 缺乏錯誤處理:handleInput 部分沒有處理阻擋不能輸入的部分

首先是加上了測試,測試比較明確,是希望輸入部分,只能輸入「浮點數」之數字格式。其他啊仨布魯的奇怪格式,都要幫我擋起來,不給輸入!

新增輸入數字的測試
- 點擊輸入 1.....1,只會顯示 1.1
- 可以輸入 0.1,就會顯示 0.1

最後加上了此項新邏輯(還是得用 claude-sonnet-3.5),並且重構了一番,生成了如下的程式碼,細節步驟就不特地展示了。

const isValidInput = (input: string) => {
    const regex = /^(\d*\.?\d*)$/;
    return regex.test(input);
};

const handleInput = (value: string) => {
    const newAmount = (() => {
        if (amount === "0" && value !== ".") return value;
        if (value === "." && !amount.includes(".")) return amount + value;
        if (value !== ".") return amount + value;
        return amount;
    })();

    if (isValidInput(newAmount)) {
        setAmount(newAmount);
    }
};

總結

有測試保護了,很穩

要不是有測試的保護,要直接請 AI 幫忙重構大區塊的程式碼,我還真不敢這麽做。此外,因為先前寫的是「整合測試」,不怕因為程式碼在重構之後消失不見,像是原先的 state 從 7 個減為 4 個,如果當初是做其中的 state 狀態變化「單元測試」,那些單元測試在經過測試的調整之後,就沒路用了。(但也不是說單元測試沒用,要看程式單元而定)

一次重構一個:重構循環

以前都覺得說,我一次只改一點點程式碼,好像就弱掉了。容易很貪心一次改很多東西,除了重構時「不遵守規矩」,常「偷渡」新功能在重構之中,而且一次重構都會改太多,值到改到壞掉才驚覺,原來改動的數量是自己無法掌控的了,這才會停手。

直到踩過多次的坑,才終於痛定思痛改變自己的習慣,以「小不增量」的方式,一次寫一點,一次做一點重構…。

永不停止的重構之路

比較好的重構方法,藉著測試的保護之下,一次調整「一塊功能」,調整完之後確保測試通過 🟢,commit 起來(存檔紀錄)。

再次重構調整,若改壞了,測試不通過 🔴,那就先改到好,直到測試通過。若無法解決,那先緩緩,切忌把「測試壞掉」的程式碼做 commit 並且 push 到遠端。

最後一點後話

我們提供策略、想法和方法,AI 負責去執行實作。現在跟 AI 協作重構,可以加速重構的速度,但也因此容易「超速」而不小心把程式碼改壞 😂 (就像 藤原拓海 車速太快 就變 藤原填海)

所以當程式碼生成的速度越快,心要越慢,慢下來好好看 AI 生成的結果。

題外話

最後的最後,小小抱怨一下,使用 Cursor IDE PRO 版的免費 gpt-4o-mini 模型,在寫新功能方面真的不太行,基本且具體的重構倒是還可以,果然還是只有 claude-sonnet 比較厲害一點。

最近有所耳聞其他 AI 工具,像是 claude dev 聽說相當厲害,有機會再來找來試試看 :)


REF

書籍參考

  • 重構:改善既有代碼的設計

上一篇
Day24 — 青出於藍 | 跟 AI 一起動手重構,小步批次重構
下一篇
Day26 — 方興未艾 | 最後一個功能,PDCA 循環跑起來 (Part1)
系列文
與 AI 一起開發 Side Project 吧!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言