要脱离单纯劳动力的角色,就要深入去了解那些底层的原理,今天我们就来聊聊执行上下文。只有理解了 JavaScrip 的执行上下文,你才能更好地理解 JavaScript 语言本身,因为JavaScript中的很多概念变量提升、作用域和闭包等等都是和执行上下文有关的。
执行上下文主要包括:
-
全局执行上下文——代码首次执行的默认环境。
-
函数执行上下文——每当进入一个函数内部。
-
Eval执行上下文——eval内部的文本被执行时。
我们就从那个这些概念出发,看看执行上下文的那些事情。
变量提升
变量提升对于前端人员来说是已经习以为常的事情,但从别的语言来看,这种特性显得非常奇怪。
所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 “undefined”。
对于变量提升的过程我们就不详细讲了,很多面试题都在变着花样的考察变量、函数的提升。
我们来看下对于JavaScript引擎来说在执行过程中做了什么,怎么实现的变量提升。 一段代码的执行分为两个阶段:编译阶段、执行阶段。
是的,JavaScript的执行是需要编译的,只不过它属于解释型语言,编译过程是在执行之前的很短时间之内完成的(具体的编译原理,后面的文章中会详细介绍)。
在编译阶段做了什么呢?
我们以一个例子来进行分析:
showMyName()
console.log(myname)
var myname = '前端记事本'
function showMyName() {
console.log('函数 showName 被执行');
}
这段代码在执行的时候,变量myname和showMyName的函数声明会被提升。这就是在编译过程中处理的。
代码编译之后会分为两个部分:执行上下文和可执行代码。(变量环境中的函数体实际上是存在堆内存中的,变量环境中会创建一个指向这个函数体的变量,我们这里图中不做详细标注了)。
而执行上下文中又分为:
-
变量环境
-
词法环境
-
this绑定
我们可以看到,被提升的变量都被放在了变量环境中,剩余的部分则是可执行代码,在执行阶段处理。 过程是怎样的呢? 第 1 行和第 2 行不是变量或者函数声明,所以 JavaScript 引擎不会做任何处理;
第 3 行是经过 var 声明的变量,因此 JavaScript 引擎将在环境对象中创建一个名为 myname 的属性,并将其初始化为undefined;
第 4 行是函数声明, JavaScript 引擎会将函数定义存储到堆 (HEAP)中,并在环境对象中创建一个 showName 的属性,然后将该属性值指向堆中函数的位置(这里涉及到JavaScript内存管理,大家可以去查资料了解一下啊)。
然后就是将代码编译成字节码的过程了,这里就不详细说了。
下面就是执行阶段了
这段代码的变量被提升之后还剩几部分,就是我们要执行的代码了。
showMyName()
console.log(myname)
myname = '前端记事本'
在执行到showName()时会到变量环境中查找这个函数,因为引擎将对这个函数的引用存在了变量环境中,所以很容易找到这段函数代码并执行;
在执行 console.log 的时候,会在变量环境中找到 myname 变量,但此时它的值为 undifined,所以会输出undifined。
最后是对变量myname的赋值操作,会将变量环境中myname的值由undifined -> '前端记事本'
这就是整个变量提升的过程了,其实通过这个过程我们也就知道了,如果声明了两个相同名称的函数,在执行的时候会怎么样了:因为名称相同,所以变量环境中只会存在一个函数名称变量,这个变量名称会指向最后声明的那个函数。
好了,变量提升讲完了,我们可以看到对于执行上下文的三个部分,我们只涉及到了变量环境,那其它两个部分呢?我们下面再来看另外一个概念,作用域。
作用域
上面在提到变量提升的时候我们说,对于其它语言,变量提升是一个很奇怪的特性。因为JavaScript 变量提升这种特性,会导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷。
其中一个很大的影响就是作用域。
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
javascript的作用域主要包括:全局作用域、函数作用域。没有块级作用域。这就造成了我们面试中那个经典的for循环输出i的问题。相信大家对于这种面试题的结果已经很了解了。
但现在,我们从执行上下文的角度分析一下。
对于作用域:
全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
我们说过,变量提升会对作用域的理解产生误解。
再来分析一个例子。
var myname = " 三木 "
function showName(){
console.log(myname);
var myname = " 前端记事本 "
console.log(myname);
}
showName()
这段代码输出是“undifined”和“前端记事本”,为什么呢?你可以看到这里有两个 myname 变量:一个在全局执行上下文中,其值是“三木”;另外一个在 showName 函数的执行上下文中,变量被提升了,其值是 undefined。
所以在执行第一个 console.log(myname)时直觉以为会是“三木”,但实际用的是 showName 函数的执行上下文中的myname,此时的值为 undefined。执行第二个console.log(myname)时,showName 函数的执行上下文中的 myname 已经被赋值为“前端记事本”了。
所以通过第一个 myname 的输出可以看到变量容易在不被察觉的情况下被覆盖掉
后来ES6出现了,推出了let、const关键字,使得 javascript 多了块级作用域。 let 和 const 声明的变量不再被提升了。
所以下面这段代码:
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()
我们前面说过 javaScript 引擎会先对其进行编译并创建执行上下文,然后再按照顺序执行代码。现在我们引入了 let 关键字,let 关键字会创建块级作用域,那么 let 关键字是如何影响执行上下文的呢?
看下上面的例子,它在编译之后的 foo 函数执行上下文是这样的:
另外,函数代码块中用 let 定义的 b 和 d 并没有出现在foo函数的执行上下文中。 所以可以看到:
函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。
在函数的作用域内部,通过 let 声明的变量并没有被存放到词法环境中。
然后是执行阶段,在执行到代码块的时候,会在foo函数的词法环境中单独一块区域存放 b 和 d。
可以看到在代码块外部定义的 b 和内部定义的 b 是独立分开的。
其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。
需要注意下我这里所讲的变量是指通过 let 或者 const 声明的变量。
所以这样在执行下面代码在查询变量值的时候,会先从词法环境的栈顶开始查找,如果找不到就去变量环境中去查找。
因为词法环境维护了一个小的栈,所以在代码块执行完之后, b 和 d 所在的区块就被弹出销毁了。
这就是块级作用域在执行栈的处理过程。
Tips:对于 let 和 const 定义的变量有个“暂时性死区”的概念,我们执行一个例子。
这就是暂时性死区的报错,初始化之前无法访问tmp。其实变量的整个创建过程包括:创建、初始化、赋值。所以 let 变量不会变量提升的真实情况是:在块作用域内,let声明的变量被提升,但变量只是创建被提升,初始化并没有被提升,在初始化之前使用变量,就会形成一个暂时性死区。
-
var 的创建和初始化被提升,赋值不会被提升。
-
let 的创建被提升,初始化和赋值不会被提升。
-
function的创建、初始化和赋值均会被提升。
作用域链
看个例子:
function bar() {
console.log(myName)
}
function foo() {
var myName = " 三木 "
bar()
}
var myName = " 前端记事本 "
foo()
我本来以为结果是:“三木”,结果它输出的是 " 前端记事本 "。为什么会这样? 这段代码的执行栈是这样的:
按照直觉来说,查找 myName 变量的顺序应该是:当前函数执行上下文(bar) -> 外部函数执行上下文(foo) -> 全局执行上下文
但这里有个作用域链的概念。
在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为outer。
所以,真实情况是:
当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,比如上面那段代码在查找 myName 变量时,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。
那又是什么决定outer的指向呢?
那就是:词法作用域: 词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。
JavaScript 作用域链是由词法作用域决定的,词法作用域是由函数声明位置决定的,所以上面 bar() 函数在全局环境声明的,所以 outer 指向是全局执行上下文,而不是 foo 函数的执行上下文。
this
对于刚学习 JavaScript 的同学来说,this 真的很让人头大。而这个 this 的指向就是绑定在第一部分我们说过执行上下文中的 this绑定 上的。
在函数执行上下文中,this 的值取决于该函数是如何被调用的,而不是函数声明的位置。
一般对于 this 的调用就分为几种:
-
对象调用内部的函数,该方法的执行上下文中的 this 指向对象本身;
-
call、bind和apply改变方法的 this 指向;
-
new 一个构造函数的时候也是 this 指向改变的过程。
闭包
**函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起构成闭包(closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。 ** 对于下面这段代码,内部函数showName函数,访问了外部 foo 函数的变量 myName。
function foo() {
var myName = " 三木 "
function showName() {
console.log(myName)
}
return showName
}
var bar = foo();
bar()
整个调用栈的情况如下图:
根据词法作用域的规则,内部函数 showName 总是可以访问它们的外部函数 foo 中的变量,所以返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 showName 函数依然可以使用 foo 函数中的变量 myName 。所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:
在浏览器中看下是这样的:
从上图可以看出,foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 showName 方法中使用了 foo 函数内部的变量 myName,所以这个变量依然保存在内存中。就像单独给showName方法配置了一个数据包,无论在哪里调用showName函数,都会带着这个数据包。
因为是专属于showName函数的数据包,所以除了 showtName 函数之外,其他任何地方都是无法访问该数据包的,我们就可以把这个数据包称为 foo 函数的闭包。
好了,现在我们终于可以给闭包一个正式的定义了。在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。
总结
我们从JavaScript的执行上下文的角度分析了一下我们常见的几个概念,整体较少比较粗略,感兴趣的同学可以自己去深入研究一下执行上下文,对于我们分析一些执行原理和处理性能优化是非常有帮助的。
参考文章:
《了解JavaScript的执行上下文》
《浏览器工作原理与实践》 - 李兵
《Understanding Execution Context and Execution Stack in Javascript》 - Sukhjinder Arora
如果你希望了解更多前端知识,请关注我的公众号“前端记事本”