在上节课中我们讲到了”先定义后执行” 这个基础概念。其他在专业术语中也叫变量提升,正是由于 JavaScript 存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷。
这种设计上的缺陷主要带来的问题有两个。
变量容易在不被察觉的情况下被覆盖掉
var myname = "max"
function showName(){
if(0){
var myname = "james"
}
console.log(myname);
}
showName()
执行上面这段代码,打印出来的是 undefined, 原因是在执行函数的过程中if 语句中定义的变量提升到函数作用域,但是并未赋值。 在函数体中访问 myname 变量因为本身作用域中有定义说以就不会去外层全局作用域中找, 这种方式很违反直觉。
我们一起来看下面这段 C 代码:
char* myname = "max";
void showName() {
printf("%s \n",myname);
if(0){
char* myname = "james";
}
}
int main(){
showName();
return 0;
}
上面这段 C 代码执行后,最终打印出来的是上面全局变量 myname 的值,之所以这样,是因为 C 语言是支持块级作用域的,所以 if 块里面定义的变量是不能被 if 块外面的语句访问到的。
本应销毁的变量没有被销毁
接下来我们再来看下面这段让人误解更大的代码:
function foo(){
for (var i = 0; i < 7; i++) {
}
console.log(i);
}
foo()
如果你使用 C 语言或者其他的大部分语言实现类似代码,在 for 循环结束之后,i 就已经被销毁了,但是在 JavaScript 代码中,i 的值并未被销毁,所以最后打印出来的是 7。
这同样也是由变量提升而导致的,在创建执行上下文阶段,变量 i 就已经被提升了,所以当 for 循环结束之后,变量 i 并没有被销毁。
这依旧和其他支持块级作用域的语言表现是不一致的,所以必然会给一些人造成误解。
为什么会这样?
var hoisting was the unintended consequence of function hoisting, no block scope, JS as a 1995 rush job. var变量提升功能是一个的意外结果,没有块作用域,JavaScript是1995年的紧急工作。 大概意思就是JavaScript在设计之初比较匆忙没想那么多。
参考地址:https://twitter.com/BrendanEich/status/522395336615428097
好在是 ES6 已经通过引入块级作用域并配合 let、const 关键字,来避开了这种设计缺陷,但是由于 JavaScript 需要保持向下兼容,所以变量提升在相当长一段时间内还会继续存在。
作用域
为了更好的了解变量提升的特性,我们还需要先理解作用域的概念。
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
在 ES6 之前,ES 的作用域只有两种:全局作用域和函数作用域。
- 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
- 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
在 ES6 之前,JavaScript 只支持这两种作用域,相较而言,其他语言则都普遍支持块级作用域。块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。
为了更好地理解块级作用域,你可以参考下面的一些示例代码:
//if块if(1){}
//while块while(1){}
//函数块function foo(){}
//for循环块for(let i = 0; i<100; i++){}
//单独一个块{}
简单来讲,如果一种语言支持块级作用域,那么其代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁。
ES6 是如何解决变量提升带来的缺陷
刚刚我们讲到ES6 引入了 let 和 const 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域。
关于 let 和 const 的用法,你可以参考下面代码:
let x = 5
const y = 6
x = 7
y = 9 //报错,const声明的变量不可以修改
从这段代码你可以看出来,两者之间的区别是,使用 let 关键字声明的变量是可以被改变的,而使用 const 声明的变量其值是不可以被改变的。但不管怎样,两者都可以生成块级作用域。
接下来,我们就通过实际的例子来分析下,ES6 是如何通过块级作用域来解决上面的问题的。
先参考下面这段使用 var 的代码:
function varTest() {
var x = 1;
if (true) {
var x = 2; // 同样的变量!
console.log(x); // 2
}
console.log(x); // 2
}
使用了 let 之后的代码:
function letTest() {
let x = 1;
if (true) {
let x = 2; // 不同的变量
console.log(x); // 2
}
console.log(x); // 1
}
这是因为 let 关键字是支持块级作用域的,所以在编译阶段,JavaScript 引擎并不会把 if 块中通过 let 声明的变量存放到变量环境中,这也就意味着在 if 块通过 let 声明的关键字,并不会提升到全函数可见。所以在 if 块之内打印出来的值是 2,跳出语块之后,打印出来的值就是 1 了。这种就非常符合我们的编程习惯了:作用域块内声明的变量不影响块外面的变量。
JavaScript 是如何支持块级作用域的
现在你知道了 ES 可以通过使用 let 或者 const 关键字来实现块级作用域,不过你是否有过这样的疑问:“在同一段代码中,ES6 是如何做到既要支持变量提升的特性,又要支持块级作用域的呢?”
那是因为函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。通过 let 声明的变量,在编译阶段会被存放到词法环境中,它们都是独立的存在。
当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 a ,在该作用域块内部也声明了变量 a ,当执行到作用域内部时,它们都是独立的存在互不影响。
当代码执行到要去查询某个变量时,就需要在词法环境和变量环境中查找变量 a 的值了,具体查找方式是:如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。
当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出。
总结:
通过上面的分析,想必你已经理解了,块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。