JavaScript 执行上下文深度解析:从变量提升到闭包机制

作者:木棉时间:2026-10-26 13:59:01浏览:400

本文深入解析 JavaScript 执行上下文的核心机制,涵盖全局、函数及 eval 三种上下文类型。详细阐述编译阶段变量环境(hoisting)与词法环境的创建过程,揭示 var、let、const 在块级作用域中的行为差异。同时解析调用栈管理、作用域链查找规则及闭包的内存回收策略,帮助开发者理解 JS 底层执行原理。

简介

执行上下文是 JavaScript 代码运行的基础环境,它存储了代码执行所需的变量、变量查找的作用域链规则以及 this 的指向。作为 JavaScript 底层的核心概念,变量提升、作用域链和闭包等常见问题的根源均可在执行上下文中找到答案,因此深入理解执行上下文是掌握 JavaScript 的关键。

执行上下文主要分为三种类型:

  • 全局执行上下文:当程序进入全局代码时,JavaScript 引擎会进行编译,创建全局执行上下文并生成可执行代码。
  • 函数执行上下文:在代码执行过程中,若遇到函数调用,引擎会编译函数内部代码并创建对应的函数执行上下文,生成可执行代码。
  • eval 执行上下文:当使用 eval 函数时,其内部代码也会被编译,并创建相应的执行上下文。

由于执行上下文是在编译阶段创建的,因此接下来我们先梳理一下 JavaScript 代码的整体执行流程。

JavaScript 代码的执行过程

JavaScript 代码的执行分为两个主要阶段:编译阶段和执行阶段。引擎首先对代码进行编译,随后进入执行阶段。

JavaScript 代码以“段”为单位执行,全局代码视为一段,函数执行也视为一段。编译过程同样按“段”进行,因此整个 JavaScript 文件可能经历多次编译阶段。

编译阶段

编译阶段是一个复杂的过程,此处仅做简要介绍:

编译阶段主要完成两件事:创建执行上下文和生成可执行代码。

执行上下文包含变量环境、词法环境和 this 指向。创建执行上下文的具体过程如下:

  • 对于普通变量,JavaScript 引擎会将其添加到变量环境中,并初始化为 undefined。
  • 对于函数声明,引擎会将函数定义添加到变量环境中,并将函数名指向该函数在内存中的位置。

随后,JavaScript 引擎将其他代码编译为字节码,生成最终的可执行代码。

编译阶段结束后,引擎开始执行可执行代码,按顺序逐行执行。当遇到变量或函数时,引擎会在变量环境中查找;若未找到,则报错。

若遇到赋值语句,引擎会将值赋给相应的变量。

var 变量提升与 let 和 const

变量提升是指在 JavaScript 代码执行过程中,引擎将变量的声明部分和函数声明部分提升到代码开头的行为。提升后,变量会被赋予默认值 undefined。

变量提升并非物理上移动代码位置,而是由 JavaScript 引擎在编译阶段将其放入内存中处理。

1. 普通变量提升会赋值为 undefined,函数声明则会将整个函数提升。

console.log(fn);  // [Function: fn]
console.log(a);  // undefined
function fn() {
    console.log(111);
}
var a = 1

2. 函数表达式仅提升变量名,不会提升函数体。

3. 当存在多个相同类型的声明(同为函数声明或同为普通变量声明)时,最后一个声明会覆盖之前的声明。

4. 当函数声明与变量声明同时出现时,函数声明的优先级更高。

鉴于变量提升存在诸多缺陷,ES6 引入了 let 和 const 关键字。通过这两个关键字声明的变量不具备变量提升特性,且支持块级作用域。接下来我们探讨作用域的概念。

作用域

作用域是一套定义函数调用和变量使用规则的体系,主要包含三种作用域:

  • 全局作用域:其中的对象在代码的任何位置均可访问,其生命周期与页面生命周期一致。
  • 函数作用域:在函数内部定义的变量和函数仅能在内部访问,外部无法访问。函数执行结束后,内部定义的变量会被销毁(注:原文此处有疑问“函数不会吗???”,指代不明,保留原意)。
  • 块级作用域:由代码块包含的代码形成块级作用域(ES6 之前不存在),其特性与函数作用域类似。

通过 var 声明的变量不具备块级作用域,而通过 const 和 let 声明的变量具备块级作用域。

JavaScript 如何实现 var 的变量提升及支持块级作用域?这需要从执行上下文的角度进行解释。

编译阶段生成执行上下文:

假设 JavaScript 需要执行一个函数。

  • 首先,编译过程会创建该函数的执行上下文,并生成可执行代码。
  • 在编译阶段,所有通过 var 声明的变量(包括代码块内的变量)都会被创建并存放在变量环境中,初始化为 undefined。
  • 通过 let 或 const 声明的变量(不包括代码块内的变量)会被创建并存放在词法环境中,状态设置为未初始化。
  • 至此,编译阶段结束,开始执行代码。
  • 在执行代码过程中,若遇到代码块,引擎会先将其中通过 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()

当执行到代码块时,对应的执行上下文如下:

foo函数执行上
变量环境
a = 1, c = 3
词法环境
{b = 3, d = 5}
{b = 2}

当代码块的代码执行完毕后,对应的词法环境里的变量就会被弹出栈。

foo函数执行上下文
变量环境
a = 1, c = 3
词法环境
{b = 2}

原因

通过上述分析,我们可以总结变量提升和块级作用域的实现机制:

  • 在编译阶段,通过 var 声明的变量已存在于变量环境中并赋值为 undefined,因此在代码的任何位置都能访问,无需在声明之后才能访问。
  • 通过 let 声明的变量会被存放在词法环境中但未初始化(不包含代码块的 let 或 const 声明的变量),因此无法访问,直到遇到 let 声明语句时才初始化并赋值。
  • 在遇到代码块时,引擎会先将 let 和 const 声明的变量存放在词法环境中并设置为未初始化。如果此时在代码块中在 let 声明变量之前使用该变量,引擎不会去外部作用域查找该变量,因为此时词法作用域中已存在该变量但未初始化,因此会报错。这就是 let 暂时性死区(TDZ)的原因。

单个执行上下文中变量的查找规则

引擎沿词法环境的栈顶向下查询,如果在词法环境的某个块中查找到了变量,就直接返回给 JavaScript 引擎;如果没有找到,则继续在变量环境中查找。


简介

调用栈

调用栈是一种用于管理函数调用关系的栈结构。

在函数调用之前,引擎会创建对应的执行上下文,并生成相应的可执行代码。

  • JavaScript 维护了一个栈结构,每当遇到函数调用时,就创建一个执行上下文,并将其压入该栈中。
  • 这个栈被称为执行上下文栈,也称为调用栈。
  • 当函数执行完毕后,会将对应的执行上下文从栈结构中弹出。
  • 栈的容量是有限的,当栈容量不足时,可能发生栈溢出错误。

作用域链

  • 在函数中,若在当前作用域中找不到所需变量,引擎会沿着作用域链向下查找,直到找到为止。
  • 我们知道,代码执行时会有对应的执行上下文,变量沿作用域链查找的规则也是在执行上下文中设置的。
  • 在每个执行上下文中,变量环境里都有一个外部引用,用于指向外部的执行上下文,我们称这个外部引用为 outer。
  • 前文已述,变量查找首先从执行上下文的词法环境中进行,找不到则在变量环境中查找;若仍找不到,则沿着 outer 指向的外部执行上下文继续查找。
  • outer 具体引用哪一个执行上下文(作用域),是由词法作用域决定的。

词法作用域

词法作用域指的是作用域由代码中函数的声明位置决定,也称为静态作用域。

也就是说,当创建一个执行上下文时,其内部的 outer 会根据词法作用域指向对应的外部执行上下文。

在外部执行上下文中查找时,也是先从词法环境中开始。

function fn() {
    console.log(a);
}
function fn1() {
    let a = 1
    fn()
}
let a = 3  // let声明的变量是在词法环境中的
fn1()  // 3

闭包

在 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())

上述代码中,由于存在闭包现象,foo 函数执行结束后,内部的变量还会被保存,调用栈如下图所示:

简介

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

简介

闭包的回收

  • 如果闭包使用不当,很容易造成内存泄漏。关注闭包是如何回收的,有助于你正确使用闭包。
  • 通常情况下,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用,就会造成内存泄漏。
  • 如果引用闭包的函数是局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。
  • 因此,在使用闭包时,应遵循一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,且占用内存较大,则尽量让它成为局部变量。
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()

从上下文角度讲 this

执行上下文分为三种,对应的 this 也只有三种:全局上下文的 this、函数中的 this、eval 中的 this。

  • 箭头函数没有自己的执行上下文。
  • 全局上下文的 this 指向全局对象。
  • 函数上下文的 this 根据四种绑定规则判断 this 指向。
  • 执行上下文包含 this 指向。

参考:https://time.geekbang.org/column/intro/100033601

作者最新文章