【你不知道的JavaScript】深入底层:函数作用域与块作用域的权力演变 【你不知道的JavaScript】深入底层函数作用域与块作用域的权力演变一、 函数中的作用域最天然的保护伞核心规则外部无法访问内部二、 隐藏内部实现与最小特权原则1. 为什么我们需要隐藏反面教材2. 隐藏的另一大核心动机规避冲突规避冲突的两大社区方案三、 函数作用域的自我进化从具名到 IIFE1. 具名函数与匿名函数的命运分水岭2. 匿名函数的阿喀琉斯之踵3. 立即执行函数表达式 (IIFE)IIFE 的高级进阶玩法 终极思考为什么要故意把代码“倒过来”写1. 应对极其复杂的历史运行环境2. 解耦让业务逻辑保持纯粹3. 经典的 UMD通用模块定义模式雏形四、 块作用域现代 JavaScript 的权力交接1. 那些被你忽略的隐秘块作用域① with 机制② try/catch 中的 catch 分句2. 时代的终结者ES6 let 与 constlet 带来的底层颠覆3. 终极常量const五、 本章小结在第二章中我们深刻理解了词法作用域的自然边界是由你在键盘上敲下代码的位置决定的。那么问题来了究竟是什么才会生成一个新的作用域“气泡”是不是只有函数才会生成气泡其他结构比如for循环、if块能生成气泡吗今天我们就走进第3章——函数作用域和块作用域一起去探寻如何利用作用域来完美隐藏代码的秘密并见证 JavaScript 块级作用域的进化史。一、 函数中的作用域最天然的保护伞在大多数人的传统认知中JavaScript 是基于函数的。这句话的意思是每声明一个函数就会为自己创建一个专属的作用域气泡。考虑以下经典代码functionfoo(a){varb2;functionbar(){// ...}varc3;}在这段代码中全局作用域最外层气泡仅包含一个标识符foo。foo 函数作用域内层气泡包含标识符a、b、c和bar。无论这些变量声明在foo内部的什么位置即便是因为变量提升它们都属于foo这个大家庭。核心规则外部无法访问内部由于词法作用域的向外查找机制foo内部的bar能够轻松访问外层的a、b、c。然而任何属于foo作用域的标识符在全局作用域中都无法被直接访问。例如直接在全局调用bar()或访问a都会抛出ReferenceError错误。这种基于函数的划分方式非常有用它可以充分利用 JavaScript 变量动态类型的“动态”特性但它最根本的软件设计学意义在于——隐藏内部实现。二、 隐藏内部实现与最小特权原则在软件设计中有一个非常著名的原则叫作最小特权原则Principle of Least Privilege也叫最小暴露原则。最小特权原则在软件设计中应该最小限度地暴露必要的内容而将其他内容“隐藏”起来比如某个模块或对象的 API 设计。如果我们不把变量隐藏在函数内部而是把它们统统丢到全局会发生什么1. 为什么我们需要隐藏反面教材看下面这段试图通过暴露内部细节来做某些工作的代码functiondoSomething(a){badoSomethingElse(a*2);console.log(b*3);}functiondoSomethingElse(a){returna-1;}varb;doSomething(2);// 15存在的问题在这段代码中变量b和函数doSomethingElse实际上只是doSomething内部运作时的“私有工具”。但是它们却被毫无防备地暴露在了全局作用域中。隐藏后的正确做法为了防止外层世界“误触”或篡改这些私有工具我们应当利用函数作用域将它们包起来functiondoSomething(a){functiondoSomethingElse(a){returna-1;}varb;badoSomethingElse(a*2);console.log(b*3);}doSomething(2);// 15通过这种重构b和doSomethingElse被完美封装在doSomething内部外部再也无法对它们指手画脚。2. 隐藏的另一大核心动机规避冲突将变量暴露在外不仅容易被误改还极易引发命名冲突。一旦两个不同的第三方库在全局定义了同一个变量名代码就会发生灾难性的覆盖。规避冲突的两大社区方案全局命名空间很多著名的库如早期的 jQuery都会在全局只声明一个足够独特的变量比如$或_然后将所有的功能和变量都挂载在这个对象的属性下。模块管理现代前端通过依赖注入或规范的模块化CommonJS / ESM工具将一个文件的作用域彻底锁在文件内部从根本上杜绝了全局冲突。三、 函数作用域的自我进化从具名到 IIFE既然利用函数可以完美实现封装那是不是每次想隐藏变量时都要大张旗鼓地function foo() { ... }声明一个函数再找个地方foo()调用它呢这样做会带来两个尴尬的副作用我们不得不凭空想出一个函数名foo这个名字本身其实也“污染”了当前所在的作用域。我们必须显式地写出foo()调用代码代码才能真正执行。为了追求极致的优雅JavaScript 引入了函数表达式来完美解决这两个痛点。1. 具名函数与匿名函数的命运分水岭区分“函数声明”和“函数表达式”最简单、最直观的方法就是看function这个关键字出现在声明行的第几个位置。如果function是声明行的第一个单词那么它就是一个函数声明。否则它就是一个函数表达式。比较下面两种写法// 写法一外部作用域被污染vara2;functionfoo(){// -- function 是第一个单词属于函数声明vara3;console.log(a);// 3}foo();console.log(a);// 2// 写法二完美隐藏不污染外部vara2;(functionfoo(){// -- function 不是第一个单词属于函数表达式vara3;console.log(a);// 3})();console.log(a);// 2核心本质区别函数声明foo会被绑定在当前所处的作用域中而函数表达式(function foo()...)中的标识符foo只会被绑定在它自身的函数体内部而绝不会污染外层作用域2. 匿名函数的阿喀琉斯之踵我们在写回调函数比如setTimeout时非常喜欢写匿名函数表达式setTimeout(function(){console.log(I waited 1 second!);},1000);匿名函数虽然写起来简单快捷但它存在三大硬伤调试极其困难在栈追踪Stack Trace中匿名函数没有名字错误报告里只会显示一个冷冰冰的anonymous function让人抓狂。无法自我引用当函数需要进行递归调用或者在事件绑定后需要解绑自身时没有名字的函数将无法引用自己旧版的arguments.callee已被严格模式禁用。可读性差一个好的函数名本身就是一行绝佳的代码注释。因此始终给函数表达式起一个名字行内函数表达式是一个极其推荐的行业最佳实践setTimeout(functiontimeoutHandler(){// -- 有了清晰的名字console.log(I waited 1 second!);},1000);3. 立即执行函数表达式 (IIFE)结合了函数表达式的特性前端史上最经典的模式诞生了——IIFE (Immediately Invoked Function Expression)vara2;(functionIIFE(){vara3;console.log(a);// 3})();console.log(a);// 2因为函数被包裹在()内部变成了表达式我们在其末尾再加一个()就可以让它在定义完的瞬间立刻执行。IIFE 的高级进阶玩法传参进化版我们可以把外部的变量当作参数传进 IIFE在内部为其改名从而让代码可读性更强vara2;(functionIIFE(global){vara3;console.log(a);// 3console.log(global.a);// 2})(window); 详细拆解形参和实参的对应在最后一行的 })( window ); 中我们将全局变量 window 作为实参传了进去。函数定义处的 function IIFE( global ) 接收了这个实参所以在函数内部global 就等价于 window。内部的变量隔离内部 var a 3; 声明的是局部变量它把外部的 a 2 遮蔽掉了。此时如果想在内部拿到外部的全局变量 a 怎么办因为 global 映射了 window而全局变量都是挂载在 window 上的所以通过 global.a 就能绕过遮蔽效应成功拿到最外层的 2。UMD (Universal Module Definition) 倒置阵法将需要运行的函数当作参数倒置传入另一个负责调度环境的 IIFE 中。这种写法在早期的模块加载器中铺天盖地vara2;(functionIIFE(def){def(window);})(functiondef(global){vara3;console.log(a);// 3console.log(global.a);// 2}); 详细拆解跟着执行顺序走这种写法虽然看似复杂但我们顺着引擎的执行顺序把它拆成两步第一步看外层的 IIFE我们把下面的那大段函数先看作一个整体 X其实它就是(functionIIFE(def){def(window);})(X);这个 IIFE 立即执行它接收了实参X并赋值给形参def。所以此时def就是下面那个具体的业务函数。随后IIFE 内部执行了def( window );这就相当于去调用了函数X并把window传给了X。2. 第二步看传入的业务函数 X即 deffunctiondef(global){vara3;console.log(a);// 3console.log(global.a);// 2}这个函数被调用时接收到了上面传过来的window并起名叫global。接下来的逻辑就和第一段完全一样了。 终极思考为什么要故意把代码“倒过来”写你可能会想“既然效果和普通传参完全一样我用第一种直白的方法不就行了干嘛非要折腾这种‘代码倒置’的奇葩写法”这正是早期大牛们极其高明的地方为了让“环境调度运行时检测”与“业务逻辑”彻底解耦。1. 应对极其复杂的历史运行环境在 ES6 官方模块化ES Modules诞生之前前端开发者面临一个巨大的痛点——你的这一段 JavaScript 代码在不同的场景下运行其**全局对象根上下文**是完全不同的如果跑在浏览器里全局对象是window。如果跑在Node.js后端环境里全局对象是global。如果跑在特殊的沙箱环境或者**模块加载器如 RequireJS**中它可能需要被当作成一个特定的匿名回调去执行。2. 解耦让业务逻辑保持纯粹通过这种倒置写法整个代码结构被完美拆分为两部分下半部分的业务逻辑函数def里面写的是你纯粹的业务代码。它不需要关心自己现在是躺在浏览器里还是 Node.js 里它只需要在形参里准备好接收一个名为global的东西就行。上半部分的包装函数IIFE专门用来探测环境并进行适配调度。3. 经典的 UMD通用模块定义模式雏形如果我们把上半部分的环境探测逻辑写完整你就能一眼看出它的威力了。在实际的工程化工具中它通常被写成这样(functionIIFE(def){if(typeofwindow!undefined){// 1. 如果当前存在 window说明是浏览器环境传入 windowdef(window);}elseif(typeofglobal!undefined){// 2. 如果当前存在 global说明是 Node.js 环境传入 globaldef(global);}else{// 3. 其他顶层沙箱环境def(this);}})(functiondef(global){// ———————————————— 纯粹的业务逻辑开始 ————————————————vara3;console.log(a);// 3console.log(global.a);// 绕过遮蔽安全访问对应环境下的全局变量 a// ———————————————— 纯粹的业务逻辑结束 ————————————————});这种“倒置”的艺术本质上就是将变化频繁的环境检测锁在头部将保持不变的业务逻辑沉淀在尾部。利用 IIFE 表达式可以把函数作为参数传递的特性同一段业务代码无需任何修改就能在各种千奇百怪的 JavaScript 运行环境中完美兼容、无缝运行四、 块作用域现代 JavaScript 的权力交接了解完函数作用域后我们来看一个软件工程中最经典、最自然的现象for(vari0;i10;i){console.log(i);}console.log(i);// 10 —— 变量 i 极其不合理地泄漏到了外部作用域在上面这个普通的for循环中我们明明只是想在循环内部使用计数器i但由于 JavaScript 早期设计只有函数作用域这个i最终竟然无耻地泄漏到了外层的函数作用域中。为了追求更合理的变量生命周期块作用域 (Block Scope)成为了前端进化的必然选择。事实上在 ES6 带来let之前JavaScript 的世界里就已经存在微弱的块作用域机制了。1. 那些被你忽略的隐秘块作用域① with 机制我们在第二章学过with语句内部会凭空创建一个全新的词法作用域。这个作用域的生命周期仅在with块内部有效。② try/catch 中的 catch 分句这绝对是 JavaScript 早期最不为人知的冷知识ES3 规范中规定try/catch的catch分句中声明的变量具有块作用域try{undefined();// 制造一个非法操作引发异常}catch(err){console.log(err);// 每个都能正常捕获}console.log(err);// ReferenceError: err is not defined注意在catch块内部声明的err在块外部访问会直接报错。早期的很多前端工程化工具如 Google 的 Traceur在将 ES6 的块作用域代码降级转换到 ES5 时底层就是利用try/catch的这个隐秘特性来模拟块作用域的2. 时代的终结者ES6let与constES6 改变了这一切它正式引入了let关键字允许我们将变量直接绑定到任意一对大括号{}锁定的块作用域中。varfootrue;if(foo){letbarfoo*2;barsomething(bar);console.log(bar);}console.log(bar);// ReferenceError: bar is not definedlet带来的底层颠覆隐式劫持let会将变量隐式地劫持在它所声明的那个大括号块中。为了让代码更具可读性社区非常推荐在书写时显式地划分出块级作用域varfootrue;if(foo){{// -- 显式的块letbarfoo*2;console.log(bar);}}没有变量提升与暂时性死区 (TDZ)使用let声明的变量不会在块级作用域中发生“变量提升”。在变量声明之前的区域被称为“暂时性死区”此时访问该变量会直接抛出错误。{console.log(bar);// ReferenceError! 触发暂时性死区TDZletbar2;}完美的垃圾回收机制 (Garbage Collection)块作用域还有一个非常强大的隐形红利就是利于引擎进行垃圾回收。考虑以下大数据处理代码functionprocess(data){// 做一些有趣的大数据处理}varsomeReallyBigData{..};// 占据巨量内存的数据process(someReallyBigData);varbtndocument.getElementById(my_button);btn.addEventListener(click,functionclick(evt){console.log(button clicked);});由于click事件绑定的闭包持有了整个外层作用域即便process(..)已经执行完毕巨量数据someReallyBigData也无法被回收。如果我们用块作用域将它锁起来functionprocess(data){// 做一些有趣的大数据处理}// 在这个块内部声明处理完毕后该内存块可立刻被回收{letsomeReallyBigData{..};process(someReallyBigData);}varbtndocument.getElementById(my_button);btn.addEventListener(click,functionclick(evt){console.log(button clicked);});当块级作用域执行完毕后引擎就可以非常明确地知道someReallyBigData已经完成了历史使命可以立刻将其占用的内存彻底回收for 循环的全新绑定let在for循环内部不仅能限制变量不泄漏它还有一个极其精妙的特性在每一次循环迭代中let都会重新绑定一个新的变量i并自动将上一次迭代结束时的值作为当前初始值。for(leti0;i10;i){console.log(i);}console.log(i);// ReferenceError: i is not defined这种机制在应对异步闭包比如循环内部绑定点击事件或setTimeout时展现出了无与伦比的优雅。3. 终极常量const除了letES6 还带来了const。它同样可以用来创建块级作用域变量但它的特殊之处在于——它的值在声明时必须初始化且一旦设定之后任何试图修改它的操作都会引发类型错误 (TypeError)。varfootrue;if(foo){vara2;constb3;// 绑定到当前 if 的块作用域a3;// 正常b4;// TypeError: Assignment to constant variable!}console.log(a);// 3console.log(b);// ReferenceError: b is not defined五、 本章小结函数是 JavaScript 中最普遍的作用域单元。在函数内部声明的变量和函数对外部世界是完美隐藏的这符合软件工程的“最小特权原则”。IIFE立即执行函数表达式允许我们在不创建具名函数的前提下立刻执行代码并建立临时保护伞从而规避全局命名空间冲突。块作用域指的是变量不仅可以属于函数还可以属于任意一个代码块通常由{}包裹。在 ES6 之前with和catch分句就具备块作用域的特征而ES6 正式引入的let和const改变了历史它们让 JavaScript 拥有了真正现代、健壮的块级作用域体系解决了变量泄漏与垃圾回收的优化难题。欢迎关注专栏下一篇我们将迎来整个作用域体系中最惊心动魄、面试必考的终极高山——第4章提升Hoisting去看看变量和函数在被执行前究竟经历了怎样神奇的“引力升空”敬请期待

相关新闻

最新新闻

Java 大厂面试高频题:别再只会 new 了!Java 创建对象竟然有这么多种方式

Java 大厂面试高频题:别再只会 new 了!Java 创建对象竟然有这么多种方式

在学习 Java 时,我们最先接触的对象创建方式通常是: Person person new Person();因此,很多初学者会认为 Java 创建对象只能使用 new。 实际上,Java 中常见的对象创建方式不止一种。除了直接使用 new,还可以通过反射、…

2026/7/13 0:39:36
Git bundle 与 tar/zip 备份对比:2种方案性能与完整性实测

Git bundle 与 tar/zip 备份对比:2种方案性能与完整性实测

Git Bundle 与 Tar/Zip 备份方案深度对比:性能与完整性实测报告在代码版本管理和数据备份领域,Git bundle 和传统的文件系统打包工具(如 tar、zip)是两种截然不同的技术路线。本文将通过 12 项核心指标的系统性测试,揭…

2026/7/13 0:39:36
VS Code C# Dev Kit 1.0 配置指南:5个必装插件与 .NET 8 项目创建实战

VS Code C# Dev Kit 1.0 配置指南:5个必装插件与 .NET 8 项目创建实战

VS Code C# Dev Kit 1.0 配置指南:5个必装插件与 .NET 8 项目创建实战微软最新推出的C# Dev Kit扩展包彻底改变了VS Code的.NET开发体验。作为从Visual Studio迁移过来的老用户,我发现这套工具链不仅保留了VS的核心功能,还带来了更轻量高效的…

2026/7/13 0:39:36
基于MediaPipe的智能手势控制PPT系统31(设计源文件+万字报告+讲解)(支持资料、图片参考_相关定制)_

基于MediaPipe的智能手势控制PPT系统31(设计源文件+万字报告+讲解)(支持资料、图片参考_相关定制)_

基于MediaPipe的智能手势控制PPT系统31(设计源文件万字报告讲解)(支持资料、图片参考_相关定制)_ 项目简介 本系统采用计算机视觉和深度学习技术,通过摄像头实时识别手势来控制PowerPoint演示,支持7种手势控制,包括翻页…

2026/7/13 0:39:36
Kimi    LeetCode 3544. 子树反转和 C语言实现

Kimi LeetCode 3544. 子树反转和 C语言实现

以下是 LeetCode 3544. 子树反转和 的 C 语言实现。解题思路本题使用树形 DP 记忆化搜索。状态定义:dp(u, steps, inv) - u:当前节点 - steps:距离最近的一次祖先反转已经经过了多少步(上限为 k)。当 steps k 时&…

2026/7/13 0:34:35
Kimi    LeetCode 3547. 图中边值的最大和 Python3实现

Kimi LeetCode 3547. 图中边值的最大和 Python3实现

以下是 LeetCode 3547. 图中边值的最大和 的 Python3 实现。解题思路由于每个节点最多与其他两个节点相连,整个图由若干链和环组成。1. 连通分量分类:用 DFS/BFS 找出所有连通分量。若分量内所有节点度数均为 2,则为环;否则为链&a…

2026/7/13 0:34:35

月新闻