变量提升
变量的声明和赋值
var name = '变量提升';
以上代码可以看作
var name = undefined; // 声明部分
name = '变量提升'; // 赋值部分
函数的声明和赋值
function foo() {
console.log('foo')
}
var bar = function () {
console.log('bar')
}
第一个函数 foo
是一个完整的函数声明,也就是说没有涉及到赋值操作;第二个函数是先声明变量 bar
,再把 function(){console.log('bar')}
赋值给 bar
。
- 在执行过程中,若使用了未声明的变量,那么 JavaScript 执行会报错。
- 在一个变量定义之前使用它,不会出错,但是该变量的值会为
undefined
,而不是定义时的值。 - 在一个函数定义之前使用它,不会出错,且函数能正确执行。
变量提升小结
- 所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值
undefined
。 - 可以在定义之前使用变量或者函数的原因:函数和变量在执行之前都提升到了代码开头。
- 函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的。
- 函数提升优于变量提升。
JavaScript 代码的执行流程
实际上变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被 JavaScript 引擎放入内存中。
JavaScript 的执行机制是先编译,再执行。
- 在编译阶段,变量和函数会被存放到变量环境中,变量的默认值会被设置为
undefined
; - 在代码执行阶段,JavaScript 引擎会从变量环境中去查找自定义的变量和函数。
- 如果在编译阶段,存在两个相同的函数,那么最终存放在变量环境中的是最后定义的那个,这是因为后定义的会覆盖掉之前定义的。
编译阶段
从上图可以看出,输入一段代码,经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码。执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this
、变量、对象以及函数等。
哪些情况下代码才算是“一段”代码,才会在执行之前就进行编译并创建执行上下文。一般说来,有三种情况:
- 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
- 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
- 当使用
eval
函数的时候,eval
的代码也会被编译,并创建执行上下文。
执行阶段
JavaScript 引擎开始执行“可执行代码”,按照顺序一行一行地执行。 实例 代码1:
function showName() {
console.log('极客邦');
}
showName();
function showName() {
console.log('极客时间');
}
showName();
以上代码都是输出 极客时间
。
- 首先是编译阶段。
- 遇到了第一个
showName
函数,会将该函数体存放到变量环境中。 - 接下来是第二个
showName
函数,继续存放至变量环境中,但是变量环境中已经存在一个showName
函数了,此时,第二个showName
函数会将第一个showName
函数覆盖掉。这样变量环境中就只存在第二个showName
函数了。
- 遇到了第一个
- 接下来是执行阶段。
- 先执行第一个
showName
函数,但由于是从变量环境中查找showName
函数,而变量环境中只保存了第二个showName
函数,所以最终调用的是第二个函数,打印的内容是极客时间
。 - 第二次执行
showName
函数也是走同样的流程,所以输出的结果也是极客时间
。
- 先执行第一个
代码2
showName();
var showName = function () {
console.log(2)
}
function showName() {
console.log(1)
}
showName();
以上代码分别输出 1
、 2
;等同于
// 编译阶段
var showName = undefined;
function showName() {
console.log(1)
}
// 执行阶段
showName(); // 1
// 重新赋值
showName = function () {
console.log(2)
}
showName(); // 2
调用栈
调用栈概念
调用栈就是用来管理函数调用关系的一种数据结构。
JavaScript 引擎正是利用栈的这种结构来管理执行上下文的(追踪函数执行)。在执行上下文创建好后,JavaScript 引擎会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈;遵循后进先出的顺序。
浏览器查看调用栈
可以利用浏览器断点查看调用栈的信息:Sources中的 Call Stack
就是调用栈的情况。
var a = 2;
function add(b, c) {
return b + c;
}
function addAll(b, c) {
var d = 10;
result = add(b, c);
return a + result + d;
}
addAll(3, 6);
栈溢出(Stack Overflow)
调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,这种错误叫做栈溢出。
例如执行没有任何终止条件的递归函数,一直创建新的函数执行上下文,并反复将其压入栈中,但栈是有容量限制的,超过最大数量后就会出现栈溢出的错误。
块级作用域
由于 JavaScript 存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷。ES6已经通过引入块级作用域并配合 let
、 const
关键字,来避开这种设计缺陷,但是由于 JavaScript 需要保持向下兼容,所以变量提升在相当长一段时间内还会继续存在。
作用域(scope)
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
在ES6之前,ES 的作用域只有两种:全局作用域和函数作用域。
- 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
- 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。 在ES6新增了块级作用域:
- 块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句或单独的一个
{}
都可以被看作是一个块级作用域- 如果一种语言支持块级作用域,那么其代码块内部定义的变量在代码块外部是访问不到的
- 并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁
变量提升所带来的问题
由于变量提升,使用 JavaScript 来编写和其他语言相同逻辑的代码,都有可能会导致不一样的执行结果。
- 变量容易在不被察觉的情况下被覆盖掉
var name = "极客时间"
function showName() {
console.log(myname);
if (0) {
var myname = "极客邦"
}
console.log(myname);
}
showName();
以上输出的都是 undefined
;来看这段代码的调用栈:
有两个 myname
变量:
- 一个在全局执行上下文中,其值是“极客时间”;
- 另外一个在
showName
函数的执行上下文中,其值是undefined
(if
语句里面的语句不会执行,但是var
声明的变量会提升) 执行这段代码需要使用变量myname
,会优先使用函数执行上下文里面的变量,即undefined
。 - 本应销毁的变量没有被销毁
function foo() {
for (var i = 0; i < 7; i++) {
}
console.log(i);
}
foo()
在 for
循环结束之后, i
的值并未被销毁,所以最后打印出来的是 7
。这同样也是由变量提升而导致的,在创建执行上下文阶段,变量 i
就已经被提升了,所以当 for
循环结束之后,变量 i
并没有被销毁。
ES6 是如何解决变量提升带来的缺陷的?
ES6 引入了 let
和 const
关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域。块级作用域块内声明的变量不影响块外面的变量。
function letTest() {
let x = 1;
if (true) {
let x = 2; // 不同的变量
console.log(x); // 2
}
console.log(x); // 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()
第一步是编译并创建执行上下文;
通过上图,可以得出以下结论:
- 函数内部通过
var
声明的变量,在编译阶段全都被存放到变量环境里面了。 - 通过
let
声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。 - 在函数的作用域块内部,通过
let
声明的变量并没有被存放到块级作用域外部的词法环境中。
第二步继续执行代码
当执行到代码块 {}
里面时,变量环境中 a
的值已经被设置成了 1
,词法环境中 b
的值已经被设置成了 2
,这时候函数的执行上下文就如下图所示:
从图中可以看出:
- 当进入函数的作用域块时,作用域块中通过
let
声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量b
,在该作用域块内部也声明了变量b
,当执行到作用域内部时,它们都是独立的存在。 - 在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。这里所讲的变量是指通过
let
或者const
声明的变量。 - 再接下来,当执行到作用域块中的
console.log(a)
这行代码时,就需要在词法环境和变量环境中查找变量a
的值了,具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。
变量查找过程如下图:
当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下图所示:
即块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。
作用域链和闭包
作用域链
在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer
。
所以查找变量的过程是这样的:
- 当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量;
- 如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。 这个查找的链条就称为作用域链。
如以下代码的查找过程如下图所示:
function bar() {
console.log(myName)
}
function foo() {
var myName = "极客邦"
bar()
}
var myName = "极客时间"
foo()
从图中可以看出, bar
函数和 foo
函数的 outer
都是指向全局上下文的,这也就意味着如果在 bar
函数或者 foo
函数中使用了外部变量,那么 JavaScript 引擎会去全局执行上下文中查找。
foo
函数调用的 bar
函数,那为什么 bar
函数的外部引用是全局执行上下文,而不是 foo
函数的执行上下文?要回答这个问题,需要知道什么是词法作用域。这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域(函数声明的位置)决定的。
词法作用域
词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。
从图中可以看出,词法作用域就是根据代码中函数声明的位置来决定的,其中 main
函数包含了 bar
函数, bar
函数中包含了 foo
函数,因为 JavaScript 作用域链是由词法作用域决定的,所以整个词法作用域链的顺序是: foo
函数作用域—> bar
函数作用域—> main
函数作用域—> 全局作用域。
所以词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。
块级作用域中的变量查找
来看以下的代码:
function bar() {
var myName = "极客世界"
let test1 = 100
if (1) {
let myName = "Chrome浏览器"
console.log(test)
}
}
function foo() {
var myName = "极客邦"
let test = 2
{
let test = 3
bar()
}
}
var myName = "极客时间"
let myAge = 10
let test = 1
foo();
要想得出其执行结果,那就得站在作用域链和词法环境的角度来分析下其执行过程。
首先是在 bar
函数的执行上下文中查找,但因为 bar
函数的执行上下文中没有定义 test
变量,所以根据词法作用域的规则,下一步就在 bar
函数的外部作用域中查找,也就是全局作用域。即最后输出的是全局作用域中 test
的值 1
。
闭包
通过变量环境、作用域链和词法作用域来理解闭包
function foo() {
var myName = "极客时间"
let test1 = 1
const test2 = 2
var innerBar = {
getName: function () {
console.log(test1)
return myName
},
setName: function (newName) {
myName = newName
}
}
return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName();
console.log(bar.getName());
当执行到 foo
函数内部的 return innerBar
这行代码时调用栈的情况如下图:
innerBar
是一个对象,包含了getName
和setName
的两个方法(通常把对象内部的函数称为方法)。可以看到,这两个方法都是在foo
函数内部定义的,并且这两个方法内部都使用了myName
和test1
两个变量。- 根据词法作用域的规则,内部函数
getName
和setName
总是可以访问它们的外部函数foo
中的变量。所以当innerBar
对象返回给全局变量bar
时,虽然foo
函数已经执行结束,但是getName
和setName
函数依然可以使用foo
函数中的变量myName
和test1
。所以当foo
函数执行完成之后,其整个调用栈的状态如下图所示:
- 从上图可以看出,
foo
函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的setName
和getName
方法中使用了foo
函数内部的变量myName
和test1
,所以这两个变量依然保存在内存中。
所以闭包的定义如下:
在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。
比如外部函数是 foo
,那么这些变量的集合就称为 foo
函数的闭包。
闭包的使用:当执行到 bar.setName
方法中的 myName = "极客邦"
这句代码时,JavaScript 引擎会沿着“当前执行上下文–> foo
函数闭包–> 全局执行上下文”的顺序来查找 myName
变量,调用栈状态图如下:
- 从图中可以看出,
setName
的执行上下文中没有myName
变量,foo
函数的闭包中包含了变量myName
,所以调用setName
时,会修改foo
闭包中的myName
变量的值。同样的流程,当调用bar.getName
的时候,所访问的变量myName
也是位于foo
函数闭包中的。
闭包是怎么回收的
如果闭包使用不正确,会很容易造成内存泄漏的,关注闭包是如何回收可以正确地使用闭包。
- 通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。
- 如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。 所以在使用闭包的时候,要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在,不适用后手动回收;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。
this
在对象内部的方法中使用对象内部的属性是一个非常普遍的需求。但是 JavaScript 的作用域机制并不支持这一点,基于这个需求,JavaScript 搞出来另外一套 this
机制。
this
是和执行上下文绑定的,执行上下文中还包含了变量环境、词法环境、外部环境。
全局执行上下文中的 this
全局执行上下文中的 this
是指向 window
对象的。这也是 this
和作用域链的唯一交点,作用域链的最底端包含了 window
对象,全局执行上下文中的 this
也是指向 window
对象。
函数执行上下文中的 this
有下面三种方式来设置函数执行上下文中的 this
值:
- 通过函数的
call
、bind
或apply
方法设置 - 通过对象调用方法设置
- 在全局环境中调用一个函数,函数内部的
this
指向的是全局变量window
。 - 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的
this
指向对象本身。
- 在全局环境中调用一个函数,函数内部的
- 通过构造函数中设置,即
new
一个新对象
this
的设计缺陷以及应对方案
- 嵌套函数中的
this
不会从外层函数中继承
var myObj = {
name: "极客时间",
showThis: function () {
console.log(this); // myObj
function bar() {
console.log(this); // window
}
bar()
}
}
myObj.showThis();
打印的结果分别是 myObj
和 window
对象,即函数 bar
中的 this
指向的是全局 window
对象,而函数 showThis
中的 this
指向的是 myObj
对象。
解决办法:
- 可以在函数中声明一个变量用来保存
this
,然后在嵌套函数中使用该变量,代码如下所示:
var myObj = {
name: "极客时间",
showThis: function () {
console.log(this); // myObj
let self = this;
function bar() {
console.log(self); // myObj
}
bar()
}
}
myObj.showThis();
这个方法的的本质是把 this
体系转换为了作用域的体系。
- 也可以使用 ES6 中的箭头函数来解决这个问题
var myObj = {
name: "极客时间",
showThis: function () {
console.log(this); // myObj
let self = this;
var bar = () => { console.log(this) }
bar()
}
}
myObj.showThis();
即解决嵌套函数中的 this
不会从外层函数中继承这个问题有两种思路:
- 第一种是把
this
保存为一个self
变量,再利用变量的作用域机制传递给嵌套函数。 - 第二种是继续使用
this
,但是要把嵌套函数改为箭头函数,因为箭头函数没有自己的执行上下文,所以它会继承调用函数中的this
。
- 普通函数中的
this
默认指向全局对象window
在实际工作中,我们有时候并不希望函数执行上下文中的this
默认指向全局对象,因为这样会打破数据的边界,造成一些误操作,解决这个问题有两种方法:
- 如果要让函数执行上下文中的
this
指向某个对象,最好的方式是通过call
方法来显示调用。 - 这个问题可以通过设置 JavaScript 的“严格模式”来解决。在严格模式下,默认执行一个函数,其函数的执行上下文中的
this
值是undefined
。
总结
- JavaScript代码的执行流程分为编译阶段和执行阶段:
- 在编译阶段会提升变量和函数,即提取到执行上下文的变量环境中
- 在执行阶段会运行可执行代码,要用到的变量和函数会根据作用域链依次查找调用
- 作用域是变量与函数的可访问范围,有全局作用域、函数作用域和块级作用域
- 全局作用域中的对象在代码中的任何地方都能访问
- 函数作用域定义的变量或者函数只能在函数内部被访问
- 块级作用域是ES6新增的概念,通过
let
和const
实现,其声明的变量不影响块外面的变量
- 执行上下文是指JavaScript 执行一段代码时的运行环境,有变量环境和词法环境
- 变量环境存储通过
var
声明的变量,在变量环境中实现了变量提升 - 词法环境存储通过
let
和const
声明的变量,通过词法环境的栈结构实现了块级作用域
- 变量环境存储通过
- 作用域链是一条查找引用变量的链条
- 每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文
- 词法作用域是指根据代码中函数声明的位置来决定的作用域
- 闭包是定义在函数内部的函数,通过闭包可以访问函数内部的变量