深入浅出JavaScript执行机制(上)

914 阅读7分钟

前言

最近开始陆陆续续写文了~

前面几篇文章记录了宏观视角下的浏览器,今天想开始新的一个话题,了解一下 JavaScript 这个即大众又神秘的家伙。只有理解了 JavaScript 的执行上下文,才能更好地理解 JavaScript 语言本身,比如变量提升、作用域和闭包等。

变量提升

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

一段 JS 代码:

showName()
console.log(myname)
var myname = 'juejin'
function showName() {
   console.log('showName被调用');
}

模拟变量提升:

截屏2023-09-04 21.40.02.png

上图可看出,有两处调整:

  • 声明的部分都提升到了代码开头,如:变量myname 和 函数showName,且变量赋值为undefined。
  • 可执行代码部分,移除了声明部分。

JS 代码执行流

字面上看,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,。但,这并不准确。实际上变量和函数声明在代码里的位置是不会改变的,而是在编译阶段被 JavaScript 引擎放入内存中

截屏2023-09-04 21.51.19.png

编译阶段

一段 JS 代码,经过编译后,会生成两部分内容:执行上下文可执行代码

执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。在执行上下文中存在一个 变量环境的对象(Viriable Environment),该对象中保存了变量提升的内容.

image.png

用上面的例子做分析 变量环境是如何生成的:

showName()
console.log(myname)
var myname = 'juejin'
function showName() {
   console.log('showName被调用');
}
  • 第1、2行代码,不是声明操作,所以 JS 引擎不会做任何处理;
  • 第3行。声明了一个 myname 的变量,JS 引擎会在环境对象中创建一个 myname 的属性,并赋值 undefined 初始化;
  • 第4行,声明了一个 showName 的函数,JS 引擎将函数定义存到堆(HEAP)中,并在环境对象中创建一个 showName 的属性,将该属性值指向堆中函数的位置;

变量环境对象生成后,JS 引擎将声明以外的代码编译为字节码。

执行阶段

开始按序执行“可执行代码”。

showName()         // 函数 showName 被执行
console.log(myname) // undefined
myname = 'juejin'
}
  • 当执行到 showName 函数时,JS 引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以 JS 引擎开始执行该函数,并输出“函数 showName 被执行”结果。
  • 接下来打印“myname”信息,JS 引擎继续在变量环境对象中查找该对象,由于变量环境存在 myname 变量,并且其值为 undefined,所以这时候就输出 undefined。
  • 执行第 3 行,把“juejin”赋给 myname 变量,赋值后变量环境中的 myname 属性值改变为“juejin”。

调用栈

在写 JavaScript 代码的时候,有时候可能会遇到栈溢出的错误:

image.png

探索其中的缘由,先了解一下什么是调用栈。调用栈就是用来管理函数调用关系的一种数据结构。比如:一个函数中调用另外一个函数。

什么是函数调用

var a = 2
function add(){
var b = 10
return  a+b
}
add()

这段代码中函数调用的过程:

  • 执行到函数 add() 之前,JS 引擎会为上面这段代码创建全局执行上下文,包含了声明的函数和变量。
  • 执行全局代码:执行 add 函数:
    • 全局执行上下文 中,取出 add 函数代码
    • 对 add 函数的这段代码进行编译,并创建 该函数的执行上下文和可执行代码
    • 执行代码,输出结果。

image.png

所以执行到 add() 时,会存在两个执行上下文 —— 全局执行上下文和 add 函数的执行上下文。

存在多个执行上下文,JS 引擎通过 调用栈 来管理的。

什么是调用栈

关于栈,有这么一个例子可以帮助理解:一条单车道的小路,另一段被堵住了,进去的车子想要出来,得先等后进去的车子先出来。这时,堵住的这条单行线可以看作一个 栈容器,车子开进去的操作叫 入栈,车子倒出去的操作叫 出栈

image.png

JS 引擎利用栈的这种结构来管理执行上下文的。在执行上下文创建好后会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈称为 执行上下文栈,又称 调用栈(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)

1、创建全局上下文,并将其压入栈底:

image.png

2、执行全局代码:首先会执行 a=2 的赋值操作:

image.png

3、执行 addAll 函数:

JS 引擎会编译该函数,并为其创建一个执行上下文,最后还将该函数的执行上下文压入栈中

image.png

执行 d=10 赋值操作,d的值变为 10.

4、执行 add 函数:

为其创建执行上下文,并将其压入调用栈

image.png

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

image.png

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

image.png

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

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

栈溢出

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

特别是在写递归代码的时候,就很容易出现栈溢出的情况:

当以下代码执行时,会发生栈溢出错误:

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

image.png

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

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

一段递归代码,有栈溢出报错:

function runStack (n) {
  if (n < 1) return 0;
  return n + runStack(n-1);
}
runStack(50000)

可以利用任务队列来实现:setTimeout、Promise

async function runStack (n) {
  if (n < 1) return 0;
  await Promise.resolve()
  return n + await runStack(n-1);
}
await runStack(50000)

总结

  • JavaScript 的执行机制:先编译,再执行;
    • 在编译阶段,变量和函数会被存放到变量环境中,变量的默认值会被设置为 undefined;
    • 在代码执行阶段,JS 引擎会从变量环境中去查找自定义的变量和函数;
  • JS 引擎利用栈的这种结构来管理执行上下文的;
    • 每调用一个函数,JS 引擎会为其创建执行上下文,并把该执行上下文压入调用栈;
    • 当前函数执行完毕后,JS 引擎会将该函数的执行上下文弹出栈;
    • 当分配的调用栈空间被占满时,会引发“堆栈溢出”问题;