从执行上下文到事件循环 - JavaScript 运行机制全面探讨

前言

本文会介绍JS的堆栈,执行上下文,变量提升,event loop,垃圾回收等JS运行机制,可能文章会略长,如果本文对你有帮助,各位可以点赞收藏,那么话不多说,开始了解JS的运行机制

执行上下文

JavaScript 在执行时会创建一个执行栈,该执行栈会包括一个全局执行上下文,这是代码执行的起点,全局执行上下文在代码开始执行前就已经存在,且随着页面的销毁而关闭

当 JavaScript 运行时创建全局执行上下文时,它包含了以下几个重要的部分:

  1. 全局对象 (Global Object)

    • 在浏览器环境中,全局对象是 window 对象;在 Node.js 等环境中,全局对象是 global 对象。
    • 全局对象包含了所有全局作用域中的内置属性和方法,例如 setTimeoutconsoleMath 等。
    • 你可以通过在全局作用域中直接访问这些属性和方法,console.log()
  2. 全局作用域链 (Global Scope Chain)

    • 全局作用域链是由多个对象组成的链式结构,用于在代码执行时查找变量和函数的定义。
    • 在全局执行上下文中,全局作用域链通常包括了全局对象自身以及一些其他对象,例如浏览器环境中的 window 对象、Node.js 环境中的 global 对象等。
    • 当代码中使用变量或者函数时,JavaScript 引擎会沿着作用域链逐级向上查找,直到找到匹配的变量或者函数为止。
  3. this 值

    • 在全局执行上下文中,this 值通常指向全局对象。
    • 在浏览器环境中,全局对象是 window 对象,因此在全局执行上下文中,this 值就是指向 window 对象。
    • 在 Node.js 等环境中,全局对象是 global 对象,因此在全局执行上下文中,this 值就是指向 global 对象

变量提升

在全局执行上下文之后,JS会开始变量提升, 会把全局声明的变量,函数都提升到顶部,这是JS的运行机制,比较好理解,但是这里面有两点需要注意的情况:

  1. 有的人会认为let,const声明的对象是不会变量提升的,其实不然, letconst 声明的对象照样会被提升到顶部,但是它们会存在一个 暂时性死区(TDZ), 在它们被初始化之前无法被访问
  2. 在函数中,如果不使用变量声明关键字,那么这个变量就会被提升为全局变量,不过需要在函数执行之后才会被全局提升

数据存储

在JS中,数据存储主要分为两种:栈(Stack)和堆(Heap)。

  • 栈(Stack) :栈用于存储基本数据类型(如数字、字符串、布尔值),以及执行上下文和作用域链。栈中的数据大小固定,访问速度快,但存储空间有限。
  • 堆(Heap) :堆用于存储对象和数组等复杂数据类型。堆中的数据大小不固定,可以动态分配和释放内存,但访问速度相对较慢。

由于栈中存储的引用类型其实存的是一个指针,该指针指向与 堆 的具体位置,所以这里就会有一个深浅拷贝的问题,如果只是简单的浅拷贝,会造成意想不到的执行后果,例如

css 复制代码
var a = { name: 'jack' }
var b = a
a.name = 'rose'
console.log(b.name) // rose

关于栈存储的执行上下文怎么理解呢, 通过上文我们知道,当JS引擎执行代码时,它会遵循以下步骤:

  1. 创建执行上下文:每当进入一个新的执行环境(如调用一个函数或执行全局代码),JS引擎都会创建一个新的执行上下文,并将其压入调用栈中。
  2. 变量提升:在执行上下文创建阶段,所有的变量和函数声明都会被提升到作用域链的顶部。
  3. 代码执行:在执行上下文准备好之后,JS引擎开始执行代码。变量赋值、函数调用等操作都在这个上下文中进行。
  4. 执行上下文出栈:当当前的执行上下文代码执行完毕后,它会被移出调用栈,控制权返回给上一个执行上下文。

在栈中存储执行上下文是JS引擎管理代码执行的一种有效机制,当一个函数被调用时,它的执行上下文会被压入执行栈。函数内部的变量、参数等信息都存储在栈中。如果函数内部又调用了其他函数,那么被调用的函数也会将自己的执行上下文压入栈中,形成一个调用栈。当某个函数执行完毕,它的执行上下文就会从栈中弹出,控制权返回到之前的执行上下文。栈结构的使用确保了代码执行的顺序性和作用域的隔离,从而保证了JS代码的正确运行。通过理解执行上下文及其在栈中的存储方式,我们可以更好地理解JS代码的执行流程和作用域规则

事件循环

深入理解 JavaScript 事件循环

JavaScript 作为一种单线程语言,如何在不阻塞主线程的情况下实现异步操作,是理解其运行机制的关键所在。这就引出了 JavaScript 的事件循环(Event Loop)机制。

事件循环是 JavaScript 实现并发和异步的基础。它由两个主要部分组成:调用栈(Call Stack)任务队列(Task Queue)

调用栈(Call Stack) : 这是一个先进后出(LIFO)的数据结构,用于存储正在执行的函数调用。当函数被调用时,它会被压入栈顶;当函数执行完毕,它会从栈顶弹出。

任务队列(Task Queue) : 这是一个先进先出(FIFO)的数据结构,用于存放异步任务的回调函数。当异步任务(如定时器、事件监听器等)完成时,它们的回调函数会被添加到任务队列中等待执行。

事件循环的工作原理如下:

  1. JavaScript 引擎首先会执行所有同步代码,将它们压入调用栈并逐一执行。
  2. 当遇到异步任务时,它们的回调函数会被添加到任务队列中,而不是直接执行。
  3. 当调用栈为空时(即所有同步代码都已执行完毕),事件循环会检查任务队列。
  4. 如果任务队列中有待执行的回调函数,事件循环会将其取出,压入调用栈执行。
  5. 重复步骤 3 和 4,形成一个无限循环。

事件循环的这种机制确保了 JavaScript 能够在单线程的情况下实现并发和异步操作,避免了主线程的阻塞。理解事件循环的工作原理对于编写高性能的 JavaScript 应用程序至关重要。

除了常见的微任务(如 Promise 的 then 回调)和宏任务(如 setTimeout)之外,HTML5 规范还引入了其他类型的任务,如 MutationObserver、requestAnimationFrame 等,在浏览器的实现上,w3c只负责指定规范,各种事件队列的优先级除了要求Promise 的优先级最高之外, 其他的并没有要求,这个主要看各个浏览器的实现,所以大家只需要知道在清空主进程之后,会优先执行微任务队列。

垃圾回收机制以及闭包

JavaScript 使用自动垃圾回收机制来管理内存。垃圾回收机制会定期检查内存中不再被使用的对象,并将其从内存中释放出来,为新对象腾出空间。

JavaScript 中主要使用以下两种垃圾回收算法:

  1. 标记-清除(Mark-and-Sweep)算法:

    • 这是 JavaScript 引擎中最常用的垃圾回收算法。
    • 该算法分为两个阶段:标记和清除。
    • 在标记阶段,JavaScript 引擎会标记所有活动的对象(即仍然可以被访问到的对象)。
    • 在清除阶段,引擎会释放所有未被标记的对象所占用的内存。
  2. 引用计数(Reference Counting)算法:

    • 这是一种较为简单的垃圾回收算法。
    • 该算法会跟踪每个对象的引用计数。当一个对象的引用计数为 0 时,表示该对象无法再被访问,可以被回收。
    • 但是该算法存在一些问题,比如无法解决循环引用的问题,因此现代 JavaScript 引擎很少使用这种算法。

为了优化垃圾回收的性能,现代 JavaScript 引擎还采用了增量式收集(Incremental Garbage Collection)分代式收集(Generational Garbage Collection) 等技术。

那么垃圾回收和闭包有什么关系呢, 因为闭包会影响垃圾回收的机制,闭包是 JavaScript 中一个非常重要的概念。它指的是一个函数能够访问并操作函数外部的变量。

闭包的特点如下:

  1. 函数嵌套函数:闭包必须存在于一个函数内部,即一个函数内部定义了另一个函数。
  2. 内部函数可以访问外部函数的变量:内部函数可以访问并操作外部函数中的局部变量,即使外部函数已经执行完毕。
  3. 外部函数的局部变量不会被垃圾回收:只要内部函数还存在,外部函数的局部变量就不会被垃圾回收。

闭包的主要应用场景包括:

  1. 数据封装和私有变量:通过闭包可以实现数据的私有化,提高代码的安全性。
  2. 柯里化(Currying)和部分应用:闭包可以用于实现函数的柯里化和部分应用。
  3. 缓存和内存管理:闭包可以用于实现缓存功能,并帮助管理内存。
  4. 模块化设计:闭包是实现模块化设计的关键基础

JS运行机制示例

接下来拿一段代码来分析一下JS具体的执行流程

scss 复制代码
var a = 1; // 全局变量 a 被声明并赋值 
function foo() { // 函数 foo 被声明 
    var b = 2; // 局部变量 b 被声明并赋值 
    function bar() { // 内部函数 bar 被声明 
        var c = 3; // 局部变量 c 被声明并赋值 
        console.log(a, b, c); // 访问并打印 a, b, c 的值 
    } 
    bar(); // 调用内部函数 bar 
} 
foo(); // 调用函数 foo

让我们一步步分析这段代码的执行过程:

  1. 进入全局执行上下文:

    • JavaScript 引擎创建全局执行上下文,并将其推入执行栈。
    • 在全局执行上下文中,引擎会扫描代码,发现变量 a 和函数 foo
    • 变量 a 被声明并初始化为 1。函数 foo 被声明,但不会执行。
  2. 调用函数 foo:

    • 当调用 foo() 时,JavaScript 引擎创建一个新的执行上下文,并将其推入执行栈。
    • foo() 的执行上下文中,引擎会扫描代码,发现局部变量 b 和内部函数 bar
    • 变量 b 被声明并初始化为 2。函数 bar 被声明,但不会执行。
  3. 调用函数 bar:

    • bar() 被调用时,JavaScript 引擎创建另一个新的执行上下文,并将其推入执行栈。
    • bar() 的执行上下文中,引擎会扫描代码,发现局部变量 c
    • 变量 c 被声明并初始化为 3
  4. 打印变量值:

    • bar() 的执行上下文中,console.log(a, b, c) 被执行。
    • 由于 bar() 是在 foo() 内部定义的,它可以访问 foo() 的局部变量 b,以及全局变量 a
    • 因此,console.log 会打印出 1, 2, 3
  5. 执行结束:

    • bar() 函数执行完毕,它的执行上下文从执行栈中弹出。
    • foo() 函数执行完毕,它的执行上下文从执行栈中弹出。
    • 最后,全局执行上下文也从执行栈中弹出,整个程序执行完毕。

文末

总结

通过上述的详细介绍,相信大家对 JavaScript 的运行机制有了更加深入的了解。从执行上下文和调用栈的概念,到变量提升、内存管理和事件循环等,这些都是理解 JavaScript 行为的关键所在。

掌握好 JavaScript 的运行机制,对于我们编写出更加高效和健壮的 JavaScript 代码至关重要。只有充分理解 JavaScript 的内部工作原理,我们才能够更好地优化代码性能,避免潜在的问题,并编写出更加优质的 JavaScript 应用程序。

相关推荐
Tiffany_Ho1 分钟前
【TypeScript】知识点梳理(三)
前端·typescript
安冬的码畜日常1 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js
太阳花ˉ1 小时前
html+css+js实现step进度条效果
javascript·css·html
小白学习日记2 小时前
【复习】HTML常用标签<table>
前端·html
john_hjy2 小时前
11. 异步编程
运维·服务器·javascript
风清扬_jd2 小时前
Chromium 中JavaScript Fetch API接口c++代码实现(二)
javascript·c++·chrome
丁总学Java2 小时前
微信小程序-npm支持-如何使用npm包
前端·微信小程序·npm·node.js
yanlele2 小时前
前瞻 - 盘点 ES2025 已经定稿的语法规范
前端·javascript·代码规范
It'sMyGo3 小时前
Javascript数组研究09_Array.prototype[Symbol.unscopables]
开发语言·javascript·原型模式