【你不知道的JavaScript】深入底层:函数作用域与块作用域的权力演变
- 一、 函数中的作用域:最天然的保护伞
- 核心规则:外部无法访问内部
- 二、 隐藏内部实现与最小特权原则
- 1. 为什么我们需要隐藏?(反面教材)
- 2. 隐藏的另一大核心动机:规避冲突
- 规避冲突的两大社区方案:
- 三、 函数作用域的自我进化:从具名到 IIFE
- 1. 具名函数与匿名函数的命运分水岭
- 2. 匿名函数的阿喀琉斯之踵
- 3. 立即执行函数表达式 (IIFE)
- IIFE 的高级进阶玩法
- 🚀 终极思考:为什么要故意把代码“倒过来”写?
- 1. 应对极其复杂的历史运行环境
- 2. 解耦:让业务逻辑保持纯粹
- 3. 经典的 UMD(通用模块定义)模式雏形
- 四、 块作用域:现代 JavaScript 的权力交接
- 1. 那些被你忽略的隐秘块作用域
- ① with 机制
- ② try/catch 中的 catch 分句
- 2. 时代的终结者:ES6 `let` 与 `const`
- `let` 带来的底层颠覆:
- 3. 终极常量:`const`
- 五、 本章小结
在第二章中,我们深刻理解了词法作用域的自然边界是由你在键盘上敲下代码的位置决定的。那么问题来了:究竟是什么才会生成一个新的作用域“气泡”?是不是只有函数才会生成气泡?其他结构(比如for循环、if块)能生成气泡吗?
今天我们就走进第3章——函数作用域和块作用域,一起去探寻如何利用作用域来完美隐藏代码的秘密,并见证 JavaScript 块级作用域的进化史。
一、 函数中的作用域:最天然的保护伞
在大多数人的传统认知中,JavaScript 是基于函数的。这句话的意思是:每声明一个函数,就会为自己创建一个专属的作用域气泡。
考虑以下经典代码:
functionfoo(a){varb=2;functionbar(){// ...}varc=3;}在这段代码中:
- 全局作用域(最外层气泡):仅包含一个标识符
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){b=a+doSomethingElse(a*2);console.log(b*3);}functiondoSomethingElse(a){returna-1;}varb;doSomething(2);// 15- 存在的问题:在这段代码中,变量
b和函数doSomethingElse实际上只是doSomething内部运作时的“私有工具”。但是,它们却被毫无防备地暴露在了全局作用域中。 - 隐藏后的正确做法:为了防止外层世界“误触”或篡改这些私有工具,我们应当利用函数作用域将它们包起来:
functiondoSomething(a){functiondoSomethingElse(a){returna-1;}varb;b=a+doSomethingElse(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是声明行的第一个单词,那么它就是一个函数声明。 - 否则,它就是一个函数表达式。
比较下面两种写法:
// 写法一:外部作用域被污染vara=2;functionfoo(){// <-- function 是第一个单词,属于函数声明vara=3;console.log(a);// 3}foo();console.log(a);// 2// 写法二:完美隐藏,不污染外部vara=2;(functionfoo(){// <-- function 不是第一个单词,属于函数表达式vara=3;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):
vara=2;(functionIIFE(){vara=3;console.log(a);// 3})();console.log(a);// 2因为函数被包裹在()内部变成了表达式,我们在其末尾再加一个()就可以让它在定义完的瞬间立刻执行。
IIFE 的高级进阶玩法
- 传参进化版:我们可以把外部的变量当作参数传进 IIFE,在内部为其改名,从而让代码可读性更强:
vara=2;(functionIIFE(global){vara=3;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 中。这种写法在早期的模块加载器中铺天盖地:
vara=2;(functionIIFE(def){def(window);})(functiondef(global){vara=3;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(即 def)
functiondef(global){vara=3;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){// ———————————————— 纯粹的业务逻辑开始 ————————————————vara=3;console.log(a);// 3console.log(global.a);// 绕过遮蔽,安全访问对应环境下的全局变量 a// ———————————————— 纯粹的业务逻辑结束 ————————————————});这种“倒置”的艺术,本质上就是将变化频繁的环境检测锁在头部,将保持不变的业务逻辑沉淀在尾部。利用 IIFE 表达式可以把函数作为参数传递的特性,同一段业务代码无需任何修改,就能在各种千奇百怪的 JavaScript 运行环境中完美兼容、无缝运行!
四、 块作用域:现代 JavaScript 的权力交接
了解完函数作用域后,我们来看一个软件工程中最经典、最自然的现象:
for(vari=0;i<10;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与const
ES6 改变了这一切,它正式引入了let关键字,允许我们将变量直接绑定到任意一对大括号{}锁定的块作用域中。
varfoo=true;if(foo){letbar=foo*2;bar=something(bar);console.log(bar);}console.log(bar);// ReferenceError: bar is not definedlet带来的底层颠覆:
- 隐式劫持:
let会将变量隐式地劫持在它所声明的那个大括号块中。为了让代码更具可读性,社区非常推荐在书写时显式地划分出块级作用域:
varfoo=true;if(foo){{// <-- 显式的块letbar=foo*2;console.log(bar);}}- 没有变量提升与暂时性死区 (TDZ):使用
let声明的变量,不会在块级作用域中发生“变量提升”。在变量声明之前的区域被称为“暂时性死区”,此时访问该变量会直接抛出错误。
{console.log(bar);// ReferenceError! 触发暂时性死区(TDZ)letbar=2;}- 完美的垃圾回收机制 (Garbage Collection):
块作用域还有一个非常强大的隐形红利,就是利于引擎进行垃圾回收。考虑以下大数据处理代码:
functionprocess(data){// 做一些有趣的大数据处理}varsomeReallyBigData={..};// 占据巨量内存的数据process(someReallyBigData);varbtn=document.getElementById("my_button");btn.addEventListener("click",functionclick(evt){console.log("button clicked");});由于click事件绑定的闭包持有了整个外层作用域,即便process(..)已经执行完毕,巨量数据someReallyBigData也无法被回收。
如果我们用块作用域将它锁起来:
functionprocess(data){// 做一些有趣的大数据处理}// 在这个块内部声明,处理完毕后该内存块可立刻被回收!{letsomeReallyBigData={..};process(someReallyBigData);}varbtn=document.getElementById("my_button");btn.addEventListener("click",functionclick(evt){console.log("button clicked");});当块级作用域执行完毕后,引擎就可以非常明确地知道:someReallyBigData已经完成了历史使命,可以立刻将其占用的内存彻底回收!
- for 循环的全新绑定:
let在for循环内部不仅能限制变量不泄漏,它还有一个极其精妙的特性:在每一次循环迭代中,let都会重新绑定一个新的变量i,并自动将上一次迭代结束时的值作为当前初始值。
for(leti=0;i<10;i++){console.log(i);}console.log(i);// ReferenceError: i is not defined这种机制在应对异步闭包(比如循环内部绑定点击事件或setTimeout)时,展现出了无与伦比的优雅。
3. 终极常量:const
除了let,ES6 还带来了const。它同样可以用来创建块级作用域变量,但它的特殊之处在于——它的值在声明时必须初始化,且一旦设定,之后任何试图修改它的操作都会引发类型错误 (TypeError)。
varfoo=true;if(foo){vara=2;constb=3;// 绑定到当前 if 的块作用域a=3;// 正常b=4;// 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)!去看看变量和函数在被执行前,究竟经历了怎样神奇的“引力升空”,敬请期待!