本文深入解析 JavaScript 中的变量提升(Hoisting)机制,解释为何变量声明会被提升至作用域顶部。通过对比函数声明与函数表达式,阐明命名提升与定义分离的差异,并提供利用立即执行函数(IIFE)创建块级作用域以规避提升陷阱的实用技巧。
JavaScript 变量提升与函数作用域详解
Scoping & Hoisting
var a = 1;
function foo() {
if (!a) {
var a = 2;
}
alert(a);
};
foo();
上述代码在执行时会输出什么结果?
虽然这对资深开发者而言轻而易举,但为了帮助初学者理清思路,我们不妨顺着常见的直觉进行推演:
1. 首先创建全局变量 a,并将其初始值设为 1。 2. 接着定义函数 foo。 3. 进入 foo 函数体内部,由于变量 a 存在,表达式 !a 会将其转换为布尔类型的 false。因此,if 语句的条件不成立,其内部代码块不会执行。 4. 程序跳过条件分支,执行 alert 输出变量 a 的值,最终结果预期为输出 1。
看似逻辑严密的推导,结果却令人意外:实际输出竟然是 2!这是为什么呢?
无需惊讶,这并非程序错误,而是 JavaScript 解释器的一种(非官方标准)行为特性。Ben Cherry 将其命名为:Hoisting(中文常译作“提升”)。
声明与定义的区别
声明与定义
为了透彻理解 Hoisting,我们先观察一个基础场景:
var a = 1;
你是否思考过,这行代码在运行时究竟发生了什么? 你是否清楚,对于这行代码,“声明变量 a”与“定义变量 a”这两个概念中,哪一个描述更为准确? • 以下代码示例被称为“声明变量”:
var a;
• 以下代码示例被称为“定义变量”:
var a = 1;
• 声明(Declaration):指告知解释器某个变量或函数存在,但不提供具体实现细节,仅通知解释器该标识符已注册。 • 定义(Definition):指明具体的实现内容,例如变量的具体数值或函数的代码体,确切地表达了该实体的意义。
总结如下:
var a; // 这是声明 a = 1; // 这是定义(赋值) var a = 1; // 合二为一:声明变量的存在并赋值给它
关键点在于:当你认为只执行了一件事(var a = 1)时,解释器实际上将其拆解为两个步骤:首先是声明(var a),其次是定义(a = 1)。
这与 Hoisting 有何关联?
回到最初那个令人困惑的例子,解释器处理代码的逻辑如下:
var a;
a = 1;
function foo() {
var a; // 关键在这里
if (!a) {
a = 2;
}
alert(a); // 此时的 a 并非函数体外的那个全局变量
}
如代码所示,进入函数体后,解释器首先声明了局部变量 a。无论 if 语句的条件如何判断,都会为这个局部变量 a 赋值为 2。若存疑,可在函数体外 alert(a),再执行 foo() 对比结果即可验证。
函数作用域与 Hoisting 机制
Scoping(作用域)
有人可能会问:“为什么变量 a 不是在 if 语句块内声明的呢?”
因为 JavaScript 不具备块级作用域(Block Scoping),仅具备函数作用域(Function Scoping)。这意味着并非看到一对花括号 {} 就代表生成了新的作用域,这与 C 语言不同。
当解析器遇到 if 语句时,发现其中包含变量声明,于是将该声明提升至当前作用域的顶部(这是默认且不可更改的行为),这种行为即称为 Hoisting。
OK,概念大家都明白了,你真正理解了吗……
理解不等于掌握。以最初的例子为例,如果我希望 alert(a) 输出那个 1 该怎么办?
利用 IIFE 创建局部作用域
创建新的作用域
当执行 alert(a) 时,它会从当前作用域开始向上(向外)查找变量 a,直至顶层作用域。若找不到则返回 undefined。
由于在 alert(a) 的同级作用域中重新声明了局部变量 a,导致输出 2。因此,我们可以将局部变量 a 的声明向下(向内)移动,使得 alert(a) 无法访问到它。
记住:JavaScript 只有函数作用域!
var a = 1;
function foo() {
if (!a) {
(function() { // 这是上一篇说到过的 IIFE,它会创建一个新的函数作用域
var a = 2; // 并且该作用域在 foo() 的内部,所以 alert 访问不到
}()); // 不过这个作用域可以访问上层作用域哦,这就叫:“闭包”
};
alert(a);
};
foo();
作用域中命名的优先级
你可能在无数 JavaScript 书籍和文章中读到:“请始终保持作用域内所有变量的声明放置在作用域的顶部”。现在你应明白此建议的原因:这样可以避免 Hoisting 特性带来的困扰(尽管 Hoisting 本身并无过错),并能明确告知代码阅读者(包括你自己)当前作用域内可访问的变量。然而,变量声明的提升并非 Hoisting 的全部。在 JavaScript 中,有四种方式使命名进入作用域(按优先级排列):
1. 语言定义的命名:如 this 或 arguments,它们在所有作用域内有效且优先级最高,因此严禁将变量命名为 this 等保留字,否则无意义。 2. 形式参数:函数定义时声明的形式参数会作为变量被 hoisting 至该函数的作用域内。因此形式参数属于本地变量,而非外部或全局变量。当然,执行函数时可传入外部变量,但传入后即变为本地变量。 3. 函数声明:函数体内部可声明函数,但它们同样属于本地作用域。 4. 变量声明:优先级最低,但最为常用。
另外,回顾之前讨论过的“声明”与“定义”的区别?当时未解释其原因,现在揭晓:
Hoisting 只提升了命名,没有提升定义
这一点与接下来要讲的内容息息相关,请看:
函数声明与函数表达式的差异
函数声明与函数表达式的差别
先看两个例子:
function test() {
foo();
function foo() {
alert("我是会出现的啦……");
}
}
test();
function test() {
foo();
var foo = function() {
alert("我不会出现的哦……");
}
}
test();
在了解了 Scoping & Hoisting 之后,你能解释这一切了吗?
在第一个例子中,函数 foo 是一个声明。既然是声明,它就会被提升(此处特意包裹了一个外层作用域,因为全局作用域需要更多想象,不如局部直观,但原理相同)。因此在执行 foo() 之前,作用域已知晓函数 foo 的存在。这称为函数声明(Function Declaration),函数声明会将命名和函数体一同提升至作用域顶部。
而在第二个例子中,被提升的仅仅是变量名 foo,其定义仍停留在原处。因此在执行 foo() 之前,作用域仅知道 foo 的命名,不知其具体类型,故执行会报错(通常为:undefined is not a function)。这称为函数表达式(Function Expression),函数表达式仅提升命名,不提升函数体定义。
尾记:Ben Cherry 的原文解释更为详尽,但为英文。本文借花献佛,旨在为初学者提供更浅显的解释。若需查看更多示例,请移步原作,谢谢。