
大家好,我又來了😁
我得承認,我有個毛病,或者說潔癖吧。
在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多行的函數,就像一篇沒有分段的短文。你必須從頭到尾把它加載到你的大腦裡,才能理解它到底在幹嘛。
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。這種充滿隱形副作用的函數,是系統中最不可預測的地雷。你根本不知道你調用它,會影響到哪裡。
我的潔癖,其實是來源於函數式編程思想。
我並不追求寫出高階組合子那些高深的東西。我只堅守兩個最樸素的原則:
函數必須小,且只做一件事
這是 單一職責原則 的終極體現。一個函數,就只做一件事。
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行嗎?😂
是的,但你發現區別了嗎?這個函數,幾乎沒有任何邏輯 ,它只負責調用其他小函數。它像一個流程圖,清晰得一目了然。
而所有的業務邏輯(validate和createPayload),都被我拆分到了可獨立測試、可重用、可預測的純函數裡。這,就是這次的重構的價值。
20行代碼的標準 不是一個KPI,它是一個預警。
它在提醒我們,這個函數的 負載 可能已經超標了,它在 單一職責 的路上可能已經走偏了。
這種潔癖,不是為了追求代碼的短小,而是為了追求代碼的簡單和可預測。
在一個由幾十萬行代碼構成的、需要長期維護的系統裡,簡單和可預測,比炫技(屎代碼💩),要寶貴一百倍😁。