🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

一個函數超過20行? 聊聊我的函數式代碼潔癖

pankaj-patel-_SgRNwAVNKw-unsplash.jpg

大家好,我又來了😁

我得承認,我有個毛病,或者說潔癖吧。

在Code Review的時候,當我點開一個*.js / *.ts檔案,看到一個函數洋洋灑灑地寫了50行、80行,甚至更多時,我的第一反應不是去讀它的邏輯,而是生理性地發慌😖。

我會下意識地在評論區留下一句:這個函數是不是太長了?能不能拆一下?

20行這個數字,是我給自己設的一个 代碼量閾值。它不絕對,但足夠靈敏。

我知道,很多人會覺得我這是小題大做、形式主義。但今天我想聊聊,這個潔癖背後,隱藏的是一個被函數式思想洗禮過的、關於代碼可維護性、可測試性和認知成本的嚴肅思考。


為什麼長函數讓人如此發慌?😒

一個超過20行的函數,對我來說,通常意味著三場災難:

1. 閱讀成本極高

// 這是一個超過 50 行的函數
// 目的:根據用戶數據生成報告並發送郵件(其實做了三件事)
function handleUserReport(users, sendEmail, isAdmin) {
  let result = [];
  let flag = false;

  console.log("開始處理用戶數據...");

  for (let i = 0; i < users.length; i++) {
    let u = users[i];
    if (u.age > 18) {
      if (u.active) {
        if (u.score > 80) {
          result.push({ name: u.name, status: "優秀" });
          flag = true;
        } else if (u.score > 60) {
          result.push({ name: u.name, status: "良好" });
        } else {
          result.push({ name: u.name, status: "待提升" });
        }
      } else {
        if (isAdmin) {
          result.push({ name: u.name, status: "非活躍但保留" });
        } else {
          result.push({ name: u.name, status: "非活躍" });
        }
      }
    } else {
      if (u.active) {
        result.push({ name: u.name, status: "未成年用戶" });
      }
    }
  }

  console.log("用戶數據處理完畢");
  console.log("生成報告中...");

  let report = "用戶報告:\n";
  for (let i = 0; i < result.length; i++) {
    report += `${result[i].name} - ${result[i].status}\n`;
  }

  if (flag) {
    console.log("存在優秀用戶!");
  }

  if (sendEmail) {
    console.log("準備發送郵件...");
    // 模擬郵件發送邏輯
    for (let i = 0; i < result.length; i++) {
      if (result[i].status === "優秀") {
        console.log(`已發送郵件給:${result[i].name}`);
      }
    }
  }

  console.log("處理完成。");
  return report;
}

上面👆這個50多行的函數,就像一篇沒有分段的短文。你必須從頭到尾把它加載到你的大腦裡,才能理解它到底在幹嘛。

  • 第5行定義的一個flag變量,在第15行被修改了。
  • 中間夾雜著三層if/else嵌套。
  • 它到底做了幾件事?天知道🤷‍♂️。

這種函數,是可寫,不可讀的。寫的人洋洋得意,幾個月後他自己回來維護,一樣罵娘😠。

2. 根本無法單元測試

我們來談談單元測試。你怎麼去測試一個50行的、混合了數據請求、數據格式化和UI狀態更新的函數?

先看代碼👇:

// 一個50行的混合函數:既請求接口、又格式化數據、還更新UI狀態
async function loadUserProfile(userId) {
  setLoading(true);

  try {
    // 1️⃣ 請求數據
    const response = await fetch(`/api/user/${userId}`);
    const data = await response.json();

    // 2️⃣ 本地緩存
    localStorage.setItem('lastUserId', userId);

    // 3️⃣ 格式化數據
    const displayName = data.firstName + ' ' + data.lastName;
    const ageText = data.age ? `${data.age}歲` : '未知年齡';

    // 4️⃣ UI狀態更新
    setUser({
      name: displayName,
      age: ageText,
      hobbies: data.hobbies?.join('、') || '無'
    });

    // 5️⃣ 額外副作用
    if (data.isVIP) {
      trackEvent('vip_user_loaded');
      showVIPBadge();
    }

    setLoading(false);
  } catch (error) {
    console.error('加載失敗', error);
    setError('加載用戶信息失敗');
    setLoading(false);
  }
}

測試代碼:

// 測試代碼(偽代碼)
test('loadUserProfile should set formatted user data', async () => {
  // Mock 一堆外部依賴
  global.fetch = jest.fn().mockResolvedValue({
    json: () => Promise.resolve({ firstName: 'Tom', lastName: 'Lee', age: 28, isVIP: true })
  });
  localStorage.setItem = jest.fn();
  const setUser = jest.fn();
  const setLoading = jest.fn();
  const setError = jest.fn();
  const trackEvent = jest.fn();
  const showVIPBadge = jest.fn();

  // 還要通過依賴注入或hook替換上下文...
  await loadUserProfile(123);

  // 然後驗證每一步是否被正確調用
  expect(fetch).toHaveBeenCalledWith('/api/user/123');
  expect(localStorage.setItem).toHaveBeenCalledWith('lastUserId', 123);
  expect(setUser).toHaveBeenCalledWith({
    name: 'Tom Lee',
    age: '28歲',
    hobbies: '無'
  });
  expect(trackEvent).toHaveBeenCalledWith('vip_user_loaded');
  expect(showVIPBadge).toHaveBeenCalled();
  expect(setLoading).toHaveBeenLastCalledWith(false);
});

你根本沒法測試。你只能去集成測試。

為了測試它,你不得不mock掉fetch、localStorage、useState... 你會發現,你的測試代碼,比你的業務代碼還長、還複雜。

3. 你看不見的地雷

函數越長,它順手去幹點髒活的概率就越大。

舉個例子👇:

// 名字看起來挺純潔的 —— 獲取用戶配置
// 實際上它幹了很多事沒人知道...
function getUserConfig(userId) {
  console.log('開始獲取用戶配置...');

  // 1️⃣ 順手改了全局變量
  globalCache.lastRequestTime = Date.now();

  try {
    // 2️⃣ 發起網絡請求
    const res = fetch(`/api/config/${userId}`);
    const data = res.json();

    // 3️⃣ 順手改了一下全局設置
    window.__APP_MODE__ = data.isAdmin ? 'admin' : 'user';

    // 4️⃣ 順手寫了一點 localStorage
    localStorage.setItem('lastConfigUser', userId);

    // 5️⃣ 格式化返回數據
    const config = {
      theme: data.theme || 'light',
      lang: data.lang || 'en-US'
    };

    return config;
  } catch (err) {
    console.error('獲取配置出錯', err);

    // 6️⃣ 順手派發了一個事件
    window.dispatchEvent(new CustomEvent('config_load_failed', { detail: { userId } }));

    // 7️⃣ 順手清空了一個全局標記
    globalCache.lastRequestTime = null;

    return { theme: 'light', lang: 'en-US' }; // 假裝有個默認值
  }
}

調用者根本不知道它幹了些什麼 😵‍💫

const config = getUserConfig(42);
console.log(config.theme); // 看起來很正常

// 但此時:
// window.__APP_MODE__ 已被改動
// localStorage 裡寫入了 lastConfigUser
// globalCache.lastRequestTime 已變化
// 如果請求失敗,還會觸發一個全局事件
  • 它在函數的中間,順手改了全局變量。
  • 它在catch塊里,順手dispatch了一個event
  • 它順手往window上掛了個東西。

這種充滿隱形副作用的函數,是系統中最不可預測的地雷。你根本不知道你調用它,會影響到哪裡。


談一談 函數式思想

我的潔癖,其實是來源於函數式編程思想。

我並不追求寫出高階組合子那些高深的東西。我只堅守兩個最樸素的原則:

函數必須小,且只做一件事

這是 單一職責原則 的終極體現。一個函數,就只做一件事。

  • getUserData就只負責fetch
  • formatUserData就只負責格式化。
  • setUserState就只負責更新狀態。

一個函數超過20行,對我來說,往往就是它至少做了兩件以上的事情的強烈信號。

追求純函數,隔離掉它的一切副作用

一個純函數:給它什麼(入參),它就吐出什麼(返回),絕不搞小動作。

我追求的目標,就是把所有的業務邏輯和計算,都抽成純函數。而那些不得不做的髒活(比如API請求、DOM操作),則被我隔離在最外層的協調函數裡。


重構一個函數

我們來看一個在React專案裡,極其常見的函數(絕對超過20行):

// 場景:一個提交用戶註冊的函數
async function handleRegister(formData) {
  setLoading(true);

  // 1. 業務邏輯:驗證
  if (!formData.username) {
    showToast('用戶名不能為空');
    setLoading(false);
    return;
  }
  if (formData.password.length < 6) {
    showToast('密碼不能少於6位');
    setLoading(false);
    return;
  }

  // 2. 業務邏輯:數據轉換
  const apiPayload = {
    user: formData.username,
    pass: btoa(formData.password + 'my_salt'), // 假設的加密
    source: 'web',
    registerTime: new Date().toISOString(),
  };

  // 3. 副作用:API請求
  try {
    const result = await api.post('/register', apiPayload);

    // 4. 副作用:更新UI狀態
    if (result.code === 200) {
      setUserData(result.data.user);
      trackEvent('register_success');
      showToast('註冊成功!');
      router.push('/dashboard');
    } else {
      showToast(result.message);
    }
  } catch (err) {
    showToast(err.message);
    trackEvent('register_fail', { msg: err.message });
  } finally {
    setLoading(false);
  }
}

這個函數,就是一場災難。它混合了4-5種職責,你根本沒法測試它。

重構過程如下👇:

1.先分離純業務邏輯(可測試)

// 純函數1:驗證邏輯 (可獨立測試)
// (5行)
export function validateRegistration(formData) {
  if (!formData.username) return '用戶名不能為空';
  if (formData.password.length < 6) return '密碼不能少於6位';
  return null; // 驗證通過
}

// 純函數2:數據轉換 (可獨立測試)
// (7行)
export function createRegisterPayload(formData) {
  return {
    user: formData.username,
    pass: btoa(formData.password + 'my_salt'),
    source: 'web',
    registerTime: new Date().toISOString(),
  };
}

2.再分離它的副作用

// 副作用函數1:API調用
// (3行)
export async function postRegistration(payload) {
  return api.post('/register', payload);
}

// 副作用函數2:處理成功後的UI邏輯
// (6行)
function handleRegisterSuccess(userData) {
  setUserData(userData);
  trackEvent('register_success');
  showToast('註冊成功!');
  router.push('/dashboard');
}

// 副作用函數3:處理失敗後的UI邏輯
// (3行)
function handleRegisterFail(error) {
  showToast(error.message);
  trackEvent('register_fail', { msg: error.message });
}

3.最後重組函數

現在,我們原來的handleRegister函數,變成了一個清晰的調用者:

// (18行)
async function handleRegister(formData) {
  // 1. 驗證
  const validationError = validateRegistration(formData);
  if (validationError) {
    showToast(validationError);
    return;
  }

  setLoading(true);
  try {
    // 2. 轉換
    const payload = createRegisterPayload(formData);
    // 3. 執行
    const result = await postRegistration(payload);
    // 4. 響應
    if (result.code === 200) {
      handleRegisterSuccess(result.data.user);
    } else {
      handleRegisterFail(new Error(result.message));
    }
  } catch (err) {
    handleRegisterFail(err);
  } finally {
    setLoading(false);
  }
}

等等!你這個handleRegister函數,不還是快20行嗎?😂

是的,但你發現區別了嗎?這個函數,幾乎沒有任何邏輯 ,它只負責調用其他小函數。它像一個流程圖,清晰得一目了然。

而所有的業務邏輯(validatecreatePayload),都被我拆分到了可獨立測試、可重用、可預測的純函數裡。這,就是這次的重構的價值。


20行代碼的標準 不是一個KPI,它是一個預警

它在提醒我們,這個函數的 負載 可能已經超標了,它在 單一職責 的路上可能已經走偏了。

這種潔癖,不是為了追求代碼的短小,而是為了追求代碼的簡單可預測

在一個由幾十萬行代碼構成的、需要長期維護的系統裡,簡單和可預測,比炫技(屎代碼💩),要寶貴一百倍😁。


原文出處:https://juejin.cn/post/7563950984255438898


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝27   💬3   ❤️7
660
🥈
我愛JS
📝1   💬9   ❤️1
72
🥉
御魂
💬1  
4
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付