变量提升
什么是变量提升
- 在介绍变量提升之前,先来看看什么是 JavaScript 中的声明和赋值;
- 变量的声明和赋值:
var myname = '张三'
- 这段代码可以把它看成是两行代码组成的:
- 如下图所示:
- 函数的声明和赋值:
function foo(){ console.log('foo') } var bar = function(){ console.log('bar') }
- 第一个函数 foo 是一个完整的函数声明,也就是说没有涉及到赋值操作;第二个函数是先声明变量 bar,再把
function(){console.log('bar')}
赋值给 bar; - 为了直观理解,可以参考下图:
- 第一个函数 foo 是一个完整的函数声明,也就是说没有涉及到赋值操作;第二个函数是先声明变量 bar,再把
- 变量的声明和赋值:
- 所谓的 变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的 “行为”;变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 undefined;
- 为了模拟变量提升的效果,对代码做了以下调整,如下图:
JavaScript 代码的执行流程
编译阶段
-
那么编译阶段和变量提升存在什么关系呢?为了搞清楚这个问题,还是回过头来看上面那段模拟变量提升的代码,为了方便介绍,可以把这段代码分成两部分;
-
第一部分:变量提升部分的代码;
var myname = undefined function showName() { console.log('函数 showName 被执行'); }
-
第二部分:执行部分的代码;
showName() console.log(myname) myname = '张三'
-
下面就可以把 JavaScript 的执行流程细化,如下图所示:
- 从上图可以看出,输入一段代码,经过编译后,会生成两部分内容:执行上下文 (Execution context) 和可执行代码;
- 执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等;
- 可以简单地把变量环境对象看成是如下结构:
VariableEnvironment: myname -> undefined, showName ->function : {console.log(myname)}
- 了解完变量环境对象的结构后,接下来再结合下面这段代码来分析下是如何生成变量环境对象的:
showName() console.log(myname) var myname = '张三' function showName() { console.log('函数 showName 被执行'); }
- 第 1 行和第 2 行,由于这两行代码不是声明操作,所以 JavaScript 引擎不会做任何处理;
- 第 3 行,由于这行是经过 var 声明的,因此 JavaScript 引擎将在环境对象中创建一个名为 myname 的属性,并使用 undefined 对其初始化;
- 第 4 行,JavaScript 引擎发现了一个通过 function 定义的函数,所以它将函数定义存储到堆 (HEAP) 中,并在环境对象中创建一个 showName 的属性,然后将该属性值指向堆中函数的位置;
- 这样就生成了变量环境对象;接下来 JavaScript 引擎会把声明以外的代码编译为字节码;
执行阶段
-
JavaScript 引擎开始执行 “可执行代码”,按照顺序一行一行地执行;
-
下面就来一行一行分析下这个执行过程:
- 当执行到 showName 函数时,JavaScript 引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以 JavaScript 引擎便开始执行该函数,并输出 “函数 showName 被执行” 结果;
- 接下来打印 “myname” 信息,JavaScript 引擎继续在变量环境对象中查找该对象,由于变量环境存在 myname 变量,并且其值为 undefined,所以这时候就输出 undefined;
- 接下来执行第 3 行,把 “张三” 赋给 myname 变量,赋值后变量环境中的 myname 属性值改变为 “张三”;
代码中出现相同的变量或者函数怎么办
-
先看下面这样一段代码:
function showName() { console.log('李四'); } showName(); function showName() { console.log('张三'); } showName();
-
分析上述代码完整执行流程:
- 首先是编译阶段:
- 遇到了第一个 showName 函数,会将该函数体存放到变量环境中;
- 接下来是第二个 showName 函数,继续存放至变量环境中,但是变量环境中已经存在一个 showName 函数了,此时,第二个 showName 函数会将第一个 showName 函数覆盖掉;
- 这样变量环境中就只存在第二个 showName 函数了;
- 接下来是执行阶段:
- 先执行第一个 showName 函数,但由于是从变量环境中查找 showName 函数,而变量环境中只保存了第二个 showName 函数,所以最终调用的是第二个函数,打印的内容是 “张三”;
- 第二次执行 showName 函数也是走同样的流程,所以输出的结果也是 “张三”;
- 首先是编译阶段:
特点总结
JavaScript 代码执行过程中,需要先做变量提升,而之所以需要实现变量提升,是因为 JavaScript 代码在执行之前需要先编译;
在编译阶段,变量和函数会被存放到变量环境中,变量的默认值会被设置为 undefined;在代码执行阶段,JavaScript 引擎会从变量环境中去查找自定义的变量和函数;
如果在编译阶段,存在两个相同的函数,那么最终存放在变量环境中的是最后定义的那个,这是因为后定义的会覆盖掉之前定义的;
块级作用域
var 缺陷以及为什么要引入 let 和 const
-
正是由于 JavaScript 存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷;
-
虽然 ES6 已经通过引入块级作用域并配合 let、const 关键字,来避开了这种设计缺陷,但是由于 JavaScript 需要保持向下兼容,所以变量提升在相当长一段时间内还会继续存在;
-
为了更好地理解和学习,这篇文章会先 “探病因” ——分析为什么在 JavaScript 中会存在变量提升,以及变量提升所带来的问题;然后再来 “开药方” ——介绍如何通过块级作用域并配合 let 和 const 关键字来修复这种缺陷;
作用域
-
为什么 JavaScript 中会存在变量提升这个特性,而其他语言似乎都没有这个特性呢?要讲清楚这个问题,就得先从作用域讲起;
-
作用域:是指在程序中定义变量的区域,该位置决定了变量的生命周期 (通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期);
-
在 ES6 之前,ES 的作用域只有两种:
- 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期;
- 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问;函数执行结束之后,函数内部定义的变量会被销毁;
-
在 ES6 之前,JavaScript 只支持这两种作用域,相较而言,其他语言则都普遍支持块级作用域;其代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁;
//if 块 if(1){} //while 块 while(1){} // 函数块 function foo(){ //for 循环块 for(let i = 0; i<100; i++){} // 单独一个块 {}
-
和 Java、C/C++ 不同,ES6 之前是不支持块级作用域的,因为当初设计这门语言的时候,并没有想到 JavaScript 会火起来,所以只是按照最简单的方式来设计;没有了块级作用域,再把作用域内部的变量统一提升无疑是最快速、最简单的设计,不过这也直接导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的,这也就是 JavaScript 中的变量提升;
变量提升所带来的问题
变量容易在不被察觉的情况下被覆盖掉
-
执行下面这段代码,打印出来的是 undefined:
var myname = "张三" function showName(){ console.log(myname); if(0){ var myname = "李四" } console.log(myname); } showName();
-
首先当刚执行到 showName 函数调用时,执行上下文和调用栈的状态如下:
-
showName 函数的执行上下文创建后,JavaScript 引擎便开始执行 showName 函数内部的代码了;首先执行的是第三行代码:
console.log(myname);
-
执行这段代码需要使用变量 myname,结合上面的调用栈状态图,可以看到这里有两个 myname 变量:一个在全局执行上下文中,其值是 “张三”;另外一个在 showName 函数的执行上下文中,其值是 undefined;
-
“当然是先使用函数执行上下文里面的变量啦!” 的确是这样,这是因为在函数执行过程中,JavaScript 会优先从当前的执行上下文中查找变量,由于变量提升,当前的执行上下文中就包含了变量 myname,而值是 undefined,所以获取到的 myname 的值就是 undefined;
本应销毁的变量没有被销毁
-
接下来再来看下面这段让人误解更大的代码:在 for 循环结束之后,i 的值并未被销毁,所以最后打印出来的是 7
function foo(){ for (var i = 0; i < 7; i++) { } console.log(i); } foo()
-
这同样也是由变量提升而导致的,在创建执行上下文阶段,变量 i 就已经被提升了,所以当 for 循环结束之后,变量 i 并没有被销毁;
ES6 是如何解决变量提升带来的缺陷
-
为了解决上述问题,ES6 引入了 let 和 const 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域;
-
先参考下面这段存在变量提升的代码:
function varTest() { var x = 1; if (true) { var x = 2; // 同样的变量! console.log(x); // 2 } console.log(x); // 2 }
- 在这段代码中,有两个地方都定义了变量 x,第一个地方在函数块的顶部,第二个地方在 if 块的内部,由于 var 的作用范围是整个函数,所以在编译阶段,会生成如下的执行上下文:
- 从执行上下文的变量环境中可以看出,最终只生成了一个变量 x,函数体内所有对 x 的赋值操作都会直接改变变量环境中的 x 值;
- 所以上述代码最后通过 console.log(x) 输出的是 2,而对于相同逻辑的代码,其他语言最后一步输出的值应该是 1,因为在 if 块里面的声明不应该影响到块外面的变量;
- 在这段代码中,有两个地方都定义了变量 x,第一个地方在函数块的顶部,第二个地方在 if 块的内部,由于 var 的作用范围是整个函数,所以在编译阶段,会生成如下的执行上下文:
-
改造上面的代码,让其支持块级作用:
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 是如何支持块级作用域的
-
在同一段代码中,ES6 是如何做到既要支持变量提升的特性,又要支持块级作用域的呢?
-
先看下面这段代码:
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()
-
接下来一步步分析上面这段代码的执行流程:
- 第一步是编译并创建执行上下文,下面是 foo 执行上下文示意图,可以得出以下结论:
- 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了;
- 通过 let 声明的变量,在编译阶段会被存放到词法环境 (Lexical Environment) 中;
- 在函数的作用域内部,通过 let 声明的变量并没有被存放到词法环境中;
- 第二步继续执行代码,当执行到代码块里面时,变量环境中 a 的值已经被设置成了 1,词法环境中 b 的值已经被设置成了 2,这时候函数的执行上下文就如下图所示:
- 从图中可以看出,当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在;
- 其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构;
- 再接下来,当执行到作用域块中的 console.log(a) 这行代码时,就需要在词法环境和变量环境中查找变量 a 的值了,具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找;这样一个变量查找过程就完成了,可以参考下图:
- 当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下图所示:
- 通过上面的分析,想必已经理解了词法环境的结构和工作机制,块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了;
- 第一步是编译并创建执行上下文,下面是 foo 执行上下文示意图,可以得出以下结论:
声明变量关键字
在 JavaScript 中,一共存在 3 种声明变量的方式:var、let、const;
之所以有 3 种方式,这是由于历史原因造成的,最初声明变量的关键字就是 var ,但是为了解决作用域的问题,所以后面新增了 let 和 const 的方式;
var 关键字
变量提升
在栈内存(作用域)形成,JS 代码自上而下执行之前,浏览器会把所有带 「VAR」 和 「FUNCTION」 关键字进行提前声明或者定义;
- 声明(declare):
var a
(默认值是 undefined)、function sum
(值是 16 进制堆内存地址);- 定义(defined):
a = 12
(定义其实就是赋值);变量提升只发生在当前作用域
- 加载页面的时候只对全局作用域下的变量进行变量提升,因为此时的函数中存储的都是 代码字符串 而已;
- 在全局作用域下、私有作用域下声明的函数或变量(带 VAR 或 FUNCTION 的才是声明);
- 浏览器很懒,做过的事情不会重复执行第二遍 (当代码遇到创建函数代码,直接跳过,因为在变量提升阶段已经 声明并定义 了);
- ES3/ES5 规范: 只有全局作用域和函数执行的私有作用域(栈内存),其他花括号不会形成栈内存;
带不带 VAR 的区别
全局作用域
带 var
(本质是变量):在全局作用域下声明一个变量也相当于给 window 设置了一个属性,属性值为变量值,在变量提升阶段,就已经把变量设置成 window 的属性了(let 声明的变量不会给 window 设置该属性),全局变量修改 window 属性也修改,反之同样修改;不带 var
:本质是 window 下的属性,a = 12
相当于window.a = 12
;私有作用域
带 var
(本质是变量):私有作用域带 var ,变量提升阶段,都声明为私有变量,和外界没有任何的关系;不带 var
:会向上级作用域查找,查看是否是上级的变量,若不是则一直查找到 window 为止,如果 window 也没有,相当于给 window 设置了一个属性(作用域链);
let 关键字
const 关键字
特点总结
-
var 关键字
- 没有块级作用域的概念
- 有全局作用域、函数作用域的概念
- 不初始化值默认为 undefined
- 存在变量提升
- 全局作用域用 var 声明的变量会挂载到 window 对象下
- 同一作用域中允许重复声明
-
let 关键字
- 有块级作用域的概念
- 不存在变量提升
- 暂时性死区
- 同一块作用域中不允许重复声明
-
const 关键字
- 与 let 特性一样,仅有 2 个差别
- 区别 1:必须立即初始化,不能留到以后赋值
- 区别 2:常量的值不能改变
面试题
看下面这样一段代码,能通过分析词法环境,得出来最终的打印结果吗
let myname= '李四'
{
console.log(myname)
let myname= '张三'
}
// 解答
// VM6277:3 Uncaught ReferenceError: Cannot access 'myname' before initialization
// 进入块级作用域不会有编译过程,只不过通过let或者const声明的变量会在进入块级作用域的时被创建,但是在该变量没有赋值之前,引用该变量JavaScript引擎会抛出错误---这就是“暂时性死区”
输出 0-9
// 第一种
for (var i = 0; i < 10; i++) {
// 每一轮都生成一个自执行函数,形成全新的执行上下文 EC
// 并且把每一轮循环的 i 当做实参传给私有 上下文中的私有变量 i(形参变量)
// 定时器触发执行用到的 i 都是私有 EC 中的保留下来的 i
// 充分利用闭包的保存机制(闭包有保护和保存 2 个机制)来完成的,这样处理不太好,
//循环 多次就会产生多个不销毁的 EC
~function (i) {
setTimeout(function () {
console.log(i);
}, 10);
}(i);
}
// 第二种
// let 存在块级作用域,var 没有
for (let i = 0; i < 10; i++) {
// 形成了 10 个块级作用域,每个块级作用域中都有一个私有变量 i
setTimeout(function () {
console.log(i);
}, 10);
}
let const var 的区别?什么是块级作用域?如何用?
let const var 的区别?
- var 定义的变量,没有块的概念,可以跨块访问, 不能跨函数访问,有变量提升;
- let 定义的变量,只能在块作用域里访问,不能跨块访问,也不能跨函数访问,无变量提升,不可以重复声明;
- const 用来定义常量,使用时必须初始化(即必须赋值),只能在块作用域里访问,而且不能修改,无变量提升,不可以重复声明;
什么是块级作用域?
- 最初在 JS 中作用域有:全局作用域、函数作用域,没有块作用域的概念;
- ES6 中新增了块级作用域,块作用域由 { } 包括,if 语句和 for 语句里面的 { } 也属于块作用域;
如何用?
- 在以前没有块作用域的时候,在 if 或者 for 循环中声明的变量会泄露成全局变量;其次就是 { } 中的内层变量可能会覆盖外层变量;
- 块级作用域的出现解决了这些问题;
第 1️⃣ 座大山:数据类型
上一篇