# 变量提升:JavaScript 代码是按顺序执行的吗?

只有理解了 JavaScrip 的执行上下文,才能更好地理解 JavaScript 语言本身,比如变量提升、作用域和闭包等。

先看段代码,下面这段代码输出的结果是什么?

showName()
console.log(myname)
var myname = '极客时间'
function showName() {
  console.log('函数showName被执行');
}
1
2
3
4
5
6

使用过 JavaScript 开发的程序员应该都知道,JavaScript 是按顺序执行的。

若按照这个逻辑来理解的话,那么:

  • 当执行到第 1 行的时候,由于函数 showName 还没有定义,所以执行应该会报错;

  • 同样执行第 2 行的时候,由于变量 myname 也未定义,所以同样也会报错。

然而实际执行结果却并非如此, 如下图:

在声明之前使用函数和变量的执行结果

第 1 行输出 “函数 showName 被执行”,第 2 行输出 “undefined”,这和前面想象中的顺序执行有点不一样啊!

通过上面的执行结果,你应该已经知道了函数或者变量可以在定义之前使用,那如果使用没有定义的变量或者函数,JavaScript 代码还能继续执行吗?为了验证这点,我们可以删除第 3 行变量 myname 的定义,如下所示:

showName()
console.log(myname)
function showName() {
  console.log('函数showName被执行');
}
1
2
3
4
5

然后再次执行这段代码时,JavaScript 引擎就会报错,结果如下:

使用了未定义的变量——执行报错

从上面两段代码的执行结果来看,我们可以得出如下三个结论。

  • 在执行过程中,若使用了未声明的变量,那么 JavaScript 执行会报错。

  • 在一个变量定义之前使用它,不会出错,但是该变量的值会为 undefined,而不是定义时的值。

  • 在一个函数定义之前使用它,不会出错,且函数能正确执行。

# 变量提升(Hoisting)

在介绍变量提升之前,先通过下面这段代码,来看看什么是 JavaScript 中的声明赋值

var myname = '极客时间'
1

这段代码可以把它看成是两行代码组成的:

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

如下图所示:

如何理解

上面是变量的声明和赋值,那接下来再来看看函数的声明和赋值,结合下面这段代码:

function foo(){
  console.log('foo')
}

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

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

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

函数的声明和赋值

理解了声明和赋值操作,那接下来可以聊聊什么是变量提升了。

所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的 “行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 undefined

下面我们来模拟下实现:

/*
* 变量提升部分
*/
// 把变量 myname 提升到开头,
// 同时给 myname 赋值为 undefined
var myname = undefined
// 把函数 showName 提升到开头
function showName() {
  console.log('showName被调用');
}

/*
* 可执行代码部分
*/
showName()
console.log(myname)
// 去掉 var 声明部分,保留赋值语句
myname = '极客时间'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

为了模拟变量提升的效果,我们对代码做了以下调整,如下图:

模拟变量提升示意图

通过这段模拟的变量提升代码,相信你已经明白了可以在定义之前使用变量或者函数的原因 —— 函数和变量在执行之前都提升到了代码开头。

# JavaScript 代码的执行流程

从概念的字面意义上来看,“变量提升” 意味着变量和函数的声明会在物理层面移动到代码的最前面,正如我们所模拟的那样。但,这并不准确。

实际上变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被 JavaScript 引擎放入内存中。

一段 JavaScript 代码在执行之前需要被 JavaScript 引擎编译,编译完成之后,才会进入执行阶段。

大致流程可以参考下图:

JavaScript 的执行流程图

# 1. 编译阶段

那么编译阶段和变量提升存在什么关系呢?

为了搞清楚这个问题,我们还是回过头来看上面那段模拟变量提升的代码,为了方便介绍,可以把这段代码分成两部分。

第一部分:变量提升部分的代码。

var myname = undefined
function showName() {
  console.log('函数showName被执行');
}
1
2
3
4

第二部分:执行部分的代码。

showName()
console.log(myname)
myname = '极客时间'
1
2
3

下面我们就可以把 JavaScript 的执行流程细化,如下图所示:

JavaScript 执行流程细化图

从上图可以看出,输入一段代码,经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码

执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。

在执行上下文中存在一个变量环境的对象(Viriable Environment),该对象中保存了变量提升的内容,比如上面代码中的变量 myname 和函数 showName,都保存在该对象中。

可以简单地把变量环境对象看成是如下结构:

VariableEnvironment:
  myname -> undefined, 
  showName -> function : {console.log(myname)}
1
2
3

了解完变量环境对象的结构后,接下来,我们再结合下面这段代码来分析下是如何生成变量环境对象的。


showName()
console.log(myname)
var myname = '极客时间'
function showName() {
  console.log('函数showName被执行');
}
1
2
3
4
5
6
7

我们可以一行一行来分析上述代码:

  • 第 1 行和第 2 行,由于这两行代码不是声明操作,所以 JavaScript 引擎不会做任何处理

  • 第 3 行,由于这行是经过 var 声明的,因此 JavaScript 引擎将在环境对象中创建一个名为 myname 的属性,并使用 undefined 对其初始化

  • 第 4 行,JavaScript 引擎发现了一个通过 function 定义的函数,所以它将函数定义存储到堆(HEAP)中,并在环境对象中创建一个 showName 的属性,然后将该属性值指向堆中函数的位置

这样就生成了变量环境对象。接下来 JavaScript 引擎会把声明以外的代码编译为字节码。

现在有了执行上下文和可执行代码了,那么接下来就到了执行阶段了。

# 2. 执行阶段

JavaScript 引擎开始执行 “可执行代码”,按照顺序一行一行地执行。

下面我们就来一行一行分析下这个执行过程:

  • 当执行到 showName 函数时,JavaScript 引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以 JavaScript 引擎便开始执行该函数,并输出 “函数 showName 被执行” 结果。

  • 接下来打印 “myname” 信息,JavaScript 引擎继续在变量环境对象中查找该对象,由于变量环境存在 myname 变量,并且其值为 undefined,所以这时候就输出 undefined。

  • 接下来执行第 3 行,把 “极客时间” 赋给 myname 变量,赋值后变量环境中的 myname 属性值改变为 “极客时间”,变量环境如下所示:

VariableEnvironment:
  myname -> "极客时间", 
  showName ->function : {console.log(myname)}
1
2
3

以上就是一段代码的编译和执行流程。

实际上,编译阶段和执行阶段都是非常复杂的,包括了词法分析、语法解析、代码优化、代码生成等。

# 代码中出现相同的变量或者函数怎么办?

现在你已经知道了,在执行一段 JavaScript 代码之前,会编译代码,并将代码中的函数和变量保存到执行上下文的变量环境中,那么如果代码中出现了重名的函数或者变量,JavaScript 引擎会如何处理?

先看下面这样一段代码:

function showName() {
  console.log('极客邦');
}
showName();
function showName() {
  console.log('极客时间');
}
showName(); 
1
2
3
4
5
6
7
8

来分析下其完整执行流程:

  • 首先是编译阶段。遇到了第一个 showName 函数,会将该函数体存放到变量环境中。接下来是第二个 showName 函数,继续存放至变量环境中,但是变量环境中已经存在一个 showName 函数了,此时,第二个 showName 函数会将第一个 showName 函数覆盖掉。这样变量环境中就只存在第二个 showName 函数了。

  • 接下来是执行阶段。先执行第一个 showName 函数,但由于是从变量环境中查找 showName 函数,而变量环境中只保存了第二个 showName 函数,所以最终调用的是第二个函数,打印的内容是 “极客时间”。第二次执行 showName 函数也是走同样的流程,所以输出的结果也是 “极客时间”。

综上所述,一段代码如果定义了两个相同名字的函数,那么最终生效的是最后一个函数。

# 评论区学习

查看 (opens new window)

# 调用栈:为什么 JavaScript 代码会出现栈溢出?

在上篇文章中,我们讲到了,当一段代码被执行时,JavaScript 引擎先会对其进行编译,并创建执行上下文。但是并没有明确说明到底什么样的代码才算符合规范。

那么接下来我们就来明确下,哪些情况下代码才算是 “一段” 代码,才会在执行之前就进行编译并创建执行上下文。一般说来,有这么三种情况:

  • 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。

  • 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。

  • 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。

好了,又进一步理解了执行上下文,那本节我们就在这基础之上继续深入,一起聊聊调用栈。学习调用栈至少有以下三点好处:

  • 可以帮助你了解 JavaScript 引擎背后的工作原理;

  • 让你有调试 JavaScript 代码的能力;

  • 帮助你搞定面试,因为面试过程中,调用栈也是出境率非常高的题目。

比如你在写 JavaScript 代码的时候,有时候可能会遇到栈溢出的错误,如下图所示:

栈溢出的错误

那为什么会出现这种错误呢?这就涉及到了调用栈的内容。

JavaScript 中有很多函数,经常会出现在一个函数中调用另外一个函数的情况,调用栈就是用来管理函数调用关系的一种数据结构。因此要讲清楚调用栈,还要先弄明白函数调用和栈结构。

# 什么是函数调用

函数调用就是运行一个函数,具体使用方式是使用函数名称跟着一对小括号。下面我们看个简单的示例代码:

var a = 2
function add(){
  var b = 10
  return a + b
}
add()
1
2
3
4
5
6

这段代码很简单,先是创建了一个 add 函数,接着在代码的最下面又调用了该函数。

那么下面我们就利用这段简单的代码来解释下函数调用的过程。

在执行到函数 add() 之前,JavaScript 引擎会为上面这段代码创建全局执行上下文,包含了声明的函数和变量,可以参考下图:

全局执行上下文

从图中可以看出,代码中全局变量和函数都保存在全局上下文的变量环境中

执行上下文准备好之后,便开始执行全局代码,当执行到 add 这儿时,JavaScript 判断这是一个函数调用,那么将执行以下操作:

  • 首先,从全局执行上下文中,取出 add 函数代码。

  • 其次,对 add 函数的这段代码进行编译,并创建该函数的执行上下文和可执行代码。

  • 最后,执行代码,输出结果。

完整流程可以参考下图:

函数调用过程

就这样,当执行到 add 函数的时候,我们就有了两个执行上下文了 —— 全局执行上下文和 add 函数的执行上下文

也就是说在执行 JavaScript 时,可能会存在多个执行上下文,那么 JavaScript 引擎是如何管理这些执行上下文的呢?

答案是通过一种叫栈的数据结构来管理的。

# 什么是 JavaScript 的调用栈

JavaScript 引擎正是利用栈的这种结构来管理执行上下文的。

在执行上下文创建好后,JavaScript 引擎会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈。

为便于更好地理解调用栈,下面我们再来看段稍微复杂点的示例代码:


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)
1
2
3
4
5
6
7
8
9
10
11

在上面这段代码中,可以看到它是在 addAll 函数中调用了 add 函数,那在整个代码的执行过程中,调用栈是怎么变化的呢?

下面我们就一步步地分析在代码的执行过程中,调用栈的状态变化情况。

第一步,创建全局上下文,并将其压入栈底。

全局执行上下文压栈

从图中也可以看出,变量 a、函数 add 和 addAll 都保存到了全局上下文的变量环境对象中。

全局执行上下文压入到调用栈后,JavaScript 引擎便开始执行全局代码了。首先会执行 a = 2 的赋值操作,执行该语句会将全局上下文变量环境中 a 的值设置为 2。设置后的全局上下文的状态如下图所示:

赋值操作改变执行上下文中的值

第二步是调用 addAll 函数。

当调用该函数时,JavaScript 引擎会编译该函数,并为其创建一个执行上下文,最后还将该函数的执行上下文压入栈中,如下图所示:

执行 addAll 函数时的调用栈

addAll 函数的执行上下文创建好之后,便进入了函数代码的执行阶段了,这里先执行的是 d = 10 的赋值操作,执行语句会将 addAll 函数执行上下文中的 d 由 undefined 变成了 10。

第三步,当执行到 add 函数调用语句时,同样会为其创建执行上下文,并将其压入调用栈。

执行 add 函数时的调用栈

当 add 函数返回时,该函数的执行上下文就会从栈顶弹出,并将 result 的值设置为 add 函数的返回值,也就是 9。如下图所示:

add 函数执行结束时的调用栈

紧接着 addAll 执行最后一个相加操作后并返回,addAll 的执行上下文也会从栈顶部弹出,此时调用栈中就只剩下全局上下文了。最终如下图所示:

addAll 函数执行结束时的调用栈

至此,整个 JavaScript 流程执行结束了。

调用栈是 JavaScript 引擎追踪函数执行的一个机制,当一次有多个函数被调用时,通过调用栈就能够追踪到哪个函数正在被执行以及各函数之间的调用关系。

# 在开发中,如何利用好调用栈

# 1. 如何利用浏览器查看调用栈的信息

当你执行一段复杂的代码时,你可能很难从代码文件中分析其调用关系,这时候你可以在你想要查看的函数中加入断点,然后当执行到该函数时,就可以查看该函数的调用栈了。

拿上面的那段代码做个演示,你可以打开 “开发者工具”,点击 “Source” 标签,选择 JavaScript 代码的页面,然后在第 3 行加上断点,并刷新页面。你可以看到执行到 add 函数时,执行流程就暂停了,这时可以通过右边 “call stack” 来查看当前的调用栈的情况,如下图:

查看函数调用关系

从图中可以看出,右边的 “call stack” 下面显示出来了函数的调用关系:

栈的最底部是 anonymous,也就是全局的函数入口;

中间是 addAll 函数;

顶部是 add 函数。

这就清晰地反映了函数的调用关系,所以在分析复杂结构代码,或者检查 Bug 时,调用栈都是非常有用的。

除了通过断点来查看调用栈,你还可以使用 console.trace() 来输出当前的函数调用关系,比如在示例代码中的 add 函数里面加上了 console.trace(),你就可以看到控制台输出的结果,如下图:

使用 trace 函数输出当前调用栈信息

# 2. 栈溢出(Stack Overflow)

调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出。

特别是在写递归代码的时候,就很容易出现栈溢出的情况。比如下面这段代码:

function division(a, b) {
  return division(a, b)
}
console.log(division(1, 2))
1
2
3
4

当执行时,就会抛出栈溢出错误,如下图:

栈溢出错误

从上图可以看到,抛出的错误信息为:超过了最大栈调用大小(Maximum call stack size exceeded)。

那为什么会出现这个问题呢?

这是因为当 JavaScript 引擎开始执行这段代码时,它首先调用函数 division,并创建执行上下文,压入栈中;然而,这个函数是递归的,并且没有任何终止条件,所以它会一直创建新的函数执行上下文,并反复将其压入栈中,但栈是有容量限制的,超过最大数量后就会出现栈溢出的错误。

理解了栈溢出原因后,就可以使用一些方法来避免或者解决栈溢出的问题,比如把递归调用的形式改造成其他形式,或者使用加入定时器的方法来把当前任务拆分为其他很多小任务。

# 评论区学习

查看 (opens new window)

# 块级作用域:var 缺陷以及为什么要引入 let 和 const?

正是由于 JavaScript 存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷。

虽然 ECMAScript6(以下简称 ES6)已经通过引入块级作用域并配合 let、const 关键字,来避开了这种设计缺陷,但是由于 JavaScript 需要保持向下兼容,所以变量提升在相当长一段时间内还会继续存在。这也加大了你理解概念的难度,因为既要理解新的机制,又要理解变量提升这套机制,关键这两套机制还是同时运行在“一套”系统中的。

# 作用域(scope)

为什么 JavaScript 中会存在变量提升这个特性,而其他语言似乎都没有这个特性呢?要讲清楚这个问题,我们就得先从作用域讲起。

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

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

  • 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。

  • 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

在 ES6 之前,JavaScript 只支持这两种作用域,相较而言,其他语言则都普遍支持块级作用域。块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个 {} 都可以被看作是一个块级作用域。

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

// if 块
if(1){}

// while 块
while(1){}

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

// 单独一个块
{}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

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

看下面这段 C 代码:

char* myname = "极客时间";
void showName() {
  printf("%s \n", myname);
  if(0){
    char* myname = "极客邦";
  }
}

int main(){
  showName();
  return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12

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

和 Java、C/C++ 不同,ES6 之前是不支持块级作用域的,因为当初设计这门语言的时候,并没有想到 JavaScript 会火起来,所以只是按照最简单的方式来设计。没有了块级作用域,再把作用域内部的变量统一提升无疑是最快速、最简单的设计,不过这也直接导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的,这也就是 JavaScript 中的变量提升。

# 变量提升所带来的问题

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

# 1. 变量容易在不被察觉的情况下被覆盖掉

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

var myname = "极客时间"
function showName(){
  console.log(myname);
  if(0){
    var myname = "极客邦"
  }
  console.log(myname);
}
showName()
1
2
3
4
5
6
7
8
9

执行上面这段代码,打印出来的是 undefined,而并没有像前面 C 代码那样打印出来 “极客时间” 的字符串。为什么输出的内容是 undefined 呢?我们再来分析一下。

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

开始执行 showName 函数时的调用栈

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

console.log(myname);
1

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

相信做过 JavaScript 开发的同学都能轻松回答出来答案:“当然是先使用函数执行上下文里面的变量啦!”

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

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

# 2. 本应销毁的变量没有被销毁

接下来我们再来看下面这段让人误解更大的代码:

function foo(){
  for (var i = 0; i < 7; i++) {
  }
  console.log(i); 
}
foo()
1
2
3
4
5
6

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

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

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

# ES6 是如何解决变量提升带来的缺陷

上面我们介绍了变量提升而带来的一系列问题,为了解决这些问题,ES6 引入了 let 和 const 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域。

关于 let 和 const 的用法,可以参考下面代码:

let x = 5
const y = 6
x = 7
y = 9 //报错,const 声明的变量不可以修改
1
2
3
4

从这段代码可以看出来,两者之间的区别是,使用 let 关键字声明的变量是可以被改变的,而使用 const 声明的变量其值是不可以被改变的。但不管怎样,两者都可以生成块级作用域。

那么接下来,我们就通过实际的例子来分析下,ES6 是如何通过块级作用域来解决上面的问题的。

可以先参考下面这段存在变量提升的代码:

function varTest() {
  var x = 1;
  if (true) {
    var x = 2;  // 同样的变量!
    console.log(x);  // 2
  }
  console.log(x);  // 2
}
1
2
3
4
5
6
7
8

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

varTest 函数的执行上下文

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

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

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

这个改造过程其实很简单,只需要把 var 关键字替换为 let 关键字,改造后的代码如下:

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

执行这段代码,其输出结果就和我们的预期是一致的。

这是因为 let 关键字是支持块级作用域的,所以在编译阶段,JavaScript 引擎并不会把 if 块中通过 let 声明的变量存放到变量环境中,这也就意味着在 if 块通过 let 声明的关键字,并不会提升到全函数可见。

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

# JavaScript 是如何支持块级作用域的

现在你知道了 ES 可以通过使用 let 或者 const 关键字来实现块级作用域,不过你是否有过这样的疑问:“在同一段代码中,ES6 是如何做到既要支持变量提升的特性,又要支持块级作用域的呢?”

那么接下来,我们就要站在执行上下文的角度来揭开答案。

你已经知道 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()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

接下来我们就来一步步分析上面这段代码的执行流程。

第一步是编译并创建执行上下文,下面是我画出来的执行上下文示意图,可以参考下:

刚执行时 foo 函数的执行上下文

通过上图,我们可以得出以下结论:

  • 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。

  • 通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。

  • 在函数的作用域块内部,通过 let 声明的变量并没有被存放到词法环境中。

接下来,第二步继续执行代码,当执行到代码块里面时,变量环境中 a 的值已经被设置成了 1,词法环境中 b 的值已经被设置成了 2,这时候函数的执行上下文就如下图所示:

执行 foo 函数内部作用域块时的执行上下文

从图中可以看出,当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在。

其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。需要注意下,我这里所讲的变量是指通过 let 或者 const 声明的变量。

再接下来,当执行到作用域块中的 console.log(a) 这行代码时,就需要在词法环境和变量环境中查找变量 a 的值了,具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。

这样一个变量查找过程就完成了,可以参考下图:

变量查找过程

从上图你可以清晰地看出变量查找流程,不过要完整理解查找变量或者查找函数的流程,就涉及到作用域链了,这个我们会在下篇文章中做详细介绍。

当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下图所示:

作用域执行完成示意图

通过上面的分析,想必你已经理解了词法环境的结构和工作机制,块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。

# 评论区学习

查看 (opens new window)

# 作用域链和闭包:代码中出现相同的变量,JavaScript 引擎是如何选择的?

理解作用域链是理解闭包的基础,而闭包在 JavaScript 中几乎无处不在,同时作用域和作用域链还是所有编程语言的基础。所以,如果你想学透一门语言,作用域和作用域链一定是绕不开的。

首先来看下面这段代码:

function bar() {
  console.log(myName)
}
function foo() {
  var myName = "极客邦"
  bar()
}
var myName = "极客时间"
foo()
1
2
3
4
5
6
7
8
9

这段代码中的 bar 函数和 foo 函数打印出来的内容是什么?这就要分析下这两段代码的执行流程。

通过前面的学习,当这段代码执行到 bar 函数内部时,其调用栈的状态图如下所示:

执行 bar 函数时的调用栈

从图中可以看出,全局执行上下文和 foo 函数的执行上下文中都包含变量 myName,那 bar 函数里面 myName 的值到底该选择哪个呢?

也许你的第一反应是按照调用栈的顺序来查找变量,查找方式如下:

  1. 先查找栈顶是否存在 myName 变量,但是这里没有,所以接着往下查找 foo 函数中的变量。

  2. 在 foo 函数中查找到了 myName 变量,这时候就使用 foo 函数中的 myName。

如果按照这种方式来查找变量,那么最终执行 bar 函数打印出来的结果就应该是“极客邦”。但实际情况并非如此,如果你试着执行上述代码,你会发现打印出来的结果是“极客时间”。为什么会是这种情况呢?要解释清楚这个问题,那么你就需要先搞清楚作用域链了。

# 作用域链

关于作用域链,很多人会感觉费解,但如果你理解了调用栈、执行上下文、词法环境、变量环境等概念,那么你理解起来作用域链也会很容易。

其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer。

当一段代码使用了一个变量时,JavaScript 引擎首先会在 “当前的执行上下文” 中查找该变量,比如上面那段代码在查找 myName 变量时,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。

为了直观理解,可以看下面这张图:

带有外部引用的调用栈示意图

从图中可以看出,bar 函数和 foo 函数的 outer 都是指向全局上下文的,这也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 JavaScript 引擎会去全局执行上下文中查找。我们把这个查找的链条就称为作用域链。

现在你知道变量是通过作用域链来查找的了,不过还有一个疑问没有解开,foo 函数调用的 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?

要回答这个问题,你还需要知道什么是词法作用域。这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。

# 词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

这么讲可能不太好理解,可以看下面这张图:

词法作用域

从图中可以看出,词法作用域就是根据代码的位置来决定的,其中 main 函数包含了 bar 函数,bar 函数中包含了 foo 函数,因为 JavaScript 作用域链是由词法作用域决定的,所以整个词法作用域链的顺序是:foo 函数作用域 —> bar 函数作用域 —> main 函数作用域 —> 全局作用域。

了解了词法作用域以及 JavaScript 中的作用域链,我们再回过头来看看上面的那个问题:在开头那段代码中,foo 函数调用了 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?

这是因为根据词法作用域,foo 和 bar 的上级作用域都是全局作用域,所以如果 foo 或者 bar 函数使用了一个它们没有定义的变量,那么它们会到全局作用域去查找。也就是说,词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。

# 块级作用域中的变量查找

前面我们通过全局作用域和函数级作用域来分析了作用域链,那接下来我们再来看看块级作用域中变量是如何查找的?

在编写代码的时候,如果你使用了一个在当前作用域中不存在的变量,这时 JavaScript 引擎就需要按照作用域链在其他作用域中查找该变量,如果你不了解该过程,那就会有很大概率写出不稳定的代码。

先看下面这段代码:

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()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

要想得出其执行结果,那接下来我们就得站在作用域链和词法环境的角度来分析下其执行过程。

ES6 是支持块级作用域的,当执行到代码块时,如果代码块中有 let 或者 const 声明的变量,那么变量就会存放到该函数的词法环境中。

对于上面这段代码,当执行到 bar 函数内部的 if 语句块时,其调用栈的情况如下图所示:

块级作用域中是如何查找变量的

现在是执行到 bar 函数的 if 语块之内,需要打印出来变量 test,那么就需要查找到 test 变量的值,其查找过程已经在上图中使用序号 1、2、3、4、5 标记出来了。

下面来解释下这个过程。

首先是在 bar 函数的执行上下文中查找,但因为 bar 函数的执行上下文中没有定义 test 变量,所以根据词法作用域的规则,下一步就在 bar 函数的外部作用域中查找,也就是全局作用域。至于单个执行上下文中如何查找变量,在上一篇文章中已经介绍过了。

# 闭包

如果理解了变量环境、词法环境和作用域链等概念,那接下来再理解什么是 JavaScript 中的闭包就容易多了。

可以结合下面这段代码来理解什么是闭包:

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())
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

首先我们看看当执行到 foo 函数内部的 return innerBar 这行代码时调用栈的情况,可以参考下图:

执行到 return bar 时候的调用栈

从上面的代码可以看出,innerBar 是一个对象,包含了 getName 和 setName 的两个方法(通常我们把对象内部的函数称为方法)。可以看到,这两个方法都是在 foo 函数内部定义的,并且这两个方法内部都使用了 myName 和 test1 两个变量。

根据词法作用域的规则,内部函数 getName 和 setName 总是可以访问它们的外部函数 foo 中的变量。

所以当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myName 和 test1。所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:

闭包的产生过程

从上图可以看出,foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。

这像极了 setName 和 getName 方法背的一个专属背包,无论在哪里调用了 setName 和 getName 方法,它们都会背着这个 foo 函数的专属背包。

之所以是专属背包,是因为除了 setName 和 getName 函数之外,其他任何地方都是无法访问该背包的,我们就可以把这个背包称为 foo 函数的闭包。

闭包的正式定义:

在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。

那这些闭包是如何使用的呢?

当执行到 bar.setName 方法中的 myName = "极客邦" 这句代码时,JavaScript 引擎会沿着 “当前执行上下文 –> foo 函数闭包 –> 全局执行上下文” 的顺序来查找 myName 变量,可以参考下面的调用栈状态图:

执行 bar 时调用栈状态

从图中可以看出,setName 的执行上下文中没有 myName 变量,foo 函数的闭包中包含了变量 myName,所以调用 setName 时,会修改 foo 闭包中的 myName 变量的值。

同样的流程,当调用 bar.getName 的时候,所访问的变量 myName 也是位于 foo 函数闭包中的。

可以通过 “开发者工具” 来看看闭包的情况,打开 Chrome 的 “开发者工具”,在 bar 函数任意地方打上断点,然后刷新页面,可以看到如下内容:

开发者工具中的闭包展示

从图中可以看出来,当调用 bar.getName 的时候,右边 Scope 项就体现出了作用域链的情况:Local 就是当前的 getName 函数的作用域,Closure(foo) 是指 foo 函数的闭包,最下面的 Global 就是指全局作用域,从 “Local –> Closure(foo) –> Global” 就是一个完整的作用域链。

因此,以后也可以通过 Scope 来查看实际代码作用域链的情况,这样调试代码也会比较方便。

# 闭包是怎么回收的

因为如果闭包使用不正确,会很容易造成内存泄漏的,关注闭包是如何回收的能让你正确地使用闭包。

通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。

如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。

所以在使用闭包的时候,你要尽量注意一个原则:

注意

如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。

关于闭包回收的问题这里只是做了个简单的介绍,其实闭包是如何回收的还牵涉到了 JavaScript 的垃圾回收机制,而关于垃圾回收,接下来会再做详细介绍的。

# 评论区学习

查看 (opens new window)

# this:从 JavaScript 执行上下文的视角讲清楚 this

在上篇文章中,我们讲了词法作用域、作用域链以及闭包,并在最后思考题中留了下面这样一段代码:

var bar = {
  myName: "time.geekbang.com",
  printName: function () {
    console.log(myName)
  }    
}
function foo() {
  let myName = "极客时间"
  return bar.printName
}
let myName = "极客邦"
let _printName = foo()
_printName()
bar.printName()
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在 printName 函数里面使用的变量 myName 是属于全局作用域下面的,所以最终打印出来的值都是 “极客邦”。这是因为 JavaScript 语言的作用域链是由词法作用域决定的,而词法作用域是由代码结构来确定的。

不过按照常理来说,调用 bar.printName 方法时,该方法内部的变量 myName 应该使用 bar 对象中的,因为它们是一个整体,大多数面向对象语言都是这样设计的,比如我用 C++ 改写了上面那段代码,如下所示:

#include <iostream>
using namespace std;
class Bar{
  public:
  char* myName;
  Bar(){
    myName = "time.geekbang.com";
  }
  void printName(){
    cout<< myName <<endl;
  }  
} bar;

char* myName = "极客邦";
int main() {
  bar.printName();
  return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

在这段 C++ 代码中,同样调用了 bar 对象中的 printName 方法,最后打印出来的值就是 bar 对象的内部变量 myName 值 —— “time.geekbang.com”,而并不是最外面定义变量 myName 的值 —— “极客邦”,所以在对象内部的方法中使用对象内部的属性是一个非常普遍的需求。

但是 JavaScript 的作用域机制并不支持这一点,基于这个需求,JavaScript 又搞出来另外一套 this 机制。

所以,在 JavaScript 中可以使用 this 实现在 printName 函数中访问到 bar 对象的 myName 属性了。

具体该怎么操作呢?可以调整 printName 的代码,如下所示:

printName: function () {
  console.log(this.myName)
}    
1
2
3

接下来咱们就展开来介绍 this,不过在讲解之前,希望你能区分清楚作用域链和 this 是两套不同的系统,它们之间基本没太多联系

在前期明确这点,可以避免你在学习 this 的过程中,和作用域产生一些不必要的关联。

# JavaScript 中的 this 是什么

关于 this,我们还是得先从执行上下文说起。

在前面几篇文章中,我们提到执行上下文中包含了变量环境、词法环境、外部环境,但其实还有一个 this 没有提及,具体可以参考下图:

执行上下文中的 this

从图中可以看出,this 是和执行上下文绑定的,也就是说每个执行上下文中都有一个 this。

执行上下文主要分为三种 —— 全局执行上下文、函数执行上下文和 eval 执行上下文,所以对应的 this 也只有这三种 —— 全局执行上下文中的 this、函数中的 this 和 eval 中的 this。

不过由于 eval 我们使用的不多,所以这里对此就不做介绍了,如果你感兴趣的话,可以自行搜索和学习相关知识。

那么接下来我们就重点讲解下全局执行上下文中的 this 和函数执行上下文中的 this。

# 全局执行上下文中的 this

首先我们来看看全局执行上下文中的 this 是什么。

可以在控制台中输入console.log(this)来打印出来全局执行上下文中的 this,最终输出的是 window 对象。

所以可以得出这样一个结论:

全局执行上下文中的 this 是指向 window 对象的。

这也是 this 和作用域链的唯一交点,作用域链的最底端包含了 window 对象,全局执行上下文中的 this 也是指向 window 对象。

# 函数执行上下文中的 this

现在我们已经知道全局对象中的 this 是指向 window 对象了,那么接下来,我们就来重点分析函数执行上下文中的 this。还是先看下面这段代码:

function foo(){
  console.log(this)
}
foo()
1
2
3
4

我们在 foo 函数内部打印出来 this 值,执行这段代码,打印出来的也是 window 对象,这说明在默认情况下调用一个函数,其执行上下文中的 this 也是指向 window 对象的。

通常情况下,有下面三种方式来设置函数执行上下文中的 this 值。

# 1. 通过函数的 call 方法设置

可以通过函数的 call 方法来设置函数执行上下文的 this 指向,比如下面这段代码,我们就并没有直接调用 foo 函数,而是调用了 foo 的 call 方法,并将 bar 对象作为 call 方法的参数。

let bar = {
  myName : "极客邦",
  test1 : 1
}
function foo(){
  this.myName = "极客时间"
}
foo.call(bar)
console.log(bar)
console.log(myName)
1
2
3
4
5
6
7
8
9
10

执行这段代码,然后观察输出结果,就能发现 foo 函数内部的 this 已经指向了 bar 对象,因为通过打印 bar 对象,可以看出 bar 的 myName 属性已经由 “极客邦” 变为 “极客时间” 了,同时在全局执行上下文中打印 myName,JavaScript 引擎提示该变量未定义。

其实除了 call 方法,你还可以使用 bind 和 apply 方法来设置函数执行上下文中的 this。

# 2. 通过对象调用方法设置

要改变函数执行上下文中的 this 指向,除了通过函数的 call 方法来实现外,还可以通过对象调用的方式,比如下面这段代码:

var myObj = {
  name : "极客时间", 
  showThis: function() {
    console.log(this)
  }
}
myObj.showThis()
1
2
3
4
5
6
7

在这段代码中,我们定义了一个 myObj 对象,该对象是由一个 name 属性和一个 showThis 方法组成的,然后再通过 myObj 对象来调用 showThis 方法。执行这段代码,你可以看到,最终输出的 this 值是指向 myObj 的。

所以,可以得出这样的结论:

使用对象来调用其内部的一个方法,该方法的 this 是指向对象本身的。

其实,也可以认为 JavaScript 引擎在执行 myObj.showThis() 时,将其转化为了:

myObj.showThis.call(myObj)
1

接下来我们稍微改变下调用方式,把 showThis 赋给一个全局对象,然后再调用该对象,代码如下所示:

var myObj = {
  name: "极客时间",
  showThis: function() {
    this.name = "极客邦"
    console.log(this)
  }
}
var foo = myObj.showThis
foo()
1
2
3
4
5
6
7
8
9

执行这段代码,你会发现 this 又指向了全局 window 对象。

所以通过以上两个例子的对比,可以得出下面这样两个结论:

  • 在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window。

  • 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身。

# 3. 通过构造函数中设置

可以像这样设置构造函数中的 this,如下面的示例代码:

function CreateObj() {
  this.name = "极客时间"
}
var myObj = new CreateObj()
1
2
3
4

在这段代码中,我们使用 new 创建了对象 myObj,那此时的构造函数 CreateObj 中的 this 到底指向了谁?

其实,当执行 new CreateObj() 的时候,JavaScript 引擎做了如下四件事:

  • 首先创建了一个空对象 tempObj;

  • 接着调用 CreateObj.call 方法,并将 tempObj 作为 call 方法的参数,这样当 CreateObj 的执行上下文创建时,它的 this 就指向了 tempObj 对象;

  • 然后执行 CreateObj 函数,此时的 CreateObj 函数执行上下文中的 this 指向了 tempObj 对象;

  • 最后返回 tempObj 对象。

为了直观理解,我们可以用代码来演示下:

var tempObj = {}
CreateObj.call(tempObj)
return tempObj
1
2
3

这样,我们就通过 new 关键字构建好了一个新对象,并且构造函数中的 this 其实就是新对象本身。

# this 的设计缺陷以及应对方案

# 1. 嵌套函数中的 this 不会从外层函数中继承

结合下面这样一段代码来分析下:

var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
    function bar(){
      console.log(this)
    }
    bar()
  }
}
myObj.showThis()
1
2
3
4
5
6
7
8
9
10
11

执行这段代码后,会发现函数 bar 中的 this 指向的是全局 window 对象,而函数 showThis 中的 this 指向的是 myObj 对象

这就是 JavaScript 中非常容易让人迷惑的地方之一,也是很多问题的源头。

可以通过一个小技巧来解决这个问题,比如在 showThis 函数中声明一个变量 self 用来保存 this,然后在 bar 函数中使用 self,代码如下所示:

var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
    var self = this
    function bar(){
      self.name = "极客邦"
    }
    bar()
  }
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)
1
2
3
4
5
6
7
8
9
10
11
12
13
14

执行这段代码,可以看到它输出了我们想要的结果,最终 myObj 中的 name 属性值变成了 “极客邦”。

其实,这个方法的的本质是把 this 体系转换为了作用域体系。

其实,也可以使用 ES6 中的箭头函数来解决这个问题,结合下面代码:

var myObj = {
  name : "极客时间", 
  showThis: function() {
    console.log(this)
    var bar = () => {
      this.name = "极客邦"
      console.log(this)
    }
    bar()
  }
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)
1
2
3
4
5
6
7
8
9
10
11
12
13
14

执行这段代码,会发现它也输出了我们想要的结果,也就是箭头函数 bar 里面的 this 是指向 myObj 对象的。

这是因为 ES6 中的箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数。

综上所述,要解决这些常见的问题,可以有两种思路:

  • 第一种是把 this 保存为一个 self 变量,再利用变量的作用域机制传递给嵌套函数。

  • 第二种是继续使用 this,但是要把嵌套函数改为箭头函数,因为箭头函数没有自己的执行上下文,所以它会继承调用函数中的 this。

# 2. 普通函数中的 this 默认指向全局对象 window

在默认情况下调用一个函数,其执行上下文中的 this 是默认指向全局对象 window 的。

不过这个设计也是一种缺陷,因为在实际工作中,我们并不希望函数执行上下文中的 this 默认指向全局对象,因为这样会打破数据的边界,造成一些误操作。

如果要让函数执行上下文中的 this 指向某个对象,最好的方式是通过 call 方法来显示调用。

这个问题可以通过设置 JavaScript 的 “严格模式” 来解决。在严格模式下,默认执行一个函数,其函数的执行上下文中的 this 值是 undefined,这就解决上面的问题了。

# 评论区学习

查看 (opens new window)

上次更新时间: 2023年12月16日 17:23:04