为什么在JS中存在变量提升
先从作用域说起,作用域是指在程序中定义变量或函数的区域,其决定了变量和函数的可见性和生命周期。
在 ES6 之前,JS的作用域只有两种: 全局作用域 和 函数作用域。
- 全局作用域 中的对象在代码中的任何地方都可以访问,其生命周期与页面的生命周期相同;
- 函数作用域 就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部的变量会被销毁(闭包除外)。
在 ES6 之前,JS只支持这两种作用域,相较而言,其他语言则都普遍支持 块级作用域。 块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个 { } 也可以看作是一个块级作用域。
//if块
if(1){}
//while块
while(1){}
//函数块
function foo(){}
//for循环块
for(let i = 0; i<100; i++){}
//单独一个块
{}
如果一种语言支持块级作用域,那么其代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完毕之后,内部变量也随之被销毁。
char* myname = "极客时间";
void showName() {
printf("%s \n",myname);
if(0){
char* myname = "极客邦";
}
}
int main(){
showName();
return 0;
}
如上面C代码所示,最终打印的值为全局变量的 myname
的值。这是因为C语言是支持块级作用域的,所以 if
块里面定义的变量是不能被 if
块外面的语句访问到的。
与C语言不同,ES6之前 JS是不支持块级作用域的,大概率是因为当初设计这门语言的时候,并没有想到JS会火起来,所以只是按照最简单的方式来设计。没有块级作用域,再把作用域内部的变量统一提升无疑是最快速、简单的设计。
变量提升带来的缺陷
JS的变量提升特性,会导致代码的执行结果与其他语言(或直觉)的结果相违背。
这是因为无论函数中的变量在哪里声明,在编译阶段都会被提升到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都可以被访问。进而导致带来如下问题:
-
变量容易在不被察觉的情况下被覆盖掉
比如上面的C代码,用JS实现后
var myname = "极客时间" function showName(){ console.log(myname); if(0){ var myname = "极客邦" } console.log(myname); } showName()
其执行结果就会输出
undefined
,而并不会像C函数那样输出"极客时间"。为什么输出
undefined
?当执行到
showName函数
调用时,执行上下文和调用栈的状态如图所示:在执行到
console.log(myname);
时, 调用栈中有两个myname
变量,一个在全局上下文(极客时间),一个在showName函数
上下文(undefined)。正是由于变量提升,showName函数
上下文中就包含了变量myname
,而其值是undefined
, 所以输出的结果是undefined
。这样的结果与其他大部分支持块级作用域语言的结果都不一样,很容易造成误解。
-
本应销毁的变量而未被销毁
function foo(){ for (var i = 0; i < 7; i++) { } console.log(i); } foo()
如果用C语言或其他大部分语言实现类似代码,在
for
循环结束之后,i
就已经被销毁了,但是在JS代码中,i
的值并未被销毁,所以最后打印结果为 7。这也是由变量提升而导致的,在创建执行上下文阶段,变量
i
就已经被提升了,所以当for
循环结束之后,变量i
并没有被销毁。
ES6 如何解决变量提升带来的缺陷
为了解决变量提升带来的一系列问题, ES6 引入了 let
和 const
关键字,从而使JS也能像其他语言一样拥有了 块级作用域。
let x = 5
const y = 6
x = 7
y = 9 //报错,const声明的变量不可以修改
使用 let
关键字声明的变量是可以被改变的,而使用 const
声明的变量其值是不可以被改变的。
对于下面代码,用 var
声明的变量:
function varTest() {
var x = 1;
if (true) {
var x = 2; // 同样的变量!
console.log(x); // 2
}
console.log(x); // 2
}
两个地方都定义了变量 x
,第一个地方在函数块的顶部,第二个地方在 if
块的内部,由于 var
的作用范围是整个函数,所以在编译阶段,会生成如下的执行上下文:
从执行上下文的变量环境中可以看出,最终只生成了一个变量 x
,函数体内所有对 x
的赋值操作都会直接改变变量环境中的 x
值。最终输出的结果为 2。而对于相同逻辑的代码,其他语言最终输出的结果为 1。
ES6之后,如何得到像其他语言一样的结果呢?只需要把 var
关键字替换为 let
关键字。
function letTest() {
let x = 1;
if (true) {
let x = 2; // 不同的变量
console.log(x); // 2
}
console.log(x); // 1
}
执行以上代码, if
块内部输出结果为 2, 最终输出结果为 1。
这是因为 let
关键字是支持块级作用域的,所以在编译阶段,JS引擎并不会把 if
块中通过 let
声明的变量存放到变量环境中,这也就意味着在 if
块中通过 let
声明的关键字,并不会提升到全函数可见。所以在 if
块之内打印出来的值是 2,跳出语块之后,打印出来的值就是 1 了。这种就非常符合我们的编程习惯了:作用域块内声明的变量不影响块外面的变量。
JS 是如何支持块级作用域的
在同一段代码中, JS如何做到既要支持变量提升,又要支持块级作用域?
JS引擎是通过变量环境来实现函数级作用域的,在ES6之后,JS引入了词法环境来实现块级作用域。
function foo(){
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a)
console.log(b)
}
console.log(b)
console.log(c)
console.log(d)
}
foo()
当执行这段代码时,JS引擎会先对其进行编译并创建执行上下文,然后再按照顺序执行代码。
首先编译代码,并创建执行上下文,其执行上下文的内容如图所示:
从图中可以看出:
-
函数内通过
var
声明的变量,在编译阶段全都被存放到变量环境里面了。 -
通过
let
声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。 -
在函数内的块级作用域内部,通过
let
声明的变量并没有(或者说还没有)被存放到词法环境中。
然后执行代码。
当执行到foo
函数的块级作用域内部时, 变量环境中 a
的值已被设为1,词法环境中 b
的值已被设为2,此时函数的执行上下文如下:
块级作用域中通过 let
声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b
,在该作用域块内部也声明了变量 b
,当执行到作用域内部时,它们都是独立的存在。
其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。需要注意下,这里所讲的变量是指通过 let
或者 const
声明的变量。
1️⃣ 这里在进入块级作用域后并不会再有编译的过程,抽象语法树在进入函数阶段就生成了,并且函数内部的作用域是已经明确的,早在进入foo函数时就已完成了整个函数的编译,只不过通过let或const声明的变量会在进入块级作用域的时候被创建,但是在该变量没有赋值之前,引用该变量JS引擎会抛出异常--暂时性死区
2️⃣ 编译时变量环境和词法环境最顶层数据已经确定了。当执行到块级作用域的时候,块级作用域中通过let和const申明的变量会被追加到词法环境中,当这个块执行结束之后,追加到词法作用域的内容又会销毁掉。
再接下来,当执行到作用域块中的console.log(a)
这行代码时,就需要在词法环境和变量环境中查找变量 a
的值了。
具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JS引擎,如果没有查找到,那么继续在变量环境中查找。
这样一个变量查找过程就完成了,你可以参考下图:
当块级作用域执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下图所示:
小结
由于JS 变量提升特性存在着变量覆盖、变量污染等设计缺陷,所以 ES6 引入了块级作用域关键字( let
/ const
)来解决这些问题。
- 对于
var
声明的变量,其创建和初始化会被提升,赋值不会提升; - 对于
let
声明的变量,其创建会被提升,初始化和赋值不会被提升; - 对于函数,其创建、初始化和赋值均会提升。
块级作用域是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。
需要注意的是变量在创建和初始化之间,会存在一段暂时性死区,这时间段内,JS引擎不允许对该变量访问(也就是在赋值前读取),否则会抛出“Uncaught ReferenceError: Cannot access 'myname' before initialization”错误。所以,对于 let/const
声明的变量在初始化之前不允许访问。
let myname= '极客时间'
{
console.log(myname) //Uncaught ReferenceError!!!
let myname= '极客邦'
}