
前阵子收拾一个旧的纯 HTML 页面打开控制台一看window 上挂了一堆我自己都快遗忘的临时变量定时器句柄、几次 DOM 查询的缓存、一个从接口返回后残留的配置对象。这些变量每一个都只在初始化时用一下用完本该就地销毁但因为我当时图省事全都裸写在脚本顶层结果它们陪着页面活了整个生命周期。后来清理这部分代码时我把所有“跑完就扔”的初始化逻辑统一改成了自执行箭头函数。所谓自执行箭头函数就是用箭头函数写的 IIFEImmediately Invoked Function Expression立即执行函数表达式——定义函数的同时立刻调用一次函数内部的作用域在调用结束后随即释放。它不是什么标新立异的花活而是 JavaScript 函数表达式机制的一种直接应用。这篇文章我就从作用域污染这条线切入拆开它的写法、原理、容易踩的坑以及在真实项目里到底怎么用才合适。1. 为什么需要“跑完就扔”的函数全局变量是如何攒成灾的1.1 浏览器页面里的全局变量生存期在原生浏览器脚本里所有顶层var声明的变量和function声明的函数最终都会挂到window对象上。一个页面加载的脚本越多全局命名空间就越拥挤。举个非常常见的例子你写了一段配置逻辑声明了一个var apiBase /api/v1。这本身没问题但另一个同事在另一个脚本文件里无意间也声明了一个var apiBase /api/v2。后加载的脚本直接覆盖前者而且浏览器不会给出任何报错。更麻烦的是这种覆盖往往是“运行时才显现”的接口偶发 404、某个按钮点击后行为异常、图片加载路径不对排查一圈才发现是全局变量被顶掉了。有人可能会说“那我用const和let不就行了” 是的const和let不会挂到window上这解决了一半问题。但如果你是在一个没有模块化的传统页面里写脚本顶层let虽然不进window它依然存在于全局作用域中也会和同作用域里的其他顶层声明相互影响。换句话说问题只是从“挂在 window 上”变成了“浪在全局作用域里”并没有真正隔离。真正的隔离是把那段逻辑扔进一个只属于它自己的作用域里跑完整个作用域就销毁。这就是 IIFE 的出发点。1.2 IIFE 的底层逻辑函数表达式与作用域回收要隔离作用域最直接的办法是“用函数包一层”。函数内部用var声明的变量属于函数作用域函数调用结束局部变量随之释放。这是 JavaScript 从一开始就具备的能力。但这里有个语法限制以function开头的语句会被解析为函数声明。函数声明的特点是必须给名字声明会被提升而且不能在同一行直接调用。你写function() { ... }()这种形式在语法解析阶段就会报错。于是前辈们想了个办法用括号把整个函数包起来让解析器把它当成函数表达式而不是函数声明。表达式求值完会得到一个函数对象后面再跟一组()完成调用。这就是 IIFE 的由来经典写法长这样(function () { console.log(只执行一次); })();底层的逻辑值得多讲两句。JavaScript 里函数本质上是对象函数表达式求值的结果就是一个可调用的对象。既然它是“值”就可以被调用、被赋值、被当作参数传递。IIFE 利用的正是这一点先求值出一个函数对象然后立刻调用它调用产生的闭包环境随调用结束而被回收。那几个局部变量就像临时请来的帮手干完活就散场不占地方。1.3 箭头函数自执行让封装变得更像“表达式”ES6 之后箭头函数普及IIFE 有了更简短的写法。普通函数需要function四个字符加一对花括号箭头函数只靠一个就把参数和函数体连接起来配合隐式返回很多临时封装能直接压缩成一行。更重要的是箭头函数天生偏爱表达式上下文和 IIFE 的“求值后立即调用”哲学天然吻合。比如你想在初始化时根据条件算出一个配置对象普通函数版本要写var config (function () { return { theme: dark, font: 14 }; })();箭头函数版本写起来就轻一点const config (() { return { theme: dark, font: 14 }; })();如果函数体只有返回对象这一个动作还能用隐式返回再压一层const config (() ({ theme: dark, font: 14 }))();当然简洁归简洁箭头函数也有自己的脾气。它在写法上比普通函数更容易踩语法坑下一节专门拆这个。2. 自执行箭头函数有哪几种合法姿势先搞懂括号的位置规则2.1 三种最常见的写法第一种也是我在项目里用得最多的写法箭头函数整体被括号包裹构成一个表达式再在括号外跟一组调用括号。(() { console.log(run); })();第二种把调用也放进外层的括号里让整个结构变成“括号包裹一次完整调用”。这种写法在老派代码里很常见对应传统(function(){ ... }())的形态(() { console.log(run); }());第三种是带返回值的赋值式写法。自执行函数执行完得到结果直接赋值给变量const theme (() { if (window.matchMedia((prefers-color-scheme: dark)).matches) { return dark; } return light; })();这种写法的价值在于计算过程中不需要在外部留下任何中间变量结果一出来临时变量立刻释放。2.2 为什么() {}()会直接语法报错新手最爱问的一个问题为什么不能像普通函数一样写完箭头函数直接加括号调用() { console.log(run); }()这行代码在水合解析阶段就会报语法错误。原因出在箭头函数的语法规则上当后面紧跟{时解析器会把{}内的一切当成函数体块而不是对象字面量。函数体块从{开始到}结束这个箭头函数到}就已经是一个完整的表达式了。后面再出现的()在语法上找不到可以挂靠的表达式部分于是直接报错。你可以这样理解普通函数表达式在括号里是一个“主表达式”PrimaryExpression主表达式天然可以被后缀的(连接成调用表达式。但箭头函数属于“赋值表达式”层级它没有这个“随时可以被后缀调用”的资格。想让解析器明确知道“这是一整个函数表达式现在要调用它”就必须用括号把箭头函数整体包起来让调用发生在表达式内部或表达式之后。// 这样才对 (() { console.log(run); })();这个区别是 IIFE 从普通函数搬到箭头函数时最容易出的错也是我在 code review 里见到最多的写错姿势。2.3 带参数、带返回值、带 async 的变体自执行箭头函数也可以接收参数写法是箭头函数加括号后在外部调用括号里传值((name, age) { console.log(name, age); })(小明, 18);如果只有一个参数还能再省一层括号(name { console.log(你好, name); })(小明);这种带参数的自执行写法在循环闭包场景里非常好用后面第 4 章会专门演示。另外还有 async 版本这是日常开发里越来越常见的形态(async () { const data await fetch(/api/user).then(r r.json()); console.log(data); })();async 自执行函数的原理和同步版本完全一样只是它调用后返回的是一个 Promise。这个 Promise 怎么处理、怎么捕获错误是个很容易被忽视的细节我在第 5 章会专门讲。3. this 与 arguments自执行箭头函数里最容易翻车的两个点3.1 箭头函数没有自己的 this继承的还是外层普通函数在调用时this的值由调用方式决定。在全局写一个 IIFE 再直接调用非严格模式下this指向全局对象严格模式下是undefined因为它是被“裸调”的普通函数。箭头函数就完全不一样了。它压根不绑定自己的this函数体里的this直接沿作用域链向上找。这意味着自执行箭头函数里的this取决于箭头函数定义的位置而不是调用方式。最典型的场景是对象方法里的自执行逻辑。如果方法内部有一段临时初始化代码用普通函数 IIFE 就得先把this存下来const counter { count: 0, init() { const self this; (function () { self.count 1; console.log(count:, self.count); })(); } };用自执行箭头函数就不用那么麻烦它自动继承init方法里的thisconst counter { count: 0, init() { (() { this.count 1; console.log(count:, this.count); })(); } };这是箭头函数版 IIFE 的优势之一。但反过来如果你确实希望 IIFE 内部有独立的this比如框架代码需要把实例作为上下文传进去箭头函数给不了你。箭头函数连call和apply都改不了它的this这种场景只能退回普通函数。3.2 arguments 的“隐性借用”与 ReferenceError 现场箭头函数没有自己的arguments对象。在箭头函数内部写arguments访问到的是外层函数的arguments。这个特性在自执行场景里特别容易引发困惑。举个例子function outer() { (() { console.log(arguments); })(); } outer(1, 2, 3);很多人以为箭头函数内部会有一个属于自己的空arguments集合但实际上打印出来的是outer收到的参数[1, 2, 3]。如果你的自执行箭头函数写在一个普通函数内部而且恰好想用arguments拿自己的参数你会拿到外层函数的东西或者直接撞上一个不存在的绑定。更隐蔽的坑在全局作用域。在浏览器普通脚本里全局并没有arguments这个绑定所以直接在全局写(() { console.log(arguments); })();在浏览器里会直接抛出ReferenceError: arguments is not defined。但同样的代码放到 Node.js 的 CommonJS 模块里又不报错因为模块包装函数自带arguments。同一段代码不同运行环境表现完全不一样——这就是最容易让人懵的地方。如果箭头函数内部确实需要“自己的参数”正确做法是使用剩余参数((...args) { console.log(args); // [1, 2, 3] })(1, 2, 3);剩余参数返回的是一个真正的数组比arguments还好用可以放心遍历。3.3 什么时候必须放弃箭头函数两个明确的反向场景第一需要函数内部有独立的this并且通过call/apply动态注入上下文时不能使用箭头函数。典型例子是事件处理逻辑、以及一些需要动态绑定 this 的工具库内部实现。第二如果在维护一个需要兼容老内核的代码库ES6 的转译和降级策略要先确认清楚。箭头函数在老旧环境里能不能用取决于团队是否统一走 Babel 构建链路而不是“试试看能不能跑”。另外从调试体验上说匿名箭头函数在堆栈追踪里显示为(anonymous)或 arrow function排错时没有上下文。如果这个自执行函数体比较长、出错的概率高我更建议给它一个名字或者干脆用命名函数。这一点第 5 章还会展开。4. 实战拆解四个高频场景分别该怎么写4.1 一次性初始化与配置门控最直接的场景一段逻辑只在页面初始化时执行一次执行完不希望留下任何痕迹。比如埋点上报脚本只在生产环境跑(() { const env document.documentElement.dataset.env; if (env ! production) return; window.addEventListener(error, e { fetch(/api/log, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message: e.message, stack: e.error e.error.stack }) }); }); })();这段代码里env变量在函数执行完就释放了。整个初始化逻辑的副作用只有一个在某些条件下给window挂了一个事件监听。其余的一切都被关在小盒子里不会污染全局。另一个常见的变体是“配置门控”根据页面环境决定要不要执行某段逻辑环境判断、分支逻辑全部塞进自执行函数里。好处是团队其他人看到这个结构一眼就知道“这块是启动代码不会在别处被引用”。4.2 模块封装私有变量和公开接口在还没有 ES Module 的年代IIFE 是模拟私有变量的核心工具。现在虽然模块化已经很普及但零依赖的纯 HTML 页面、浏览器插件脚本、油猴脚本这些环境仍然需要 IIFE 来做模块封装。一个典型的 reveal module 写法window.Utils (() { const cache new Map(); const get key cache.get(key); const set (key, value) cache.set(key, value); const clear () cache.clear(); return { get, set, clear }; })();cache对全局不可见外部只能通过返回的get、set、clear三个方法访问它。这就是闭包的力量函数返回后cache不会被销毁因为返回的方法仍然持有对它的引用。如果用隐式返回直接返回对象有个细节必须注意对象字面量需要额外包一层括号否则{}会被当成函数体块window.Utils (() ({ cache: new Map(), get(key) { // 注意这里的 this 指向返回的对象 return this.cache.get(key); }, set(key, value) { this.cache.set(key, value); } }))();这种写法很紧凑但容易让新人误解。我的建议是在团队代码里优先用显式return可读性更高只有简单的工厂对象才用隐式返回。4.3 循环内按快照捕获绕过 var 的老问题经典的闭包陷阱for (var i 0; i 3; i) { setTimeout(() console.log(i), 100); } // 输出3 3 3问题在于var没有块级作用域三个定时器回调共享同一个i。等到 100 毫秒后回调执行时i已经是 3 了。现代解法是改用let这个正常。但如果你接手的是老代码库或者不想大动干戈把var改成let自执行箭头函数可以做到最小改动for (var i 0; i 3; i) { (j { setTimeout(() console.log(j), 100 * j); })(i); } // 输出0 1 2这里的关键在于(j { ... })(i)箭头函数接收参数j实参i按值传递。每次迭代j都是当前i的一个快照回调闭包捕获的是快照而不是共享变量。这种写法其实是“参数绑定版 IIFE”在日常代码里出现频率很高。如果你看到一段代码里写着(x { ... })(data)那本质上就是给箭头函数自执行传入了初始数据。4.4 用 async 自执行函数模拟顶层 await在普通 script 标签里顶层不能直接用await。以前遇到异步初始化只能包一层普通函数再调用或者写成 Promise 链。现在用 async 自执行函数代码可以写得像“顺序执行”一样自然(async () { const res await fetch(/api/config); const config await res.json(); // 根据 config 完成各种初始化 document.documentElement.dataset.theme config.theme || light; initRouter(config.routes); initAnalytics(config.trackingId); })();这个写法的优势是await后面的所有逻辑都待在独立作用域里中间变量不会泄漏。async 自执行函数调用后返回的是 Promise所以可以挂.catch(async () { // ... })().catch(err { console.error(初始化失败, err); });不过我个人更推荐在函数体内部用try...catch因为这样你可以在 catch 里做更完整的清理工作也能避免外部漏挂.catch导致 unhandled rejection。具体来说(async () { try { const res await fetch(/api/config); if (!res.ok) throw new Error(config request failed); const config await res.json(); // ... } catch (err) { console.error(初始化失败, err); showFallbackConfig(); } })();5. 边界与习惯哪些位置别乱用团队规范怎么定5.1 上一行没有分号的时候自执行函数开头就是一颗炸弹这是我在旧项目里真实栽过的一个坑也是最值得拿出来分享的边界情况。如果你的代码风格是无分号行尾不写分号那么当前一行没以分号结尾时自执行函数那行以(开头就会被 JavaScript 的自动分号插入机制误认为上一行的延续。看这段代码const greeting hello (() { console.log(world); })()它看起来像是两条独立的语句实际被解析成const greeting hello(() { ... })()引擎试图把字符串hello当函数来调用于是直接抛出TypeError: greeting is not a function。我当时排查一个“初始化报错但页面还能正常显示”的诡异 bug查了几个小时最后发现罪魁祸首就是模板字符串后面漏了分号。修复方式很简单自执行函数前一行必须显式加分号。const greeting hello; (() { console.log(world); })();如果你的团队统一使用无分号风格还有一个更稳妥的做法在自执行函数自身前面加一个前导分号const greeting hello ;(() { console.log(world); })()这个前导分号是很多压缩工具和人肉写代码时的安全习惯防的就是上一行行尾漏分号。ESLint 的semi-style规则里也专门有“最后一个语句加分号”的检查可以配一下。5.2 async 自执行函数的未捕获 Promise这个坑我见过不少次。代码表面上跑得很好但控制台时不时出现一条 unhandled promise rejection。(async () { await fetch(/api/nonexist); })();如果fetch请求失败这个 Promise 的 rejection 没有任何地方处理。现代浏览器会在控制台打出一条显眼的警告但页面逻辑可能继续跑错误被悄悄吞掉排障时很难定位。我在前面的 4.4 里已经给过两种处理方案内部try...catch或外部挂.catch。这里再补充一个细节如果自执行 async 函数是应用的入口建议统一在外层做一个错误兜底把错误信息上报或输出到界面。(async () { try { await bootstrap(); } catch (err) { window.__errorReport window.__errorReport(err); document.body.insertAdjacentHTML( beforeend, div classfatal-error初始化失败请刷新重试/div ); } })();入口级的错误兜底越早做越好。5.3 可读性权衡什么时候该用命名函数自执行箭头函数简洁但简洁不等于适合所有场合。如果函数体超过四五十行我会强烈建议给它起个名字再调用function initApp() { // 一堆初始化逻辑包括权限判断、路由注册、数据预取…… } initApp();理由有三个第一堆栈追踪里有函数名报错时能直接定位到初始化逻辑的哪一段出了问题。匿名箭头函数在控制台里只会显示(anonymous)排查问题时要靠猜。第二命名函数可以被单独测试。单元测试框架可以直接import { initApp } from ./init并调用它而 IIFE 在模块加载时就已经执行了没法独立控制执行时机。第三阅读代码时函数名本身就是文档。看到initApp()读者马上知道这里是应用启动入口看到一坨(() { ... })()读者得先通读整个函数体才知道这段代码在干什么。在正式项目里我给自己定了几条规矩初始化逻辑超过 20 行优先命名函数2 到 10 行的小包装用自执行箭头函数。同一个文件里不混用(function(){})()和((){})()至少保证局部统一便于搜索和替换。无分号风格的团队IIFE 前一行一律加分号或者 IIFE 自身加前导分号。需要 this 或 arguments 的地方不用箭头函数形态。一个文件里自执行函数不要超过三处超过了大概率说明该拆成正式模块了。现在接手一个老页面或写零依赖的 HTML 演示时我已经习惯了把整个脚本的“启动段”写成自执行箭头函数。它像是一个可以随时丢弃的小盒子盒子里定义多少变量都不用担心漏到全局执行完自动消失盒子外面如果需要暴露什么显式挂到window或return出去。这个习惯帮我省了很多心——至少现在打开控制台window 上干干净净遇到问题也知道去哪里查了。