一、词法作用域
1.1词法阶段
词法化:上一篇文章介绍过,大部分标准语言编译器的第一个工作阶段叫做词法化(单词化)。
编译器在词法化阶段会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。
词法作用域:定义在词法阶段的作用域。
简单来说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的。
让我们看看一段代码:
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar( b * 3 );
}
foo( 2 ); // 2, 4, 12
这段代码中有三个逐级嵌套的作用域。我们把他们想象成三个气泡:
- 🫧1:包含着整个全局作用域,其中只有一个标识符:
foo。 - 🫧2:包含着 foo 所创建的作用域,其中有三个标识符:
a、bar和b。 - 🫧3:包含着 bar 所创建的作用域,其中只有一个标识符:
c。
作用域气泡由其对应的作用域块代码写在哪里决定,它们是逐级包含的。
1.2 查找
作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的位置。
作用域查找会在找到第一个匹配的标识符时停止。
举个栗子🌰:
上一段代码中:引擎想要执行 console.log(...),要查找a、b、c三个变量的引用,它会先从自己所在的作用域bar(..)(🫧气泡3)中开始查找,发现并没有三个变量的声明,因此,它会去上级作用域(上一个气泡2🫧)查找,诶!找到b啦!但是a无法找到,它又从上上层作用域(🫧气泡1)查找到a,因此引擎使用了这个引用。
看看他们是如何对话哒!
引擎:开始执行
foo(2),初始化参数a = 2,进入foo函数作用域(气泡 1🫧 )。
🫧 1 :我这儿有变量a = 2,执行var b = a * 2,算出b = 4,记好咯。
引擎:接下来要调用bar(b * 3),得先算出参数b * 3,这得先找到b的值。
🫧 1 :我这儿b = 4,给你!这么一来参数就是4 * 3 = 12。
引擎:现在进入bar函数作用域(气泡🫧 3 ),参数c = 12初始化好啦。
🫧 3 :我这里就只有参数c = 12,没声明过a和b呢。
引擎:执行console.log(a, b, c),先找a。当前作用域没有,去上级作用域(气泡🫧 2 )瞅瞅。
🫧 2 :哎呀,我这儿也没有a,你再去上一级(气泡🫧 1 )看看。
🫧 1 :我这儿有a = 2,拿走~
引擎:再找b,当前作用域(气泡🫧 3 )没有,去上级作用域(气泡🫧 2 ),也没有,再问上上级作用域(气泡🫧 1 )。
🫧 1 :我这儿b = 4,给你~
引擎:最后找c,当前作用域(气泡🫧 3 )有参数c = 12,能用。
最终输出:2, 4, 12(所有变量都通过作用域链顺利解析出来啦)。
当然这样的查找机制也会造成一些问题,比如:遮蔽效应。
遮蔽效应:指在多层嵌套作用域里,内部作用域定义的同名变量会遮蔽外部作用域的同名变量 。
var x = 10;
function foo() {
var x = 20;
console.log(x); // 输出20,函数内的x遮蔽了全局的x
}
foo();
console.log(x); // 输出10,在全局作用域访问全局的x
小tips:遮蔽效应只在函数作用域内生效,不影响其他函数作用域 。过多使用同名变量会让代码难理解和维护,建议用不同变量名或避免在函数内定义与全局同名变量 。
二、函数作用域
2.1函数中的作用域
function foo(a) {
var b = 2;
// 一些代码
function bar() {
// ...
}
// 更多的代码
var c = 3;
}
- 作用域气泡:一般认为 JavaScript 基于函数作用域,即声明函数会创建作用域气泡,但不完全正确。如
foo(..)有自己的作用域气泡,包含标识符a、b、c和bar;bar(..)也有自己的作用域气泡,全局作用域气泡包含foo。 - 访问规则:附属于函数作用域气泡的标识符,如
foo(..)中的a、b、c、bar,在函数外部(如全局作用域)无法访问,直接访问会导致ReferenceError错误,但在foo(..)内部及bar(..)内部(无同名标识符声明时)可访问。 - 函数作用域意义与风险:函数作用域让函数内变量可在函数范围内及嵌套作用域使用复用,利用了 JavaScript 变量动态特性;但不妥善处理可全局访问变量,可能引发问题。
2.2隐藏内部实现
什么是隐藏内部实现?
- 本质:用函数包裹一段代码,形成独立的作用域气泡,让代码中的变量和函数仅在这个作用域内可见,外部无法访问。
- 举个栗子🌰:
// 未隐藏时(外部可访问 b 和 doSomethingElse)
var b;
function doSomethingElse(a) { ... }
function doSomething(a) { b = a + ...; }
// 隐藏后(b 和 doSomethingElse 仅在 doSomething 内部可见)
function doSomething(a) {
function doSomethingElse(a) { ... } // 内部函数
var b; // 内部变量
b = a + doSomethingElse(a * 2);
}
为什么需要隐藏?
在回答之前,我们先来认识一个概念:
最小特权原则:也叫最小授权或最小暴露原则。是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计。
- 风险:若变量 / 函数暴露在全局作用域,可能被意外修改,导致程序逻辑混乱(如示例中
b和doSomethingElse若被外部篡改,会影响doSomething的正常运行)。 - 优势:隐藏后,内部逻辑被封装,外部只能通过函数的接口(如参数和返回值)与内部交互,提升代码的稳定性和安全性。
如何实现隐藏?
- 用函数包裹代码:将需要隐藏的变量和函数声明在另一个函数的作用域内。
- 作用域链规则:内部函数可以访问外部函数的变量 / 函数,但外部无法反向访问内部的内容。
2.3规避冲突
场景:当不同作用域中存在同名变量 / 函数时,内部作用域的变量会遮蔽外部作用域的同名变量,导致值被意外修改。 所以会出现命名冲突。
function foo() {
for (var i=0; i<10; i++) { // 外部作用域的 i
function bar(a) {
i = 3; // 意外修改外部的 i,导致循环无限执行
console.log(a + i);
}
bar(i * 2);
}
}
foo();
问题:
bar内部未声明i,直接修改了外部循环的i,导致逻辑错误。
如何规避冲突?
1. 用作用域隐藏内部变量
方法:在函数内部声明变量,利用作用域气泡将其私有化,避免与外部同名变量冲突。
function foo() {
for (var i=0; i<10; i++) {
function bar(a) {
var i = 3; // 声明内部变量 i,遮蔽外部的 i,避免冲突
console.log(a + i);
}
bar(i * 2);
}
}
foo();
2. 全局作用域中的冲突规避
命名空间模式:将全局变量封装成一个对象,避免直接暴露变量名。
// 不好:直接暴露全局变量 doSomethingElse
function doSomethingElse() { ... }
// 好:用对象作为命名空间
var MyLibrary = {
doSomethingElse: function() { ... } // 作为对象属性存在
};
3. 使用模块管理工具
- 原理:通过模块系统(如 Node.js 的
require、ES6 的import/export)将每个模块的变量限制在私有作用域中,避免全局污染。 - 效果:无需担心模块间变量同名冲突,因为它们不在同一个作用域中。
核心思路总结
- 核心原则:尽量减少全局变量,将变量限制在最小作用域内(如函数内部),利用作用域的 “隔离性” 避免命名冲突。
- 一句话概括:把变量 “关” 在小的作用域 “盒子” 里,别让它们在全局 “乱跑”,就不会互相 “打架” 了。
2.4 函数作用域
2.4.1 核心概念:函数表达式 vs 函数声明
1. 函数声明:
- 以
function开头,绑定到当前作用域(如全局作用域)。 - 缺点:污染命名空间,必须显式调用。
function foo() { ... } // 全局作用域可直接调用 foo()
2. 函数表达式:
- 用括号包裹
(function... ),绑定到函数自身内部,不污染外部作用域。 - 优点:可立即执行,避免全局命名冲突。
(function bar() { ... })(); // bar 仅在函数内部可见
2.4.2 匿名 vs 具名函数表达式
1. 匿名函数(如回调函数):
setTimeout(function() { ... }, 1000); // 无函数名
缺点:
- 调试时栈追踪显示
anonymous,难以定位问题。 - 代码可读性差。
2. 具名函数:
setTimeout(function timeoutHandler() { ... }, 1000); // 有函数名
优点:
- 栈追踪显示函数名,便于调试。
- 可直接通过
timeoutHandler引用自身。 - 代码更易理解。
2.4.3 立即执行函数表达式(IIFE)
语法:
(function() { ... })(); // 标准形式
(function() { ... }()); // 另一种形式(效果相同)
作用:
- 创建独立作用域,避免变量污染全局。
- 立即执行代码,无需显式调用。
进阶用法:
-
传递参数:
(function(global) { // global 即 window 对象 })(window); -
安全处理
undefined:undefined = true; // 给其他代码挖了一个大坑!绝对不要这样做! (function IIFE( undefined ) { var a; if (a === undefined) { console.log( "Undefined is safe here!" ); } })();
将一个参数命名为 undefined,但是在对应的位置不传入任何值,这样就可以保证在代码块中 undefined 标识符的值真的是 undefined。
- 倒置执行顺序(UMD 模式) :
(function(def) {
def(window); // 调用传入的函数
})(function(global) {
// 实际逻辑
});
一句话总结
- 函数表达式(尤其是 IIFE)通过创建独立作用域,避免全局变量污染,并可立即执行代码。
- 具名函数表达式比匿名函数更易调试和维护,建议优先使用。
- IIFE 是 JavaScript 中封装代码、隔离变量的经典模式,广泛用于模块化和避免命名冲突。
三、块作用域
什么是块作用域?
- 定义:指变量的作用域限制在代码块(如
if、for、{ }包裹的代码段)内部,块外无法访问。 - 其他语言对比:多数编程语言(如 Java、C++)支持块作用域,但传统 JavaScript 中
var声明的变量不受块限制,仅受函数或全局作用域限制。
JavaScript 中的块作用域 “陷阱”
1. var 声明的变量无块作用域
for (var i=0; i<10; i++) {
console.log(i); // 0-9
}
console.log(i); // 10(i 被暴露在外部作用域)
- 问题:
var i在for循环外仍可访问,可能导致意外修改或命名冲突。
2. if 块中的变量暴露
var foo = true;
if (foo) {
var bar = 2; // `bar` 实际属于外部作用域
}
console.log(bar); // 2(`bar` 在块外可见)
- 风险:本应 “块内使用” 的变量被意外暴露,违反 “最小作用域” 原则。
JavaScript 如何实现块作用域?
1. let 和 const(ES6 引入)
let声明块级变量
for (let i=0; i<10; i++) {
console.log(i); // 0-9
}
console.log(i); // 报错(`i` 不在外部作用域)
const声明块级常量:同理,作用域仅限所在块。
2. 传统 JavaScript 的替代方案
- 立即执行函数表达式(IIFE) :用函数作用域模拟块作用域。
(function() {
var tmp = "仅在块内可见";
console.log(tmp); // 可见
})();
console.log(tmp); // 报错(`tmp` 不在外部作用域)
总结
-
传统 JavaScript 痛点:
var声明的变量无块作用域,导致变量暴露和污染。 -
解决方案:
- ES6 及以上:用
let/const声明块级变量。 - 旧环境:用 IIFE 模拟块作用域。
- ES6 及以上:用
-
核心价值:让变量 “就近声明,限域使用”,提升代码整洁度和安全性。
一句话记忆:块作用域就像一个 “代码小房间”,let/const 声明的变量只能在房间里用,出了门就 “消失” 了,而 var 声明的变量会 “跑出门” 污染外面的空间。