资讯详情

从sum(1)(2)到柯里化:闭包与高阶函数实战解析

📅 2026/10/29 22:12:04 | 华诺云谱 👁 阅读
从sum(1)(2)到柯里化:闭包与高阶函数实战解析
面试里被问到闭包很多人能背出函数内部返回函数内部函数持有外部变量这句标准答案但一旦面试官说那你写个sum(1)(2)出来看看手就开始抖了。这个标题看着像一道入门练习题实际上它把闭包、柯里化、函数作为一等公民、arguments与剩余参数、this绑定这几件事全串在了一起是检验一个人对 JavaScript 函数机制理解深度的试金石。我见过不少写了三四年业务代码的人能熟练用各种框架却在这个双括号调用上卡壳原因不是不会写而是没想清楚第一次调用之后到底发生了什么。这篇内容就是围绕sum(a)(b)这个具体场景展开的。它适合两类人一类是正在准备技术面试、想把闭包从能背变成能写能讲的开发者另一类是日常写业务时对高阶函数、函数工厂这些概念一直似懂非懂想找个最小切口彻底搞明白的人。我会从最朴素的实现讲起一路推到支持任意参数、支持链式累加、支持占位符的进阶版本中间把每一步为什么这么设计讲透也会把我在实际调试中踩过的坑原样摆出来。读完你不仅能默写出这段代码更能对着白板把执行过程一步步讲给别人听。1. 双括号调用到底在考什么从一次真实卡壳说起先说个我自己的经历。早年一次技术交流对方让我实现一个sum要求sum(1)(2)返回 3sum(1)(2)(3)也能返回 6。我当时第一反应是写了个普通函数function sum(a, b) { return a b }然后发现sum(1)(2)直接报错sum(...) is not a function。这个报错信息其实已经把答案点破了——sum(1)的返回值被当成了函数去调用而它返回的是一个数字数字当然不能被调用。问题瞬间清晰要让第二个括号成立第一次调用的返回值必须是一个函数。这就是双括号调用的本质。sum(1)(2)在 JavaScript 引擎眼里是两步先执行sum(1)拿到一个结果再对这个结果执行(2)也就是把它当函数调用。所以sum的返回值类型决定了整个表达式能不能成立。想通这一点闭包就自然登场了——第一次调用时传入的1必须被记住等到第二次调用时再拿出来和2相加而能跨越两次调用记住变量的机制正是闭包。很多人对闭包的理解停留在内部函数访问外部变量但在这个场景里闭包的作用更具体它是两次独立函数调用之间的状态载体。第一次调用结束时按常理它的局部变量应该被回收但因为返回的那个内部函数还引用着a垃圾回收器不敢动它于是a活了下来等第二次调用时被读取。这个变量生命周期被延长的视角比单纯背定义有用得多因为它直接解释了为什么双括号能成立。从语言特性角度看这道题同时踩中了三个点。第一是函数是一等公民函数可以作为返回值传递这是双括号的语法基础。第二是闭包捕获第一次的参数被内部函数捕获。第三是调用链的连续性每次调用都要返回一个还能继续被调用的东西。理解了这三点你就能明白为什么这道题被反复用作面试题——它小但五脏俱全。还有一个容易被忽略的细节sum(1)(2)里的两个括号在语法解析上是左结合的等价于(sum(1))(2)。这意味着如果sum(1)返回的不是函数整个表达式在解析阶段没问题但运行到第二次调用时必然抛TypeError。这个错误类型很关键它和变量未定义的ReferenceError是两码事排查时看到is not a function第一反应就该是某个本该返回函数的地方返回了别的类型。2. 最朴素的闭包实现三行代码背后的执行时序先把最小可用版本写出来别急着上花活。针对sum(a)(b)这个固定两参数场景代码是这样的function sum(a) { return function (b) { return a b; }; } console.log(sum(1)(2)); // 3就这三行但值得逐帧拆解。调用sum(1)时引擎创建一个执行上下文形参a被赋值为1然后函数体执行到return创建了一个匿名函数对象。关键在于这个匿名函数的作用域链里包含了sum的活动对象所以它能访问a。sum(1)返回这个匿名函数后sum的执行上下文出栈但活动对象因为被匿名函数引用而没有被销毁。接着(2)触发匿名函数执行形参b为2函数体里a b去作用域链上找a找到1返回3。这里有个新手常犯的错把内部函数写成箭头函数时以为行为一样其实在this相关的场景里会有差异不过在这个纯数值求和的例子里箭头函数和普通函数结果一致。真正需要注意的是不要在外层函数里用var声明一个会被后续修改的变量然后指望它保持调用时的值那是经典的循环闭包陷阱虽然和本题无关但同属闭包理解范畴后面会单独说。再进一步如果需求变成sum(1)(2)(3)固定两层的写法就不够了。最直接的思路是让返回值既能当数字用又能继续被调用但 JavaScript 里一个值不可能同时是数字和函数。所以纯函数式写法通常约定一个终止条件比如不传参数时求值function sum(a) { return function (b) { if (b undefined) return a; return sum(a b); }; } console.log(sum(1)(2)(3)()); // 6这个版本用递归把累加状态通过参数a一路传递下去每次调用返回一个新的sum包装函数直到传入undefined触发终止。注意这里每次sum(a b)都是一次全新的闭包创建a的值在每一层都被固化互不干扰。这种用参数携带状态的写法比在外层维护一个可变变量更安全因为它天然避免了多次调用之间的状态污染——你可以同时持有const s1 sum(1)和const s2 sum(10)它们各自独立不会互相影响。提示递归版本里判断终止条件用b undefined而不是!b因为sum(1)(0)是合法调用0是假值用!b会误判为终止这是个很隐蔽的 bug。3. 支持任意参数与链式累加把玩具代码变成能用的工具固定两参数和递归终止两个版本都偏教学。真实场景里我们往往需要sum(1, 2)(3)(4, 5)这种混合形式也就是每次调用可以传任意个参数并且能一直链下去。这时候就得请出剩余参数和reduce。function sum(...args) { const total args.reduce((acc, cur) acc cur, 0); const fn (...nextArgs) sum(total nextArgs.reduce((acc, cur) acc cur, 0)); fn.valueOf () total; fn.toString () String(total); return fn; } console.log(sum(1, 2)(3)(4, 5)); // 输出函数但 valueOf 为 15 console.log(sum(1, 2)(3)(4, 5)); // 15 console.log(${sum(1, 2)(3)(4, 5)}); // 15这个版本信息量很大逐块讲。sum(...args)用剩余参数收集本次调用的所有实参reduce求和得到total。然后构造一个fn它接收下一批参数递归调用sum并把total累加进去。到这里sum(1)(2)(3)返回的始终是函数链式调用没问题但如果你直接console.log它会看到一个函数而不是数字因为返回值本质是函数。解决办法是重写valueOf和toString。JavaScript 在做隐式类型转换时比如fn、fn * 2、模板字符串插值会优先调用valueOf拿不到原始值再调toString。我们把valueOf指向total于是sum(1)(2)就能得到3。这个技巧在需要既像函数又像值的场景里非常实用比如某些链式查询构造器、单位换算库都用过类似手法。但要注意valueOf返回的必须是原始值返回对象会被忽略并继续走toString。这里有个我实际踩过的坑早期我用fn.valueOf () total时total是在sum调用时算好的常量没问题但后来我改成在外层维护一个可变累加器let acc 0每次调用acc ...结果发现同时持有两个链式对象时它们共享了同一个acc数据串了。原因就是闭包捕获的是变量引用而非值两个链共享同一个acc。改回每次递归传新参数的纯函数写法后问题消失。这个教训说明闭包里捕获可变变量要格外小心能用参数传递状态就别用外部可变变量。版本支持参数形式终止方式返回值类型适用场景固定两层sum(a)(b)无数字教学演示递归终止sum(a)(b)(c)()空调用数字不定长求和任意参数链式sum(a,b)(c)(d,e)隐式转换函数可转数字链式 API 构造表格里第三种的终止方式写隐式转换意思是它不需要显式空调用靠valueOf在需要时求值。这种设计的好处是调用方写起来自然坏处是调试时console.log看到的是函数容易让人误以为没算出来得用或模板字符串才能看到数值。我在团队里推这种写法时都会在注释里明确写一句本函数返回可转换函数取值请用或String()避免后来人踩坑。4. 占位符与偏函数应用双括号思路的延伸玩法双括号的本质是分步传参顺着这个思路可以玩出偏函数应用partial application。假设有个通用工具curry能把任意多元函数变成可以分多次传参的形式function curry(fn) { return function curried(...args) { if (args.length fn.length) { return fn.apply(this, args); } return function (...nextArgs) { return curried.apply(this, args.concat(nextArgs)); }; }; } const add3 (a, b, c) a b c; const curriedAdd curry(add3); console.log(curriedAdd(1)(2)(3)); // 6 console.log(curriedAdd(1, 2)(3)); // 6 console.log(curriedAdd(1)(2, 3)); // 6curry的核心是拿fn.length形参个数当阈值参数攒够了就执行原函数不够就返回一个继续收集参数的函数。这里args.concat(nextArgs)每次生成新数组避免了共享可变数组的问题和前面说的用参数传状态是同一个思路。fn.length有个坑如果原函数用了剩余参数或默认参数length会失真比如(a, b 1, c) {}的length是 1 而不是 3这时候curry的阈值判断就错了。所以curry只适合形参明确、无默认值的函数用之前得确认。再往深一层是占位符。有些库支持curriedAdd(1)(_)(3)(2)这种_是占位符表示这个位置先空着后面补。实现思路是在收集参数时遇到占位符就跳过等后续参数来填。这个功能在参数顺序不方便调整时很有用但实现复杂度陡增日常业务里我一般不建议手写除非确实有大量偏函数调用需求。真要实现核心是维护一个已收集参数数组和占位符索引集合每次新参数进来先填占位符位置填满了再按顺序补。从sum(a)(b)到curry再到占位符是一条很自然的进阶线。它们的共同点是把一次调用拆成多次用闭包在调用之间传递状态。理解了这条主线你会发现很多看似不同的工具——函数工厂、装饰器、中间件、链式查询构造器——底层都是同一套机制的不同包装。我在带新人时通常让他们先把sum(a)(b)手写到不看资料再让他们自己实现一个简易curry两步走完闭包这块基本就通了。5. 调试与踩坑实录那些报错信息背后的真相理论讲完说说实战里真正会卡住人的地方。第一个高频坑是sum is not a function和sum(...) is not a function分不清。前者通常是sum本身没定义或拼错后者是sum(...)的返回值不是函数。看到后者直接去检查sum的return语句八成是某条分支返回了数字或undefined。我调试时习惯在return前加一行console.log(typeof result)一眼就能定位。第二个坑是箭头函数和arguments。有人图省事在内部函数里用arguments收集参数结果发现箭头函数没有自己的arguments它拿到的是外层函数的。比如function sum(a) { return (b) { console.log(arguments); // 这里是 sum 的 arguments不是箭头函数的 return a b; }; }箭头函数里访问arguments会沿作用域链往上找找到sum的arguments值可能是[1]而不是[2]逻辑直接错乱。正确做法是用剩余参数(...args)它不受箭头函数限制语义也清晰。这个坑我在 code review 里见过不止一次尤其是从普通函数改写成箭头函数时容易遗留。第三个坑是this丢失。如果sum是某个对象的方法内部返回的普通函数在被单独调用时this会变成undefined严格模式或全局对象导致依赖this的逻辑崩掉。解决办法要么用箭头函数继承外层this要么在返回前bind。不过纯数值求和的场景用不到this这里提一句是提醒闭包和this是两套独立机制别混为一谈闭包管变量作用域this管调用上下文很多混淆都源于把两者搅在一起。第四个坑是性能。有人担心每次调用都创建新函数对象会不会很慢。实测下来现代引擎对这种短生命周期的小函数优化得很好几万次链式调用不会有明显问题。真正要警惕的是在循环里创建大量闭包且长期持有那才会造成内存压力。判断标准很简单闭包捕获的变量如果很大比如大数组、DOM 引用且闭包本身被长期保存就要考虑是否必要如果只是捕获几个数字放心用。注意用valueOf做隐式转换时如果对象同时参与比较行为可能反直觉。比如sum(1)(2) 3为true但sum(1)(2) 3为false因为严格相等不做类型转换。写库给别人用时文档里必须写清楚取值方式别让调用方猜。6. 从这道题往外看闭包在真实代码里的落点把sum(a)(b)写熟之后你会发现闭包在业务代码里到处都是只是平时没往这个方向想。举几个我实际用过的场景。第一个是防抖和节流它们内部用闭包保存定时器 ID 或上次执行时间跨多次事件触发维持状态本质和sum记住第一次参数是一回事。第二个是模块模式用一个立即执行函数包住私有变量只暴露有限接口闭包让私有变量外部访问不到这是早期没有模块系统时的标准做法现在虽然有了 ES Module但理解它有助于看懂老代码。第三个是函数工厂比如根据配置生成不同行为的校验函数function createValidator(min, max) { return function (value) { return value min value max; }; } const isAdultAge createValidator(18, 120); console.log(isAdultAge(25)); // true console.log(isAdultAge(10)); // falsecreateValidator(18, 120)返回的校验函数捕获了min和max之后每次调用都不用再传边界这就是双括号思路的实用化——第一次调用配置参数后续调用传业务数据。这种模式在表单校验、权限判断、单位换算里非常常见写起来比每次传一堆配置清爽得多。第四个是缓存memoize用闭包保存计算结果function memoize(fn) { const cache new Map(); return function (...args) { const key JSON.stringify(args); if (cache.has(key)) return cache.get(key); const result fn.apply(this, args); cache.set(key, result); return result; }; }这里的cache被返回的函数长期持有跨调用复用。注意JSON.stringify做 key 有局限参数含函数、undefined、循环引用时会出问题生产环境通常用更稳的序列化或直接限定参数类型。这个例子也说明闭包不只是记住变量还能承载缓存、计数器、状态机这类有状态逻辑是函数式编程和面向对象之间的一座桥。回头看sum(a)(b)这道题的价值不在于它本身多有用而在于它是一个最小可验证的闭包模型。你能把它讲清楚就能把防抖、柯里化、模块模式这些更大的话题讲清楚因为它们共享同一套底层机制。我自己现在的习惯是遇到任何跨调用保持状态的需求先在纸上画一下哪次调用创建闭包、闭包捕获了什么、生命周期到哪结束画完再写代码比直接上手敲要快得多也少踩很多状态串味的坑。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑