一个函数超过20行? 聊聊我的函数式代码洁癖
大家好,我又来了😁
我得承认,我有个毛病,或者说洁癖吧。
在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️⃣ 顺手写了一点 localStoragelocalStorage.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行了吗?😂
是的,但你发现区别了吗?这个函数,「几乎没有任何逻辑」 ,它只负责「调用」其他小函数。它像一个流程图,清晰得一目了然。
而所有的业务逻辑(validate和createPayload),都被我拆分到了「可独立测试、可复用、可预测」的纯函数里。这,就是这次的重构的价值。
20行代码的标准 不是一个KPI,它是一个「预警」。
它在提醒我们,这个函数的 「负载」 可能已经超标了,它在 「单一职责」 的路上可能已经走偏了。
这种洁癖,不是为了追求代码的短小,而是为了追求代码的「简单」和「可预测」。
在一个由几十万行代码构成的、需要长期维护的系统里,简单和可预测,是比炫技(屎代码💩),要宝贵一百倍😁。