📝 JavaScript 作用域和变量提升
2025-01-22 08:19:30    3.1k 字   
This post is also available in English and alternative languages.

浏览器中的JavaScript执行机制:09 | 块级作用域:var缺陷以及为什么要引入let和const?


1. 作用域(scope)

作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期

在ES6之前,ES的作用域只有两种:「全局作用域」和「函数作用域」。

  • 「全局作用域」中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
  • 「函数作用域」就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

在ES6之前,JavaScript 只支持这两种作用域(「全局作用域」和「函数作用域」),相较而言,其他语言则都普遍支持「块级作用域」


1.1. 块级作用域

「块级作用域」就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个 {} 都可以被看作是一个「块级作用域」。

为了更好地理解「块级作用域」,可以参考下面的一些示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//if块
if(1){}

//while块
while(1){}

//函数块
function foo(){

//for循环块
for(let i = 0; i<100; i++){}

//单独一个块
{}

简单来讲,如果一种语言支持「块级作用域」,那么其代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁。

比如下面这段C代码:

1
2
3
4
5
6
7
8
9
10
11
12
char* myname = "极客时间";
void showName() {
printf("%s \n",myname);
if(0){
char* myname = "极客邦";
}
}

int main(){
showName();
return 0;
}

上面这段C代码执行后,最终打印出来的是上面全局变量 myname 的值,之所以这样,是因为C语言是支持「块级作用域」的,所以 if 块里面定义的变量是不能被 if 块外面的语句访问到的。

ES6之前是不支持「块级作用域」的,而把作用域内部的变量统一提升无疑是最快速、最简单的设计,不过这也直接导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的,这也就是 JavaScript 中的 变量提升


2. 变量提升

在了解变量提升之前,先来看看什么是 JavaScript 中的声明和赋值。

2.1. 变量的声明和赋值

1
var myname = '极客时间'

上面这段代码,可以把它看成是两行代码组成的,如下代码:

1
2
var myname    //声明部分
myname = '极客时间' //赋值部分

参考下图:


2.2. 函数的声明和赋值

1
2
3
4
5
6
7
function foo(){
console.log('foo')
}

var bar = function(){
console.log('bar')
}

第一个函数 foo() 是一个完整的函数声明,也就是说没有涉及到赋值操作;

第二个函数是先声明变量 bar,再把 function(){console.log('bar')} 赋值给 bar

参考下图:

2

2.3. 变量提升

了解了 JavaScript 中的声明和赋值后,接下来就可以聊聊什么是变量提升了。

在 JavaScript 代码执行之前,解释器会将 变量 和 函数 的声明部分移动到它所在作用域的最顶部,这个过程就叫做变量提升。变量被提升后,会赋予变量默认值,这个默认值就是熟悉的 undefined

需要注意的是,只有 变量 和 函数 的声明会被提升,而它们的赋值或初始化则不会。换句话说,在 变量函数 声明之前就尝试访问它们,得到的值将是 undefined

模拟下实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ----- 变量提升前 -----
showName()
console.log(myname)
var myname = '极客时间'
function showName() {
console.log('showName被调用');
}

// ----- 模拟变量提升 -----
var myname = undefined
function showName() {
console.log('showName被调用');
}
showName()
console.log(myname)
myname = '极客时间'

如下图:

3

从图中可以看出,对原来的代码主要做了两处调整:

  1. 把声明的部分都提升到了代码开头,如变量 myname 和函数 showName,并给变量设置默认值 undefined
  2. 移除原本声明的变量和函数,如var myname = '极客时间’的语句,移除了 var 声明,整个移除 showName 的函数声明

通过这两步,就可以实现变量提升的效果。你也可以执行这段模拟变量提升的代码,其输出结果和第一段代码应该是完全一样的。


2.4. 变量提升所带来的问题

由于变量提升的作用,使用 JavaScript 来编写和其他语言相同逻辑的代码,都有可能会导致不一样的执行结果。为什么会出现这种情况呢?主要有以下两种原因:变量在不被察觉的情况下被覆盖掉本应销毁的变量没有被销毁


2.4.1. 变量在不被察觉的情况下被覆盖

比如重新使用 JavaScript 来实现上面那段C代码,实现后的 JavaScript 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ----- 变量提升前 -----
var myname = "极客时间"
function showName(){
console.log(myname);
if(0){
var myname = "极客邦"
}
console.log(myname);
}
showName()

// ----- 模拟变量提升 -----
var myname = "极客时间";
function showName() {
var myname; // 函数作用域内的变量声明被提升
console.log(myname); // 输出 undefined
if (0) {
myname = "极客邦"; // 赋值不会被提升
}
console.log(myname); // 输出 undefined
}
showName();

首先当刚执行到 showName 函数调用时,调用栈状态如下图所示:

4

showName 函数的执行上下文创建后,JavaScript 引擎便开始执行 showName 函数内部的代码了。

首先执行的是:console.log(myname); 。执行这段代码需要使用变量 myname,结合上面的调用栈状态图,可以看到这里有两个 myname 变量:一个在全局执行上下文中,其值是"极客时间";另外一个在 showName 函数的执行上下文中,其值是 undefined。那么到底该使用哪个呢?

先使用函数执行上下文里面的变量! 这是因为在函数执行过程中,JavaScript 会优先从当前的执行上下文中查找变量,由于变量提升,当前的执行上下文中就包含了变量 myname,而值是 undefined,所以获取到的 myname 的值就是 undefined

这输出的结果和其他大部分支持块级作用域的语言都不一样,比如上面 C 语言输出的就是全局变量,所以这会很容易造成误解,特别是在你会一些其他语言的基础之上,再来学习 JavaScript,你会觉得这种结果很不自然。


2.4.2. 本应销毁的变量没有被销毁

在控制台运行如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ----- 变量提升前 -----
function foo(){
for (var i = 0; i < 7; i++) {
}
console.log(i);
}
foo()


// ----- 模拟变量提升 -----
function foo() {
var i; // 变量 i 被提升并声明
for (i = 0; i < 7; i++) {} // 循环初始化表达式 i = 0 并执行循环
console.log(i); // 输出 i 的最终值 7
}
foo();

如果使用 C 语言或者其他的大部分语言实现类似代码,在 for 循环结束之后, i 就已经被销毁了,但是在 JavaScript 代码中,i 的值并未被销毁,所以最后打印出来的是 7。

这同样也是由变量提升而导致的,在创建执行上下文阶段,变量 i 就已经被提升了,所以当 for 循环结束之后,变量 i 并没有被销毁。

这依旧和其他支持块级作用域的语言表现是不一致的,所以必然会给一些人造成误解。


2.5. 解决变量提升带来的缺陷

ES6 通过引入了 letconst 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域。

先看下面的这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ----- 变量提升前 -----
function varTest() {
var x = 1;
if (true) {
var x = 2; // 同样的变量!
console.log(x); // 2
}
console.log(x); // 2
}

// ----- 模拟变量提升 -----
function varTest() {
var x; // 变量 x 被提升并声明
x = 1; // 赋值语句不会被提升
if (true) {
x = 2; // 同样的变量!
console.log(x); // 输出 2
}
console.log(x); // 输出 2
}

上面在这段代码中,有两个地方都定义了变量 x,第一个地方在函数块的顶部,第二个地方在 if 块的内部,由于 var 的作用范围是整个函数,所以在编译阶段,会生成如下的执行上下文:

5

从执行上下文的变量环境中可以看出,最终只生成了一个变量 x,函数体内所有对 x 的赋值操作都会直接改变变量环境中的 x 值。

所以上述代码最后通过 console.log(x) 输出的是 2,而对于同样逻辑的代码,其他语言最后一步输出的值应该是 1,因为在 if 块里面的声明不应该影响到块外的变量。

既然支持块级作用域和不支持块级作用域的代码执行逻辑是不一样的,那么就改造上面的代码,让其支持块级作用域:

1
2
3
4
5
6
7
8
9
function letTest() {
let x = 1;
if (true) {
let x = 2; // 不同的变量
console.log(x); // 2
}
console.log(x); // 1
}
letTest()

在控制台执行这段代码,其输出结果和预期是一致的。这是因为 let 关键字是支持块级作用域的,所以在编译阶段,JavaScript 引擎并不会把 if 块中通过 let 声明的变量存放到变量环境中,这也就意味着在 if 块通过 let 声明的关键字,并不会提升到全函数可见。

所以在 if 块之内打印出来的值是 2,跳出语块之后,打印出来的值就是 1 了。这种就非常符合我们的编程习惯了,作用域块内声明的变量不影响块外面的变量。


2.6. var、let和const区别

  • var
    • 作用域:全局或函数作用域(在全局代码中声明就是全局变量;在函数中声明就是局部函数变量)。
    • 变量提升:会发生变量提升;在声明之前可以访问变量(值是 undefined)。
    • 重复声明:允许重复声明同一个变量;后面的声明会覆盖前面的声明。
    • 值的修改:可以修改值。
  • let
    • 作用域:块级作用域(在代码块 {}、循环或判断语句中声明,只在该块级内有效)。
    • 变量提升:不会发生变量提升;必须先声明后使用,否则会报错。
    • 重复声明:不允许在同一作用域内重复声明,否则会报错。
    • 值的修改:可以修改值。
  • const
    • 作用域:块级作用域(在代码块 {}、循环或判断语句中声明,只在该块级内有效)。
    • 变量提升:不会发生变量提升;必须先声明后使用,否则会报错。
    • 重复声明:不允许在同一作用域内重复声明,否则会报错。
    • 值的修改:声明的常量一旦赋值后,值不能修改。但如果是复合类型(如对象或数组),其属性或元素是可修改的。

总的来说,var 存在一些设计缺陷,ES6 推出 letconst 是为了解决这些问题,让变量更符合编程直觉。一般建议使用 let 去取代 var ,使用 const 声明常量以提高代码质量。