1. 函数入门——为什么说函数是JavaScript的骨架1.1 函数在JS里的真实地位大概没有任何一个JavaScript入门教程能绕开函数这一关。我刚接触前端那会儿总觉得函数就是个“把代码包起来”的容器直到一次重构让我彻底改观一个本来需要滚动三屏的页面脚本拆成五个小函数之后不仅bug少了同事也终于愿意看我写的代码了。JavaScript里的函数远不止“封装代码块”这么简单——它是一等公民可以像变量一样赋值、传递、返回是模块化、异步处理和函数式编程的地基。所谓一等公民指的是函数和字符串、数字没有本质区别能塞进数组、当作参数传给另一个函数、甚至从一个函数里再返回一个新函数。这个特性在部分传统语言里并不存在却是JavaScript最灵活也最容易被误用的地方。同样是处理一段逻辑你既可以用普通函数声明也可以用箭头函数还能通过闭包把状态“包”在函数内部这种自由度放在整个语言体系里都相当稀有。执行机制也值得一提。浏览器解析到函数声明时并不会马上执行函数体而是先把函数登记成一个可识别的对象真正执行发生在调用那一刻此刻函数会创建自己的执行上下文、局部变量、参数和作用域链。这种“先登记、后执行”的设计让代码组织和运行时行为出现了很多微妙差异也直接引出了提升、闭包、this绑定这些让初学者头疼的概念。1.2 不写函数的真实痛点我接手过一个线上项目脚本单文件将近八百行全部顺序执行中间只有几处if...else分支。表面看逻辑没错可每次改需求都像拆炸弹改一个变量名可能另外两个地方也要跟着变想加一个导出功能只能找到对应区块复制代码再改。这种代码的本质就是把“函数本该承担的结构化职责”全压在了人的记忆力上。后来做重构把八百行拆成十几个函数每个函数只做一件事通过调用关系串联起来。参数显式传入返回值清晰可见测试也变得简单——直接给函数传一组数据看返回结果对不对根本不需要跑完整套页面交互。这就是函数最朴素的价值它把人从记忆的负担里解放出来让你把注意力集中在“这个模块要完成什么”而不是“整体流程绕到哪里了”。2. 三种声明方式怎么选函数声明、表达式与箭头函数2.1 语法、提升与执行时机的差异JavaScript里定义函数的主流姿势有三种看起来差别不大细节却足以决定bug的去向。// 函数声明 function add(a, b) { return a b; } // 函数表达式 const add function(a, b) { return a b; }; // 箭头函数 const add (a, b) a b;函数声明存在完整的提升机制。引擎在执行任何代码之前就把整个函数登记到当前作用域所以你把调用写在定义之前也能正常运行。这种特性在规划代码顺序时很舒服但也会造成反直觉的隐患如果同一个作用域里出现两个同名函数声明后者会覆盖前者而且覆盖发生在代码执行之前排查问题时容易一头雾水。函数表达式则只有变量声明被提升赋值不提升。对比下面两段代码console.log(sayHi()); // 能运行输出 hi function sayHi() { return hi; }console.log(sayHello()); // TypeError: sayHello is not a function const sayHello function() { return hello; };第二种写法里const sayHello被提升但绑定的是undefined所以调用失败。箭头函数和普通函数表达式在这个行为上保持一致都要求“先赋值再调用”。理解了提升你就能解释很多“为什么在定义前调用却报错”的怪问题。我在实际团队里会统一建议优先用const声明函数表达式让代码的阅读顺序和执行顺序保持一致少依赖提升机制带来的副作用。2.2 箭头函数this与arguments的特殊规则箭头函数最容易被误解的地方是this。普通函数的this是动态的谁调用就指向谁箭头函数没有自己的this它捕获的是定义时所在作用域的this。这个区别在处理事件回调和异步逻辑时特别关键。举个例子给一个按钮绑定点击事件里面用setTimeout延迟输出thisconst button document.querySelector(#btn); button.addEventListener(click, function() { setTimeout(function() { console.log(this); // 普通函数里this指向全局或undefined }, 1000); });最外层function的this是button但setTimeout里的普通函数this已经不在button上。传统解决方法是var self this或者bind一下。用箭头函数重构后button.addEventListener(click, function() { setTimeout(() { console.log(this); // this仍是button }, 1000); });因为箭头函数在定义位置捕获了外层function的this问题自然消失。同理箭头函数也没有自己的arguments对象。如果你必须在箭头函数里取参数用剩余参数语法即可(...args) 。这些差异在后端写Node.js业务逻辑、前端写事件处理时都会高频出现建议直接写几遍记住肌肉记忆。2.3 一份可收藏的对比表三种声明方式的差异可以用一张表收拢方便平时查询维度函数声明函数表达式箭头函数提升完整提升变量提升赋值不提升同函数表达式this动态绑定动态绑定词法绑定arguments有有无能否被new能能不能适用场景顶层工具函数模块内部封装回调、简短逻辑表格里的“能否被new”经常被忽略。箭头函数不能被new调用因为它没有自己的this也无法作为构造函数生成实例。如果你在代码里试图new一个箭头函数会直接抛出TypeError。普通函数表达式和函数声明都可以被new这也是类与继承在JavaScript里基于函数原型工作的前提。3. 作用域、闭包与this绑定理解执行背后的规则3.1 作用域链的工作原理函数执行时JavaScript会沿着作用域链寻找变量。所谓作用域链可以理解成一串嵌套的盒子当前函数自己的作用域在最里面向外一层是它定义时所在的作用域再往外是全局作用域。查找变量时从内到外找到就停下全都找不到就报ReferenceError。有个很经典的例子const name 全局; function outer() { const name 外部; function inner() { console.log(name); } inner(); } outer(); // 输出外部inner函数内部没有name于是沿着作用域链去outer里找到了“外部”。注意一个重要结论作用域是在函数定义时确定的不是在调用时。这意味着函数能访问哪些变量取决于它写在哪里而不是它被谁调用。实际项目里很多作用域困惑都源于“以为函数能访问调用方的变量”。比如你写了一个公共工具函数放在a模块里被b模块调用b模块里定义了一个同名变量工具函数并不会因此访问到它。理解了作用域链再去理解闭包思路会顺畅很多。3.2 闭包、内存与私有状态闭包的定义可以很学术也可以很实在一个函数加上它定义时捕获的外部变量就是一个闭包。直白点说内层函数即使在外层函数执行结束后仍然能访问外层函数的变量因为那些变量被“留”在了内存里。看一个计数器的例子function createCounter() { let count 0; return function() { count; return count; }; } const counterA createCounter(); console.log(counterA()); // 1 console.log(counterA()); // 2这里的count没有被回收因为返回的匿名函数一直引用着它。每次调用counterAcount就在原有基础上累加。你可以创建多个计数器互不干扰而且外部代码碰不到count只能通过暴露的函数来操作。这就是闭包实现“私有状态”的典型用法。闭包也是最容易被忽略的内存话题。如果闭包引用了一个很大的对象又长期被全局变量持有内存就会一直占着。我在排查页面卡顿时会先检查是不是有人无意中用闭包把大数组留在了引用链上。用完的引用主动置为null内存才有机会被回收。这里没有银弹唯一有效的方式就是养成习惯在长生命周期对象里注意闭包引用的范围。3.3 call/apply/bind三件套普通函数的this是动态的但JavaScript提供了三个方法让你手动指定this。call(thisArg, arg1, arg2)立即调用参数逐个传。apply(thisArg, [arg1, arg2])立即调用参数装数组。bind(thisArg, arg1, arg2)返回一个新函数不立刻执行参数可以预先绑定。比如有一个展示用户信息的工具函数function greet(prefix) { return prefix , this.name; } const user { name: 某开发者 }; console.log(greet.call(user, 你好)); console.log(greet.apply(user, [你好])); const boundGreet greet.bind(user, 你好); console.log(boundGreet());call和apply的区别只在于参数传递方式括号里展开还是数组性能差别可以忽略不计。bind最常用于事件回调和定时器里固定this。在写防抖节流这类工具函数时内部需要用apply把参数透传给原函数同时保留调用方的this。多手写几遍这三个方法你自然能体会到它们和箭头函数this规则的配合关系。4. 从基础到工程函数式编程实战4.1 高阶函数与数据转换链高阶函数这个名字容易被初学阶段吓到实际上就是“接收函数作为参数或者返回一个函数的函数”。数组的map、filter、reduce是最高频的三个高阶函数也是把数据变换写清楚的关键工具。假设后端返回一组订单数据你要算出状态为“已支付”的订单总金额。命令式写法可能是for循环加if再累加代码不少。用高阶函数组合const orders [ { id: 1, amount: 88, status: paid }, { id: 2, amount: 120, status: paid }, { id: 3, amount: 60, status: pending } ]; const result orders .filter(order order.status paid) .map(order order.amount) .reduce((sum, amount) sum amount, 0); console.log(result); // 208这种写法的好处是每一步都在描述“要什么”而不是“怎么循环”。过滤条件增加、字段调整时只改其中一个函数即可不会牵动其他逻辑。我自己处理复杂数据转换时优先用链式写法只有当性能瓶颈被证明出在循环上才考虑退回for循环加立即执行。4.2 防抖与节流两个被问爆的场景前端高性能处理里防抖是“事件被连续触发时只等最后一次触发结束后一段时间再执行”。最典型的场景是搜索框输入用户每敲一个字都去请求接口不仅浪费网络资源还容易出现旧请求覆盖新请求的问题。用防抖后用户停下来才算数。function debounce(fn, delay 300) { let timer null; return function(...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, delay); }; }这里有两个容易遗漏的细节。第一timer要使用闭包变量保证每次调用返回的新函数共享同一个定时器状态第二fn.apply(this, args)才能在透传参数的同时保留调用方的this。如果你写成fn(args)参数就变成整个数组回调逻辑立刻出错。这两个点都是我实际踩过坑之后才记住的。节流是“固定时间间隔内最多执行一次”适合滚动加载、窗口resize这类高频触发的场景。基础版function throttle(fn, interval 200) { let last 0; return function(...args) { const now Date.now(); if (now - last interval) { last now; fn.apply(this, args); } }; }选择防抖还是节流核心看业务以最后一次为准就用防抖以频率为准就用节流。理解了这两个函数的实现你也会更清楚闭包和高阶函数在真实工程中的价值。4.3 柯里化与组合可拼装的函数设计柯里化是把一个多参数函数拆成一系列单参数函数。听起来有点绕看例子就明白了function add(a, b, c) { return a b c; } function curry(fn) { return function curried(...args) { if (args.length fn.length) { return fn.apply(this, args); } return (...next) curried.apply(this, args.concat(next)); }; } const curriedAdd curry(add); console.log(curriedAdd(1)(2)(3)); // 6为什么要把参数拆开因为柯里化让参数可以被“分期”提供。你可以先传一部分参数得到一个更具体的函数然后传给map或者filter。比如先固定税率生成一个专门计算含税价格的函数后续直接复用不用每次重复传税率。函数组合是更上层的玩法把多个单参数函数像管道一样串起来const compose (...fns) (x) fns.reduceRight((acc, fn) fn(acc), x); const addOne (x) x 1; const double (x) x * 2; console.log(compose(addOne, double)(5)); // 11先乘2再加1这类工具在小型项目里不一定天天用但理解后会自然推动你把函数设计成“输入输出单一、职责单一”的小单元。我自己在大型项目里维护业务逻辑时越来越偏好这种可拼装的设计改动最小化、影响可控化。5. 高频报错、异步坑与我的避坑实录5.1 三大错误类型速查表写函数时最常碰到的报错大概有三种报错通常原因排查方向ReferenceError: xxx is not defined变量根本不存在或作用域不对检查拼写、变量是否声明在可见作用域TypeError: xxx is not a function你把undefined或对象当函数调用检查变量是否被赋值成非函数类型SyntaxError: 括号、箭头或花括号问题语法有误比如箭头函数误用块级花括号回到定义语句逐字排查还有一种很隐蔽的情况函数内部忘记return导致调用结果永远是undefined。它不是报错但会让上层逻辑完全失效。我调试这类问题时会在调用点直接打印返回值立刻就能看出有没有return这一行。不要依赖控制台“没有报错”来推断代码没问题——没有返回值的地方往往会静默地埋下隐患。5.2 循环里的var陷阱与事件索引问题这几乎是所有JS学习者都碰到过的坑for (var i 0; i 3; i) { setTimeout(function() { console.log(i); }, 100); } // 输出3 3 3原因很清晰var没有块级作用域循环结束后i已经在外部所有闭包捕获的是同一个变量。换成let它有块级作用域每次循环都会创建一个独立的i输出0、1、2。如果项目里还大量使用var遇到这类问题优先把var改成let。真实项目中同样常见给一批按钮绑定点击事件点击后获取当前索引。如果循环用的是var所有按钮打印的都是最后一个索引。用let声明循环变量后问题立刻消失。这背后不是玄学就是作用域规则在起作用。你理解了闭包和var/let的区别这类问题基本不会再困扰你。5.3 从回调地狱到Promise化的思路回调用得不好代码会像阶梯一样向下生长getData(function(a) { getMore(a, function(b) { getMoreAgain(b, function(c) { // 越来越深 }); }); });解决思路不是彻底抛弃回调而是用Promise包装再配合async/await把嵌套拉平。比如function getData() { return new Promise((resolve) { setTimeout(() resolve(数据), 100); }); } async function main() { const a await getData(); const b await getMore(a); const c await getMoreAgain(b); console.log(c); }这里你会发现Promise并没有消灭函数而是把“回调函数”这个参数从函数签名里去掉了改由then或await来承接后续逻辑。函数作为封装和复用的能力依旧存在只是异步协作的方式变了。所以我一直建议新手先把函数基础筑牢再去学Promise和async/await否则很容易在“函数返回值到底去哪了”这个问题上卡住。5.4 边界情况的个人踩坑记录第一个是默认参数和undefined的纠缠。function fn(x 1)这种写法只有传入undefined时才触发默认值传入null不会。如果你期望null也走默认值必须自己判断。这个细节很容易被忽略。第二个是剩余参数必须放在最后。function fn(a, ...rest)合法function fn(...rest, a)直接语法报错。看起来很基础但重构时确实容易写错。第三个是立即执行函数表达式IIFE的写法。以前很多人写成(function(){})()箭头函数时代也可以写成(() {})()但最外层括号不能丢。如果不加括号function开头的语句会被解析成函数声明后面跟()会直接报语法错误。第四个坑是函数名和变量名冲突。如果你在模块顶层用了const add ...又通过function add()声明了同名函数第二个定义会在提升阶段抢先占据名称。这种问题不好定位但只要你养成了“先检查作用域顶层是否有同名标识符”的习惯排查会快得多。这些坑都不深但都是我在真实代码里被抓过的。写下来不是让你背答案而是提醒你函数看起来简单边界情况却非常多。写完一个函数多跑几个边界值测试——空数组、null、负数、字符串和数字混传——永远不亏。我能坚持写好JavaScript业务逻辑很大程度就是从这些不起眼的小函数和边界测试里磨出来的。
阅读完成 · 觉得有帮助?