1. 前言
最近读了一些资料,对 JS 中的执行堆栈有了新的体会,有的是来自一些书籍(红宝书、《你不知道的JS》),有的是一些优秀的博客和教程,脑子里装了很多东西,但是平常开发无法直观地去应用这些知识,所以想了想还是写一篇博客来记录一下吧,下面一起来探索其中的奥妙吧~
2. JS中的代码执行流程
我先提出一个问题:JS 代码到底是如何执行的呢?
我们都知道 JS 是单线程的,但严格来说负责执行 JS 代码的程序是单线程的,这个程序就是 JS 引擎,比如我们熟知的 Google V8 引擎。 JS 代码的执行需要环境提供内存空间、依赖的全局变量、Event Loop系统,而这个是由 JS 引擎所在的宿主环境提供的,这个环境可以是浏览器或者 Node.js(下面的讨论基于浏览器中的V8环境)。
当我们写完代码后,代码的表现形式还是字符串(相对于代码本身来说),接下来会大体经过下面阶段:
- 词法分析:将代码字符串分析成词法单元(token),你可以理解为英文单词中的词性(名词、动词、介词...)
- 语法分析:将词法单元流解析成 AST (抽象语法树),这个过程包括词法作用域生成、变量提升等阶段。
- 代码生成:根据上面生成的 AST 转换成字节码,这部分由 V8 中的
Ignition解释器来生成的。 - 执行代码:逐条解释执行字节码,当 V8 发现有大量重复字节码时(热点代码
HotSpot),会将其编译成机器码(由引擎中TurboFan编译器进行编译),下次再碰到类似字节码不需要解释,直接执行,这种与解释器配合的过程也称为JIT(即时编译)。
而从底层去追溯,那就是 CPU 如何执行计算机指令的问题了,这里不在本文的范畴了。上面过程用一张图表示:
有了上面的知识的铺垫后,我们可以知道 JS 的执行大体上分为两个阶段:
- 编译阶段:变量提升、确定词法作用域。
- 运行阶段:创建执行上下文、作用域链等。
图中右半部分不用看清楚,只需要了解长什么样子就可以了。
下面将从编译阶段与运行阶段来对本文主题进行展开说明。
2.1 可执行环境
引擎是怎么知道要执行代码的呢?这里就要提到 JS 中可执行的环境了,主要是三类:
- 全局执行环境
- 函数执行环境
- eval执行环境
本文暂不考虑 eval 所在的执行环境。
我们先来说说全局执行环境,当你打开一个 tab 页面后,浏览器就会单独分配一个渲染进程(暂时不考虑 iframe ,以及同域 tab),它提供了 JS 引擎需要的一切(内存,全局变量),其中就包括了全局执行环境,也就是说当你打开 tab 页面,JS 引擎就立马开始工作了,它会执行 script 标签里面的代码。
当遇到函数调用时,会创建一个新的执行环境,这个环境就是函数执行环境,也可以叫执行上下文。而 JS 为了管理函数的调用关系,申请了一个固定内存(不同浏览器大小有差异),这个内存区域就是执行环境栈,它来管理当前渲染进程中所有的函数调用关系。当 tab 页面打开时,就已经创建了一个全局执行上下文,并且放到了栈的底部。
讲到这里我们先捋一捋,上面引入了一些概念:
- 执行环境(Execution Environment):JS 代码可执行的一个环境。
- 执行环境栈 (Execution Stack Context):一块内存区域,用于管理函数的调用关系。
- 执行上下文(Execution Context):当前代码所在区块的上下文,保存了当前代码需要的数据(比如this、变量)。
我们看一个简单的例子:
var a = 1
function bar() {
console.log(a)
}
function foo() {
bar()
}
foo()
上面这段代码展现的形式如下:
当然浏览器也是可以清楚看到调用关系的:
2.2 变量对象
现在有一个新的疑问:bar 函数的 a 变量是如何找到它的值?log 我们没有定义呀,它又在哪儿?
这里我引用红宝书中的一段话:
变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象(variable object),而这个上下文中定义的所有变量和函数都存在于这个对象上。
这个变量对象就存在于执行上下文中,而全局执行上下文比较特殊,它还有一个全局对象(global object),它里面包括了能提供给 JS 引擎的各种 API:DOM、BOM、定时器...但是我们是不能直接访问的,必须通过一个引用关系来访问,这个就是我们熟悉的 window对象 ,而为了方便我们使用,我们可以在使用这些 API 的时候省略 window这个关键字。
说完了全局执行上下文中的变量对象后,我们再来聊聊函数执行上下文。它内部也有一个变量对象,但它的名字比较特殊,函数中的变量对象被称为活动对象(activation object)。我就纳闷了,两个对象作用都是一样的,凭啥还要搞“特殊化”?后来我仔细想了想,函数只有调用的时候,函数执行上下文才会被激活,被激活这个过程里面的各种变量就“活跃”了,如果函数不掉用里面的代码对于解释器来说就是普通的字符串,所以叫活动对象也挺好。
讲到这里,我们再总结一下:
执行上下文中会存在一个叫变量对象的家伙,在全局执行上下文中叫 variable object,而且还多了一个叫 global object,而在函数执行上下文中被叫做 activation object,它们里面都存放了代码执行需要用到的各种数据。
到此为止,我们接触了很多名词,为了后面作图方便,这里对相关术语进行缩写:
- AO:activation object。
- GO:global object。
- VO:variable object。
- ESC:执行环境栈。
- EC:执行环境。
那什么时候变量会存到变量对象里头呢?这里就要说到变量提升了。
2.3 变量提升
我们再来看一个例子:
console.log(a)
function a() {
console.log('a')
}
var a = 1
function a() {
console.log('b')
}
上面代码会打印最后一个函数 a 的字符串形式,这是因为代码在 AST 生成阶段会进行变量提升,并且把这些变量存放在 VO/AO 中,初始值为 undefined。如果 VO/AO 中已经存在该变量声明,则跳过;如果是function 声明的同名变量,则覆盖。
能提升的变量有下面几种方式:
- function 声明的变量。
- var 声明的变量。
- import 声明的变量(暂时不考虑)。
上面整段代码执行之前会先扫描上面提到的关键字(这里是 function 和 var ),如果有会提升到 VO 中,下面是解析整体流程:
- 遇到函数声明
function a,放入VO,并且让其初始值为undefined。 - 遇到
var a,准备放入VO,结果发现VO已经存在同名变量,跳过。 - 再次遇到
function a,准备放入VO,结果发现VO已经存在同名变量,覆盖。
从这里可以看出,function 声明的变量很有“分量”,同一个变量使用 function 声明可以覆盖已经存在的同名变量,说明 function 声明的优先级比 var 声明的要高。
还有一点要注意的是,变量的提升只会提升到当前词法作用域的顶端。咦,啥是词法作用域 ?
2.4 词法作用域
我们开头有说过,代码在生成 AST 的时候就会确定词法作用域,也就说我们代码还没有执行它的词法作用域就已经确定好了,通俗来说词法作用域就是变量能被访问的一块区域,它决定着变量的生命周期。我们再来看一段代码:
function baz() {
console.log(a)
}
function foo() {
var a = 2
function bar() {
var a = 3
console.log(a)
baz()
}
bar()
}
var a = 1
foo()
大家记住一句话就好:函数的词法作用域跟它的定义的位置有关系。上面代码的执行栈为:
- foo函数被调用,创建一个新的执行上下文,入栈。
- bar函数被调用,创建一个新的执行上下文,入栈。
- baz函数被调用,创建一个新的执行上下文,入栈。
那 baz 里面访问 a 变量时它是如何知道去哪个词法作用域去查找 ?
这里需要介绍一个特殊的机制:函数在创建 AO 的时候会存放它上一级词法作用域的引用,当 baz 执行上下文中找不到 a 时,会通过这个引用往上一层的词法作用域查找,而它上一层的词法作用域是全局作用域,所以它最终打印的是 1,这个过程也是作用域链的机制,而查找 a 的值的过程也是 RHS 查询规则。
作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。 --- 《你不知道的JavaScript》(上)。
3. 案例分析
我们上小节从代码的执行阶段开始,往编译阶段进行追溯, 了解了 JS 在不同阶段会有不同的处理。接下来我们会结合一些案例来对本文所有涉及到的知识点进行串讲。
3.1 案例一
var a = {
n: 1,
}
var b = a
a.x = a = {
n: 2,
}
console.log(a.x)
console.log(b)
这是一道很经典的题,我们先分析一下。首先在编译期间:
- 遇到
var进行变量提升。 - 确定全局作用域。
- 将外部(上下文)引用保存在
outer(这里就是全局)。
执行代码的时候:
- 动态分配堆区内存
- 确定执行上下文
用图来展示过程就是:
3.2 案例二
var num = 100
function fn() {
var num = 200
return function (a) {
console.log(a + num++)
}
}
var foo = fn()
foo(10)
foo(20)
还是像上题分析:
- 变量提升:num、fn、foo 提升到 VO,初始值
undefined。 - 函数也是一个特殊的对象,所以也会在堆内存申请一块空间。
- 函数调用时确认函数上下文(
this、AO...),作用域链、形参赋值、初始化arguments。
调用 fn 函数时,创建一个函数执行上下文,入栈,然后将返回的函数引用值存储在 foo 变量中,但是当 fn 调用出栈时,发现内部函数引用了 AO 中的 num 变量,导致这块地址无法被回收,于是就形成了闭包。
然后再调用 foo 函数,创建一个新的上下文,设置实参为 10 ,然后利用 RHS 查询找到闭包里的 num ,于是最后打印的是 210,最后让闭包里面的 num 自增为 201(注意自增的方向,放前面先自增后运算,反之后自增)。后面再次调用 foo 时,处理过程是同样的,这里就不分析了。
那闭包如何销毁?
这个就要分析闭包所在的内存空间被引用的位置了,这里是存在了 foo 变量中,而这个变量在全局上下文,所以只能等到页面关闭的时候闭包才可以释放(或者通过设置 foo=null 进行手动释放),而如果 foo 是局部变量时,那函数销毁时就会等待垃圾回收器回收。
上面提到的手动释放是立马释放内存,而是让它变成无引用状态,等垃圾回收器回收,这点跟 C++/C 语言中的释放内存不是一个概念。
4. 总结
本文主要对 JS 执行过程中涉及到一些如执行上下文、变量对象、变量提升等特性进行了讲解,最后用了两个案例来对上面过程进行一个概述。虽然都是一些概念性知识,但是理解了这些概念对我们理解 JS 语言有很大的帮助。最后,本文难免有一些概念没有阐述有误情况,请诸位进行批评指正,您的建议也是对我的肯定,多谢观看~
5. 参考
- 《浏览器工作原理与实践》(极客时间专栏)
- 《你不知道的JavaScript》(上)
- 《JavaScript高级程序设计》(第4版)
- Js是怎样运行起来的(推荐阅读)