
写代码写到第三年的时候我才真正意识到一件事大多数人不是卡在语法上而是卡在函数上。变量、循环、条件判断这些东西看两遍就能照猫画虎地写出来可一旦让你把一段逻辑抽象成一个函数、给它起个合适的名字、设计好参数和返回值很多人立刻就手软了。要么把所有东西塞进一个巨大的函数里要么拆得七零八落、参数传来传去像击鼓传花。函数这两个字看着朴素其实是编程从“背语法”到“写工程代码”的分水岭也是我开始认真理解代码结构的地方。这篇内容想聊的就是“函数”这个起点。它会讲清楚函数在整个代码结构里扮演什么角色、一个函数该怎样设计参数和返回值、为什么有的函数用起来顺手有的用一次就想重写以及那些只有真正被线上问题教育过才会记住的细节。不管你是刚学完基础语法、正准备写第一个像样模块的新手还是写了几年代码但一直靠直觉组织函数的老手下面这些内容应该都能对得上号。我尽量少讲教科书上的定义多讲我在实际项目里是怎么判断、怎么取舍、怎么返工的。1. 为什么说函数是真正入门的起点1.1 从“流水账代码”到“可复用单元”的那道坎刚开始写代码的人写出来的东西几乎都是流水账从上到下一行行执行遇到重复逻辑就复制粘贴。这没什么好羞耻的因为这是最符合直觉的写法。问题在于当同一段逻辑出现第三次的时候麻烦就开始了。我第一次被这件事教训是做一个订单金额计算的模块原价、折扣、优惠券、运费、税费五六个因素叠在一起。第一版我在下单页写了一遍第二版在订单详情页又写了一遍第三版在导出报表的地方复制了第二版。后来业务方说折扣规则要改我改了下单页忘了改导出报表结果月底对账对不上排查了大半天才找到原因。那次之后我才明白复制粘贴的成本不是复制的那几秒钟而是未来每一次修改时你需要记住“这段逻辑到底在几个地方出现过”。函数存在的第一个价值就是把这个“记住”的负担交给代码结构本身。你把一段逻辑收进一个函数所有调用方都指向同一个地方改一处就等于改全部。这个道理听起来简单但真正养成习惯需要刻意练习每次你准备按 CtrlC 的时候先停一秒问自己这段逻辑会不会再出现第二次。从流水账到函数本质上是从“描述过程”转向“定义能力”。流水账回答的是“先做什么再做什么”函数回答的是“我这里有一个能力你给我输入我给你输出”。这个视角的切换是后面所有设计讨论的基础。1.2 函数在工程里到底承担了哪几件事很多人对函数的理解停留在“把代码包起来”实际上它在工程里承担的责任要重得多。我把常见的几项列在下面你可以对照自己写的函数看看覆盖了几项。作用具体表现缺失后的后果复用同一逻辑被多处调用复制粘贴修改遗漏抽象调用方不需要知道内部细节业务代码被实现细节淹没隔离变化内部改动不影响调用方改一处崩一片可测试能独立喂输入、验输出只能整体跑问题定位困难命名表达函数名即文档需要靠注释和阅读源码理解这五项里我觉得最容易被低估的是“命名表达”。一个好的函数名比如calculateShippingFee读代码的人一眼就知道这里在算运费不需要点进去看实现。反过来一个叫handleData或者processInfo的函数等于告诉读者“这里有一堆事情具体是什么你自己猜”。命名这件事后面还会展开讲先记住一个判断标准如果函数名里出现了 Data、Info、Handle、Process 这类词多半说明你还没想清楚它到底在干什么。1.3 一个反例把所有逻辑塞进一个函数是什么体验我见过也写过不少“巨无霸函数”。典型场景是用户注册校验手机号、检查是否重复、生成初始信息、写数据库、发欢迎邮件、记操作日志全部塞在一个register函数里一百多行。写的时候很顺因为思路是线性的维护的时候就难受了。比如产品说欢迎邮件的文案要按渠道区分你打开这个函数发现邮件相关代码在第八十行前后都是数据库操作改的时候得小心翼翼地跳过一堆不相关的东西。更麻烦的是想给“校验手机号”单独写测试根本没法测因为它和发邮件、写库缠在一起。合理的拆法其实不复杂validatePhone、isUserExists、createUser、sendWelcomeMail、logRegister每个函数只负责一件事register函数变成几行调用。这样改邮件文案只动sendWelcomeMail测校验逻辑只测validatePhone。拆分的收益不是代码变短了而是每一块的修改理由变得单一了。2. 函数设计的核心取舍2.1 单一职责判断标准不是行数而是“变化的原因”“一个函数只做一件事”这句话被说烂了但很多人判断“一件事”的标准是行数超过五十行就拆。这个标准不太靠谱因为有些函数天生就是流程编排二十行调用五个子函数逻辑清晰有些函数只有十五行却在同时处理参数解析、业务计算和结果格式化问题很大。我的判断标准是“变化的原因”。你问自己未来可能让我修改这个函数的理由有几个如果只有一种比如“折扣规则变了”那它职责是单一的如果有好几种比如“折扣规则变了”“接口字段改了”“日志格式调整了”那就该拆。这个标准的依据很实际每一次修改都是一次引入 bug 的机会把不同原因的修改隔离开就降低了一次改动波及无关逻辑的概率。还有一个更直观的自测方法给函数写注释的时候如果你需要用“并且”“同时”“以及”来连接描述基本可以确定它承担了多个职责。比如“这个函数校验参数并且写库并且发通知”三个“并且”拆三段。2.2 参数设计位置参数、对象参数、可选参数怎么选参数设计是最容易被随手对待、又最容易在后期反咬一口的地方。常见的三种风格各有适用场景我整理了一张对照表。风格示例适合场景主要风险位置参数createUser(name, age, city)参数少2 到 3 个、顺序语义天然明确参数一多就记不住顺序对象参数createUser({ name, age, city })参数多、有可选项、未来可能扩展调用处看不出哪些必填可变参数sum(...numbers)参数个数不定、同质容易误传数组导致结果异常我的经验是参数超过三个或者存在可选参数优先用对象参数。原因很实在——位置参数一旦超过三个调用处就变成了createUser(张三, 28, 北京, true, false)读代码的人必须回头查函数定义才知道那个true是什么意思。对象参数至少能把字段名写在调用处可读性直接上一个台阶。不过对象参数也有个坑它天然让“必填”和“可选”变得模糊。我的做法是在函数开头做一次显式校验缺必填项就直接抛错别让它悄悄带着 undefined 往下跑。这个习惯能省掉大量“为什么数据库里存了个 undefined”的排查时间。2.3 返回值与副作用什么时候该“只进不出”关于副作用我的观点比较直接能不产生副作用就不产生。一个函数如果只依赖入参、只通过返回值输出结果那它是可预测的测起来也简单——给一组输入比对输出就行。一旦它开始修改外部变量、写文件、发请求测试就得搭环境、造数据、清理现场成本翻几倍。但现实里不可能全是纯函数总要有地方去写库、发消息。我的处理方式是分层核心计算逻辑写成纯函数副作用集中放在外层。举个例子计算订单最终价格这件事折扣、税费、运费全是纯计算抽成calcFinalPrice(order)返回一个数字真正落库和发通知放在调用它的业务流程里。这样做的好处是折扣规则以后再怎么变测试只需要覆盖那个纯函数不用管数据库。还有一个细节值得说返回值的形态要稳定。有的函数成功时返回对象、失败时返回 null、异常时抛错调用方得写三套判断。我倾向于统一成两种要么正常返回结果、异常直接抛要么统一返回{ ok, data, error }这种结构。选哪种看团队习惯关键是同一个项目里别混着来。3. 实操从零打磨一个能上线的函数3.1 先写函数签名再写函数体我现在写函数有个固定习惯先不写实现先把签名和注释写出来。签名包括函数名、参数、返回值类型。这一步看起来多余实际上能提前暴露很多问题。比如你要写一个分页计算函数签名大概是paginate(total, pageSize, currentPage)返回{ offset, limit, totalPages }。写签名的时候你就会发现几个必须想清楚的点pageSize有没有上限currentPage超出范围怎么办total为 0 时返回什么这些问题的答案如果在动手写实现之前就定下来代码会干净很多如果边写边想很容易写成“这个分支返回这个、那个分支返回那个”的混乱结构。我踩过的坑是一个函数写到一半发现参数不够用又在末尾加了一个可选参数结果所有老调用处都得检查一遍。先定签名能大幅减少这种返工。命名上我有个小习惯函数名以动词开头get、calc、build、is、has、format一眼能看出它是取数据、做计算、构造对象、做判断还是做格式化。这个习惯坚持下来代码库的整体可读性会明显不同。3.2 边界与异常参数校验这一步不能省参数校验是很多人觉得“啰嗦”而跳过的一步也是线上问题的高发区。我的原则是对外暴露的函数入口处必须校验内部私有函数可以适度放宽但关键参数还是要防一手。分页这个例子就很典型。假设pageSize传了 0 或者负数limit就会变成 0 或者负值SQL 查出来是空或者报错currentPage传了超出总页数的值返回空列表前端可能显示“暂无数据”用户以为真的没数据。这些都不是崩溃级问题但排查起来很烦因为现象和原因离得远。解决办法就是在函数入口把这些情况全部兜住total小于等于 0 时直接返回空结果不进入后续计算pageSize限制在 1 到 100 之间超出就取边界值currentPage小于 1 时按 1 处理大于总页数时按最后一页处理这里的取舍要说清楚为什么不直接抛错而是取边界值因为分页这种场景用户手动改 URL 参数很容易传越界值抛错会让页面直接白屏取边界值则是温和降级。但如果参数错误代表调用方代码有 bug比如金额计算传了负数那就该抛错让问题在开发阶段暴露。判断依据是这个错误是用户输入导致的还是程序逻辑导致的。3.3 完整实现与逐段讲解下面这个分页计算函数是我现在项目里用的版本去掉业务定制后大概长这样function paginate(total, pageSize 10, currentPage 1) { // 1. 统一转成数字防止字符串参数混进来 const safeTotal Math.max(0, Math.floor(Number(total) || 0)); const safeSize Math.min(100, Math.max(1, Math.floor(Number(pageSize) || 10))); // 2. 总数为 0 时直接返回空结果避免后续除零 if (safeTotal 0) { return { offset: 0, limit: safeSize, totalPages: 0, currentPage: 1 }; } // 3. 计算总页数并夹取当前页 const totalPages Math.ceil(safeTotal / safeSize); const safePage Math.min(totalPages, Math.max(1, Math.floor(Number(currentPage) || 1))); // 4. 换算成数据库需要的 offset 和 limit return { offset: (safePage - 1) * safeSize, limit: safeSize, totalPages, currentPage: safePage, }; }逐段说一下为什么这么写。第一段做类型归一Number(total) || 0是为了把 undefined、null、空字符串、NaN 都归到 0Math.floor是防止小数页码虽然理论上不该出现但接口传参这事你永远不能太信任。第二段单独处理总数为 0是因为Math.ceil(0 / 10)虽然等于 0但后面Math.min(0, ...)会让当前页变成 0再算 offset 就成负数了。第三段的Math.min和Math.max顺序不能反先夹下界再夹上界否则当前页传 999 会先被夹成 999 再被夹到总页数结果是对的但逻辑读起来别扭。这类函数的价值在于它的行为是封闭的任何输入组合你都能在纸上算出输出不会因为外部状态不同而不同。这就是我前面说的“把核心计算写成纯函数”的具体样子。3.4 用测试用例把函数钉死写完函数我一般会立刻补一组用例尤其是边界值。分页函数的用例表大概是这样输入期望输出验证点total0totalPages0offset0空数据不崩total1pageSize10totalPages1向上取整正确total100pageSize10page10offset90末页偏移正确total100pageSize10page99currentPage10超界夹取到末页pageSize0limit1下界保护pageSize999limit100上界保护参数传字符串2正常解析类型容错这张表看着简单但每一条都对应一个我曾经遇到过或者差点遇到的问题。写用例的过程其实是在逼自己想清楚“这个函数到底承诺了什么”。一个函数如果没有明确的契约调用方就会各自脑补脑补的结果就是线上各种奇怪的边界 bug。4. 作用域、闭包与高阶函数4.1 作用域链变量到底在哪里被找到函数一旦和变量扯上关系问题就变得微妙起来。理解作用域链是绕不开的一步。简单说函数内部访问一个变量时会先在函数自己的作用域里找找不到就去外层函数找再找不到就去更外层一直到全局。这条“由内向外”的查找路径就是作用域链。这件事的实际影响在于“变量遮蔽”。外层有个count内层函数又声明了一个count内层的会完全覆盖外层你在内层无论怎么改都影响不到外层。我见过一个 bug 就是这样来的一个统计函数里写了let count 0而外层已经有count作者以为在累加总数实际上每次调用都从 0 开始统计数据永远是 1。另外就是var和let的区别。var是函数作用域let是块级作用域。在循环里用var声明变量并配合异步回调是经典的事故现场所有回调拿到的都是循环结束后的最终值。改用let之后每次迭代都会创建新的绑定问题自然消失。这个知识点网上讲烂了但我还是建议你亲手写一遍对比一下光看是记不住的。4.2 闭包好用但要知道它把什么留住了闭包这个概念被讲得很玄其实一句话就能说清函数记住了它被定义时所在的作用域即使外层函数已经执行完了内层函数依然能访问那些变量。最典型的用途是做计数器或者缓存function createCounter() { let count 0; return { inc() { count 1; return count; }, get() { return count; }, }; }count在外层函数执行完后本该被回收但因为返回的对象里两个方法都引用了它所以它被保留下来了。这就是闭包的本质它让变量有了比函数调用更长的生命周期。好用归好用闭包也是内存问题的常见来源。因为你很难从代码表面看出“这个变量还被谁引用着”。我遇到过的典型场景是事件监听给 DOM 元素绑了一个回调回调里引用了很大的数据结构元素被移除后监听没解绑数据就一直留着。解决办法是在组件销毁或者元素移除时显式解绑别指望自动回收。还有一种是定时器里的闭包setInterval没清掉回调里的变量就跟着一直活着。判断标准很简单凡是“注册”了什么东西就要想清楚在哪里“注销”。4.3 高阶函数把函数当参数传高阶函数是指接收函数作为参数、或者返回函数的函数。这个概念听起来学术实际上你每天都在用数组的map、filter、reduce都是高阶函数。它的价值在于把“做什么”和“怎么遍历”分开你只关心业务逻辑遍历细节交给框架。在实际项目里高阶函数最实用的场景是抽公共逻辑。比如防抖和节流如果不用高阶函数你会在每个需要的地方复制一遍定时器代码用高阶函数包一层调用处只写业务函数function debounce(fn, delay 300) { let timer null; return function (...args) { clearTimeout(timer); timer setTimeout(() fn.apply(this, args), delay); }; }这段代码有两个细节值得注意。一是...args把参数原样透传否则业务函数拿不到事件对象二是fn.apply(this, args)里的this如果不处理业务函数里的this会指向丢失这在类方法里绑定事件时特别容易出问题。很多人抄了防抖代码却不知道为什么点击事件里的this是 undefined原因就在这里。高阶函数的另一个常见用途是“包装”。比如给一批接口函数统一加上日志、重试或者错误处理与其一个个改不如写一个withRetry(fn)把它们包起来。这种写法在保持原函数不变的前提下增加能力改动面小回滚也方便。5. 常见问题与排查技巧实录5.1 函数相关高频问题速查表函数相关的问题有个特点现象往往和原因隔得很远。我把这些年遇到的高频问题整理成表排查的时候可以对着看。现象常见原因排查方向函数返回 undefined忘记写 return或 return 在分支里检查所有分支是否有返回参数默认值不生效显式传了 undefined 之外的空值确认传参是 undefined 还是 null、空串方法里的 this 是 undefined函数被单独取出调用丢失了调用上下文用箭头函数或显式绑定循环里的回调拿到同一个值用 var 声明循环变量改用 let递归报栈溢出缺少终止条件或终止条件写错打印每层参数看是否收敛内存持续增长闭包持有大对象监听或定时器未清理检查注册与注销是否成对同一函数两次调用结果不同函数内部依赖了外部可变状态找出被修改的外部变量这张表里我觉得最值得多说一句的是“同一函数两次调用结果不同”。这类问题最隐蔽因为函数看起来没变变的其实是它依赖的外部状态。排查方法是在函数入口把关键外部变量打印出来对比两次调用的差异。如果差异存在那就说明这个函数不该依赖外部状态应该把它改成纯函数把状态作为参数传进去。5.2 几个印象深刻的踩坑记录第一个坑是参数默认值。有一次写了个函数formatPrice(price, currency CNY)测试的时候发现传了空字符串进去currency 变成空串而不是默认值。原因是默认值只在参数是undefined时生效空字符串是有效值。后来改成在函数内部判断currency || CNY问题解决。这件事让我记住默认值语法不是万能的兜底它只兜 undefined。第二个坑是递归。写一个树形结构的遍历本地测试数据只有三层跑得好好的上线后有个用户的分类树有几千层直接栈溢出。后来改成用栈模拟递归也就是迭代写法问题解决。这件事的教训不是“别用递归”而是“递归深度不可控的时候要用迭代”。判断依据是数据深度是否由外部决定如果是就别赌。第三个坑和闭包有关。一个页面里有个定时刷新setInterval里引用了页面的大对象页面关闭后定时器没清内存一直涨。排查的时候用开发者工具的内存快照对比发现对象数量只增不减最后定位到定时器。修复方式是在页面销毁的生命周期里clearInterval并且把定时器 ID 存到能访问到的地方。这个坑之后我养成了一个习惯凡是写setInterval下一行就把清理逻辑想好写在旁边。第四个坑是返回值形态不一致。一个查询函数查到返回对象查不到返回 null出错抛异常。调用方有的判断了 null有的没判断结果某天一个没判断的地方读了 null 的属性页面崩了。后来统一改成查不到返回空对象或者空数组调用方只需要处理一种形态。这个改动不大但消除了一整类问题。5.3 几个上不了台面但很管用的习惯有些习惯不适合写进规范但确实好用我分享几个。第一个是“函数写完先自己调用三次”一次正常输入、一次边界输入、一次异常输入很多时候问题在这三步里就暴露了。第二个是“改函数之前先搜调用方”尤其是改参数和返回值的时候搜一遍能避免大部分连带故障。第三个是“给函数起名的时候念出来”如果念着别扭比如“处理一下这个数据”那说明你还没想清楚它在干什么。还有一个习惯我觉得收益最大把长函数里的每一段用一句话注释标出来然后看这些句子能不能直接变成函数名。如果能就拆如果不能说明这段逻辑本身还没想清楚先想清楚再动手。这个方法我用过很多次几乎每次都能发现一两个藏得很深的职责混杂。6. 写在不同阶段的一些体会我自己对函数的理解大致分了三个阶段。第一阶段是“能跑就行”把所有逻辑堆在 main 里能出结果就收工。第二阶段是“知道要拆”看到长函数就难受但经常拆过头把三行代码也包成一个函数结果调用链长得像迷宫读代码要跳十几个地方。第三阶段是“知道什么时候不拆”开始用“变化的原因”和“是否值得单独命名”来判断能用一行说清的逻辑就不包函数让代码保持扁平。这个演进过程没什么捷径就是写、出错、返工、再写。我到现在也不敢说自己写的每个函数都合理但我至少知道不合理的时候问题出在哪要么是职责没想清要么是参数设计偷懒要么是把副作用混进了计算逻辑。这三件事想明白了函数这部分基本就过关了。如果你现在正卡在“不知道怎么组织代码”的阶段我的建议是先别追求优雅先把重复逻辑收进函数让代码能改一处生效多处等你对这一层熟练了再去琢磨参数怎么设计、副作用怎么隔离。函数是开始的地方但它不是一次学完的东西你每写一年代码对它的理解都会不一样。