
函数这玩意儿说真的只要你写过代码就绕不开它。你写的每一行可执行语句其实都是站在函数的世界里。但“会写函数”和“真正懂函数是怎么被创建出来的”这中间隔着一条很深的河。我见过不少刚上手的朋友能噼里啪啦敲出一堆功能但你问他function关键字后面到底发生了什么、函数声明和函数表达式差在哪、为什么箭头函数不能做构造函数他就开始眼神游离了。这篇东西我不打算给你念教科书。咱们就唠点实在的——函数创建背后的机制、几种写法的脾气秉性、作用域链是咋连上的、参数传递有哪些暗坑、怎么顺手用闭包而不把自己绕进去。适合谁看刚学编程想把这层窗户纸捅破的初学者也适合写了一阵子代码但总觉得差点意思、想回头补补基础功的开发者。1. 为什么我们非要“创建”函数先掰扯一下本质。你写代码其实是在给计算机下指令。但指令这玩意儿如果你每条都铺开写那代码就是一团乱麻。函数的诞生说白了就俩字封装。把一段逻辑打包成一个带名字的盒子给它定义输入和输出然后你就能反复用、到处用还能像搭积木一样组合出更复杂的逻辑。但你有没有想过一个问题为什么说“创建”函数而不说“写”函数因为在 JavaScript 这门语言里函数本质上是一个对象。它不是一段静态的文本而是在运行时被构造出来的一个实体。这个实体有属性、有方法、有原型甚至还能像普通对象一样被赋值给变量、塞进数组、当作参数传来传去。我打个比方。你买了个智能音箱它预置了“播放音乐”这个技能。这个技能不是凭空出现的而是厂家在生产线上把它“创建”出来再烧录进去的。放到代码世界里我们写下一段函数代码引擎做的工作就是一条“生产线”它读取你的函数声明解析参数列表创建执行上下文建立作用域链最后把这个函数对象“烧录”到内存里返回给你一个可以直接调用的引用。理解了这个你就能明白为更有意思创建函数不仅仅是“写代码”而是在设计一个微型的执行机器。它的内部结构作用域链、它的入口参数、它的出口返回值都需要你精心设计。2. 创建函数的三大流派JavaScript 里创建函数主流的玩法就三种。它们像三个性格迥异的朋友功能上都能干活但脾气和适用场景完全不同。2.1 函数声明最正统的“定义式”创建这是我们最早接触的方式function greet(name) { return Hello, ${name}!; }关键词是function开头后面跟着函数名。这种方式创建的函数有一个非常著名的特性声明提升Hoisting。啥意思就是在代码执行之前引擎会先把整个函数声明读进内存。所以你可以在声明之前就调用它sayGreeting(); // 输出: Hello, undefined! function sayGreeting() { console.log(Hello!); }看到没代码从上往下读sayGreeting()在function sayGreeting之前就执行了但没报错。这是引擎内部处理的“优先读取”机制。当然我可以负责任地说一句虽然这是合法特性但永远不要依赖它来组织代码规规矩矩先声明再用别让项目里出现“我看不懂这代码为什么能跑”的灵异事件。函数声明的另一个身份特征就是必须有名字。没有匿名函数声明这回事——除非你写成function () {}但那叫函数表达式。2.2 函数表达式函数作为“值”的体验函数表达式是把一个匿名函数或者命名函数赋值给变量、对象属性等。看这一个const greet function(name) { return Hello, ${name}!; };这里发生的本质变化是函数从一个“声明”变成了一个“表达式”。它不是被引擎提前注册的而是在程序执行到这一行的时候创建一个函数对象然后把它赋值给greet变量。所以它没有声明提升。如果你在赋值之前调用greet()报错没商量TypeError: greet is not a function。不信你试试那红字特别治愈。另外注意函数表达式还可以带名字叫命名函数表达式Named Function Expressionconst count function counter(n) { if (n 0) return; console.log(n); counter(n - 1); // 在这里可以用 counter 递归 }; count(3); // 3 2 1那名字counter只存在于函数内部作用域外部访问不到。这么声明的好处是便于调试堆栈里能看到函数名以及方便递归调用。但实战中用得少了解即可。2.3 箭头函数语法糖里的“性格差异”ES6 引入了箭头函数因为写法极简现在简直泛滥了const add (a, b) a b; const double x x * 2; // 单个参数可省略括号 const noArg () console.log(hi);和前面两位比起来箭头函数是个“没有自己脾气”的主儿。它没有自己的this、没有arguments对象也不能被用作构造函数用new调用会直接炸。你可以把它理解为一个纯净的计算工具——只根据输入产生输出不绑定调用者的执行环境。但这把双刃剑也埋了雷如果你在对象方法里用箭头函数那this会指向外部作用域不再是这个对象。我用一个简化例子说明const obj { value: 42, getValue: () this.value // 这拿不到 obj.value }; console.log(obj.getValue()); // undefined因为箭头函数里的this是在它被创建时从周围作用域捕获的这里是全局而不是运行时动态绑定的。所以如果你习惯用this访问对象属性箭头函数不是你的菜你要用普通函数表达式。这里没有任何玄学记住一个关键点箭头函数没有自己的执行上下文它用的this是从定义者那儿继承来的。适合做回调、适合做纯函数、适合在函数式编程里飞但不适合当“方法”。3. 函数体、参数与返回值的门道形式定了接下来是“身体”部分——函数体、参数和返回值。这段东西写得好不好直接决定你函数的健壮度和可读性。3.1 参数传递到底是“值”还是“引用”这个坑我已经踩过无数回了。JavaScript 的参数传递机制口诀是基本类型传值对象类型传引用。基本类型number、string、boolean、undefined、null、symbol、bigint就好比你把自己的身份证复印件递给办事员他在复印件上改来改去你手里的原件纹丝不动。function changeNum(num) { num 100; } let n 5; changeNum(n); console.log(n); // 5没变但对象类型数组、函数、普通对象不一样传进去的是指向这块内存地址的指针。函数内部修改这个对象的属性外部看到的也是修改后的结果function changeObj(obj) { obj.name 改过了; } const person { name: 原名字 }; changeObj(person); console.log(person.name); // 改过了但你要小心一个迷惑行为如果在函数内部直接把形参重新赋值成新对象外部引用不受影响function changeObj(obj) { obj { name: 全新对象 }; // 重新赋值形参 } const person { name: 原名字 }; changeObj(person); console.log(person.name); // 原名字外部还是旧的那个这说明了啥形参其实是传入引用的一个本地副本。你可以通过这个副本修改原地址上的数据但把副本指到别的地儿和原对象就没关系了。3.2 默认参数与剩余参数旧时代的写法是function log(message) { message message || 默认消息; console.log(message); }但 ES6 以后有更优雅的默认参数了function log(message 默认消息) { console.log(message); }默认参数有个细节只有你显式传入undefined时才会触发默认值传null不会。这是一个隐蔽的小知识很多人在传null时发现默认值没生效一脸懵。剩余参数...args则是把多余参数收集成一个数组function sum(first, ...rest) { return rest.reduce((acc, cur) acc cur, first); } console.log(sum(1, 2, 3, 4)); // 10注意剩余参数必须放最后一位并且这玩意儿和箭头函数里不存在的arguments是两码事——前者是真正的数组可以直接用数组方法。至于arguments对象它是个类数组结构只能通过下标访问用不了map、filter这些方法得先转一把。这也是我日常强烈建议以剩余参数为准的原因之一。3.3 return 与返回值纪律函数不能没有输出设计。我一直倡导一个观点函数的出口是它的公开接口设计接口比写内部逻辑更重要。保持纯函数习惯很有用——同样的输入必定产生同样的输出不修改外部状态。如果你能让项目里 60% 以上的函数是纯函数你的代码调试和测试体验会上升一个维度。对初学者来说可能觉得矫枉过正但这是后期排查问题省下大量时间的秘诀。4. 作用域链与闭包的创建机制函数对象创建的时候真正值得琢磨的其实是作用域链是怎么搭起来的。引擎每进入一个函数就会创建一个执行上下文而函数对象内部则保存着一个[[Environment]]引用直指创建它的那个环境。这就是作用域链的基石闭包并不是什么神秘巫术不过是函数对象保存了定义时的环境引用罢了。写个经典示例function outer() { let count 0; return function inner() { count; console.log(count); }; } const counter outer(); counter(); // 1 counter(); // 2outer执行完之后按理说局部变量count应该被回收掉。但inner函数仍然持有对外部词法环境的引用于是垃圾回收器就发现“这个环境还被别人拉着呢不能拆”于是count活了下来。这里有个很重要的体验想分享闭包让你可以“偷渡”私有变量避开全局污染但也容易造成内存泄漏。如果你在闭包里挂了大量数据而闭包本身又被一个长期存活的变量引用着那这些数据就永远释放不了。所以别没事就把大数组挂进闭包用完了该置空就置空该解绑就解绑。再看一道常见面试场景function createFunctions() { var funcs []; for (var i 0; i 3; i) { funcs.push(function() { console.log(i); }); } return funcs; } const fs createFunctions(); fs[0](); // 3 fs[1](); // 3 fs[2](); // 3老掉牙的坑。原因就是var声明的i属于函数作用域所有回调函数闭包引用的都是同一个i循环结束之后i已经变成 3 了所以打出来全是 3。搁以前得靠 IIFE 包一层现在直接把var i改成let i就行因为let是块级作用域每次循环都会重新绑定一个新的i。这个小改动背后是作用域粒度的本质差异。5. 函数也是对象一等公民的实践价值为什么把函数称为一等公民因为它能干嘛普通对象就能干嘛。你可以把函数存在变量里把函数作为参数传给别人把函数作为返回值从别人那儿拿给函数对象挂属性这条特性催生了高阶函数HOF的繁荣。map、filter、reduce这些数组方法本质上都是高阶函数const nums [1, 2, 3, 4]; const doubled nums.map(n n * 2); // [2, 4, 6, 8] const evens nums.filter(n n % 2 0); // [2, 4] const total nums.reduce((acc, n) acc n, 0); // 10我还挺爱用高阶函数做函数复合的。比如有个需求先过滤掉偶数再乘三再求和。你拆成三个小函数然后一个个套起来读起来清清楚楚。有些小伙伴喜欢用什么“函数柯里化”把参数拆成一个一个传const multiply a b a * b; const double multiply(2); console.log(double(5)); // 10本质上这利用了闭包把 2 留在内部环境里。什么场景下好用当你需要预配置一些通用参数而且这个配置过程可能会动态变化时柯里化特别香。6. 常见错误与排查心得最后讲讲实践中遇到最多的问题每条都是血泪教训。6.1 函数声明还是表达式结果调用时机错了我的排查经验是先确认创建形式再谈调用时机。函数声明有提升能提前调用函数表达式没有提升写在变量声明之前必然报not defined。看到报错第一反应可以去看它是function开头还是const开头思路就清晰了。有个更隐蔽的坑是条件语句里的函数声明。按老规范各种浏览器解释得五花八门现在严格模式下已明确是块级作用域但为兼容严谨性建议宁可写成函数表达式也别直接压在if里。6.2 this 丢失经典翻车现场是把对象方法拆出来用const obj { name: demo, getName() { return this.name; } }; const fn obj.getName; console.log(fn()); // undefinedthis 丢了fn在全局环境被调用时this不再是obj。我以前解决办法是用bind硬绑定const boundFn obj.getName.bind(obj);或者直接箭头函数 闭包预置const obj { name: demo, getName: () this // 不行this 是全局 };箭头函数解决不了这个场景需要起手就把正确上下文传给一个类或者用普通函数 bind。其实最佳解法往往是当你拆出方法时直接检查this的来源别到调用处再补救。设计阶段就要决定好你持有哪些上下文。6.3 递归调用自身引用报错函数表达式递归时如果用了函数名const counter function(n) { if (n 0) return 0; return counter(n - 1) n; };这样写没问题因为外部变量counter存着引用。但如果你把函数传给别处内部再喊counter就可能拿不着。解决办法是命名函数表达式const counter function f(n) { if (n 0) return 0; return f(n - 1) n; };内部引用f铁稳。虽然不是天天用但遇到状态紧绷的递归时这个小细节能救命。6.4 闭包把变量搞串了前文var i那个例子已经展示闭包捕获的是变量本身不是当前时刻的值。如果你在循环里用闭包批量创建函数记着这件事。用let或者主动给每个函数单独绑定一份参数IIFE 老方案都能解决。还有惯性坑闭包改动的是外层变量如果你本意每次都新开一个局部别粗心又复用同一个外层标记。最后借着这些年调试的经验啰嗦一句遇到函数相关的问题先别急着改代码把函数的创建方式、闭包捕获的对象、调用时的this指向三件事在脑子里过一遍90% 的困惑都能迎刃而解。这比盲目 console.log 高效得多。实践上我还有个习惯——写函数时先问自己这个函数需要访问外部状态吗需要被当参数传来传去吗会保留多久想清楚了再动手代码整个都干净不少。