iT邦幫忙

2024 iThome 鐵人賽

DAY 24
0
Modern Web

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

Day24 — 青出於藍 | 跟 AI 一起動手重構,小步批次重構

  • 分享至 

  • xImage
  •  

前言

Code Smell Spots 異味點

上次已經巡完目前的程式碼,聞到一些具有「異味」的程式部分,我將其稱為「程式異味點」。意味著這些部分是邏輯相對複雜,不容易理解,有值得重構優化的部分。

再次強調一個概念,程式碼壞味道是個「指標」,凸顯出這裡有可以優化的空間,至於重構的方法,則是因程式而定和異味而定。(可以參考「重構:改善既有程式」中的附錄,裡面有異味 & 重構方法的對照表)

接下來進入正題,動口不如動手,捲起袖子下去進行重構囉。

重構之路漫漫

一些初步重構

上次已經聞到不少怪味道的程式碼,手開始有點癢了,就開始來…列一下問題點(Code Smells)的 Todo 清單,誰叫我是 Todo 魔人(?

  • [ ] 缺乏錯誤處理:handleInput 部分沒有處理阻擋不能輸入的部分
  • [ ] 過深的巢狀結構處理:在 Keypad 部分的按鍵處理部分,if / else 巢狀結構過深
  • [ ] 重複邏輯:handleEdithandleOk 這些 function 內的邏輯,有些地方重複
  • [ ] 狀態管理複雜:在 App 共有 7 個狀態,有點太多,不好理解

大致上是以上這些地方有異味,問題點大致上是 4 個,彼此沒有重複涵蓋邏輯。

重構還是新功能?

首先是 handleInput 的部分,先請 AI 幫我處理看看,總之就是要不能出現像是如下「非正常可輸入金額」

https://ithelp.ithome.com.tw/upload/images/20241004/20168308WpjPA2POZi.jpg

這與其說是「重構」,不如說是功能的一部分,這晚點再來處理。還記得上次說的嗎?

重構不能加上新功能邏輯

「使用者只能輸入特地規則的數字」,原本沒有做此規則,使用者可以隨意輸入,確實算新功能,所以先 hold 著。

跨大步還是小步重構? 依功能而定

下面一位~

  • [ ] 過深的巢狀結構處理:在 Keypad 部分的按鍵處理部分,if / else 巢狀結構過深
<button
    key={index}
    className={`p-4 text-xl font-bold rounded ${
        btn === "AC"
            ? "bg-orange-500 text-white"
            : btn === "⌫"
                ? "bg-yellow-500 text-white"
                : btn === "+"
                    ? "bg-gray-600 text-white"
                    : "bg-gray-700 text-white"
    }`}
    onClick={() => {
        if (btn === "AC") onClear();
        else if (btn === "⌫") onBackspace();
        else onInput(btn);
    }}
>
    {btn}
</button>

腦中是有一些解法了(用多型來處理),但先問問 AI 看看,它有什麼好想法?用了 inline 指令問了 claude-sonnet 看看:

以下的 <button ... > 有 Code Smell,可以幫我試著重構嗎?

雖然 AI 給了我解法,幫我用 switch 做了重構,是解決掉 if / else 的問題,但我覺得這樣不是個一勞永逸地好解法。所以也不特地貼上來給大家參考。為什麼這麼說? 你想嘛,如果哪天鍵盤改變了配置,負責處理 className 和 handleButtonClick 都要動,如果多了 3 種功能,以上兩個函式都要「進去修改」,可以說是違反了「開放封閉」原則。

那可以怎麼改?就回頭改指令,改以剛剛想到的「多型」這個方式來處理,提供以下指令:

以下的 <button ... > 有 Code Smell,可以幫我試著用「多型」的模式重構嗎

給出了不錯滿意的結果,雖然沒有「小步」調整,但跟 AI 協作重構,似乎也不用管這麼多,再加上有測試的保護,不用太害怕:

  • 調整了 buttons 的結構,使其化為「多型」的形式,只要符合介面格式,皆可作為其中一個按鈕
  • 很自動地將 Button 抽成共用組件

interface ButtonProps {
    label: string;
    onClick: () => void;
    className?: string;
}

const Button: React.FC<ButtonProps> = ({ label, onClick, className }) => {
    const buttonClass = `p-4 text-xl font-bold rounded ${className}`;
    return (
        <button className={buttonClass} onClick={onClick}>
            {label}
        </button>
    );
};

const Keypad: React.FC<KeypadProps & { currentAmount: string }> = ({
    onInput,
    onClear,
    onBackspace,
    onOk,
    currentAmount
}) => {
    const buttons: ButtonProps[] = [
        { label: "7", onClick: () => onInput("7") },
        { label: "8", onClick: () => onInput("8") },
        { label: "9", onClick: () => onInput("9") },
        { label: "AC", onClick: onClear, className: "bg-orange-500 text-white" },
        { label: "4", onClick: () => onInput("4") },
        { label: "5", onClick: () => onInput("5") },
        { label: "6", onClick: () => onInput("6") },
        { label: "⌫", onClick: onBackspace, className: "bg-yellow-500 text-white" },
        { label: "1", onClick: () => onInput("1") },
        { label: "2", onClick: () => onInput("2") },
        { label: "3", onClick: () => onInput("3") },
        { label: ".", onClick: () => onInput(".") },
        { label: "0", onClick: () => onInput("0") },
        { label: "+", onClick: () => onInput("+"), className: "bg-gray-600 text-white" },
    ];

    return (
        <div>
            <div className="bg-black p-4">
                <div className="text-5xl font-bold text-white text-right">${currentAmount}</div>
            </div>
            <div className="grid grid-cols-4 gap-2 bg-gray-800 p-4 rounded-b-lg">
                {buttons.map((btn, index) => (
                    <Button
                        key={index}
                        label={btn.label}
                        onClick={btn.onClick}
                        className={btn.className || "bg-gray-700 text-white"}
                    />
                ))}
                <Button
                    label="OK"
                    onClick={onOk}
                    className="bg-blue-500 text-white row-span-2 col-start-4 row-start-3"
                />
            </div>
        </div>
    );
};

跑了測試,很好! 測試都通過了!先 commit 起來,推上遠端!

寫完收工!(並沒有)

重構 Part2

下一個是重構「重複邏輯」,把上面列的 Todo 搬下來 👇

  • [ ] 重複邏輯:handleEdithandleOk 這些 function 內的邏輯,有些地方重複

看了一下,handleOk 的部分最適合先拿它開刀 👨‍⚕️,其中的怪味道是:「過多的 if / else (兩層就算不少了),而且有重複的程式碼」

const handleOk = () => {
    const amount = parseFloat(currentAmount);
    if (!isNaN(amount)) {
        if (isEditingAmount) {
            setPendingAmount(amount);
            setIsEditingAmount(false);
            setShowCategorySelector(true);
        } else {
            setPendingAmount(amount);
            setShowCategorySelector(true);
        }
    }
    setCurrentAmount("0");
};

首先,我先用 early return 的方式進行重構,將第一個 if 嵌套給解開,在該 handleOk 的地方下了指令後,給了我以下結果。不只將 if 解開,還把重複沒用到的程式碼砍了,變得如此精簡,很棒!此外,還看到 isEditingAmount 這個 state 根本在 App 中沒用到,先砍了再說!

按了測試跑一下,全都綠燈通過,看來是沒啥問題 👍

const handleOk = () => {
    const amount = parseFloat(currentAmount);
    if (isNaN(amount)) return;

    setPendingAmount(amount);
    setShowCategorySelector(true);
    setCurrentAmount("0");
};

調整這個 handleOk 的方法之後,跟 handleEdit 就沒有什麼重複的地方了(或者可以說,抽了共用也沒什麼意思),可喜可賀。

最後,還剩下有同樣症頭的 handleSelectCategory,這個 if 有兩層,看了頭有點痛。

const handleSelectCategory = (category: string) => {
    if (pendingAmount !== null) {
        if (editingItem) {
            setHistory(history.map(item =>
                item.id === editingItem.id ? { ...item, amount: pendingAmount, category } : item
            ));
            setEditingItem(null);
        } else {
            const newItem = { amount: pendingAmount, category, id: Date.now() };
            setHistory([...history, newItem]);
        }
        setPendingAmount(null);
    }
    setShowCategorySelector(false);
};

先一樣下指令,把第一個 if 嵌套,用 early return 給去除掉。

const handleSelectCategory = (category: string) => {
    setShowCategorySelector(false);

    if (pendingAmount === null) return;

    if (editingItem) {
        setHistory(history.map(item =>
            item.id === editingItem.id ? { ...item, amount: pendingAmount, category } : item
        ));
        setEditingItem(null);
    } else {
        const newItem = { amount: pendingAmount, category, id: Date.now() };
        setHistory([...history, newItem]);
    }
    setPendingAmount(null);
};

接著,我看裡面這個 if(editingItem) 有點不順眼,因為其中做的事情蠻像的,差別只是在於 setHistory 裡面塞的 history ,會因為是不是在「編輯中」而有所不同,於是將這部分也跟著重構了一下。(一樣小提醒,記得要先把上一個重構給 commit 起來呦)

const handleSelectCategory = (category: string) => {
    if (pendingAmount === null) return;

    const updatedHistory = editingItem
        ? history.map(item =>
            item.id === editingItem.id ? { ...item, amount: pendingAmount, category } : item
        )
        : [...history, { amount: pendingAmount, category, id: Date.now() }];

    setHistory(updatedHistory);
    setEditingItem(null);
    setPendingAmount(null);

    setShowCategorySelector(false);
};

這樣就算重構得差不多,至於資料邏輯做封裝什麼的,是還可以再優化一下,但已經相比原先的寫法,可讀性好非常多了呀。而且測試一樣也都是通過的呦 🟢(再次強調)

所以這部分先到此為止囉 :)。盤點這次做了哪些重構:

  • 過深的巢狀結構處理:在 Keypad 部分的按鍵處理部分,if / else 巢狀結構過深
  • 重複邏輯:handleEdithandleOk 這些 function 內的邏輯,有些地方重複

以上兩個散發怪味道的程式碼,都用重構的方式給處理掉了,而且在重構過程中,藉由重構的「形塑之力」,原本沒發現到的可優化之處也浮現出來,一併解決掉了 💪。

結尾

總共做了什麼重構?

回顧一下這次做的重構,一個是「重複邏輯」異味的處理,用了 early return 就差不多解決了。

另一個是 KeyPad 的「過深巢狀結構」異味,用了「多型」的方式來重構一番,算是跨步比較大的重構,解決得比較徹底,且整體程式碼易讀性高很多。

REF

  • 一樣是「重構:改善既有程式的設計」

上一篇
Day23 — 青出於藍 | 淺談「識別壞味道」,分辨程式碼的好壞
下一篇
Day25 — 青出於藍,重構的小步前進,一次一小步 Part2
系列文
與 AI 一起開發 Side Project 吧!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言