深入 JavaScript 核心:从词法作用域到闭包的底层奥秘

1 阅读8分钟

深入 V8 底层:从词法作用域到闭包的硬核实战


作为一名 JavaScript 开发者,你是否遇到过以下困惑?

  • 为什么函数在别处调用,却打印了全局变量,而不是调用者的局部变量?
  • 为什么 var 声明的变量会“提升”到顶部,而 let/const 却不行?
  • 函数执行完毕出栈后,为什么它内部的变量还能被外部访问(闭包)?

这些问题的根源,都在于我们只看到了代码的执行顺序(调用栈),而忽略了代码的静态结构(词法作用域)。

本文将带你透过 JavaScript 代码的表象,深入 V8 引擎的底层,结合具体的代码案例,彻底搞懂 词法作用域 (Lexical Scope)作用域链 (Scope Chain)  以及 闭包 (Closure)  的工作原理。


🏗 第一章:词法作用域 —— 变量的“出生地”决定命运

JavaScript提到一个核心铁律:词法作用域是静态的,只和函数声明的位置相关,在编译阶段就决定好了,和调用没有关系。

这听起来很反直觉。让我们用一个例子来说明:


function bar() { 
  console.log(myName); 
} 

function foo() { 
  var myName = '极客邦';
  bar(); 
} 

var myName = '极客时间';
foo();

❓ 灵魂拷问: 这段代码会打印什么?

  • 猜测 A: '极客邦'。理由:bar 是在 foo 里面执行的,foo 里有 myName
  • 猜测 B: '极客时间'。理由:全局有 myName

如果你选了 A,说明你陷入了“动态作用域”的思维陷阱。JavaScript 采用的是词法作用域(静态作用域)

V8 底层视角:编译阶段的“指针”

在 V8 引擎中,作用域的确定发生在编译阶段,而不是执行阶段。

  1. 编译期(静态分析):

    • V8 扫描代码,发现 bar 函数是在**全局(Global)**环境下声明的。
    • 此时,引擎为 bar 创建了一个内部指针(``),指向它的“出生地”——全局作用域。
    • 结论: bar 的查找路径被锁定为:bar 自身 -> Global
  2. 执行期(调用栈):

    • 执行 foo()foo 入栈。
    • foo 内部执行 bar()bar 入栈。
    • 关键点: 虽然 bar 是在 foo 的栈帧里被调用的,但这改变不了 bar 在编译期就确定的“血统”(作用域链)。

最终结果:
bar 在查找 myName 时,跳过了 foo,直接找到了全局的 '极客时间'

图解:

622bce1414bbc24943856fe3aef8da25.png

核心总结:
函数在哪里声明,决定了它去哪找变量;函数在哪里调用,只影响调用栈的顺序,不影响作用域链。


第二章:作用域链与块级作用域 —— V8 的“查找地图”

随着 ES6 的到来,JavaScript 的作用域机制变得更加复杂,“作用域链是变量的查找路径,按函数声明的时候(编译)已经决定”。

接下来我将用一个例子来展示了函数作用域块级作用域在 V8 引擎中的共存与查找逻辑。


function bar () { 
  var myName = "极客世界"; 
  let test1 = 100; 
  if (1) { 
    let myName = "Chrome 浏览器";
    console.log(test) // 注意:这里引用了 test
  } 
} 

function foo() { 
  var myName = "极客邦"; 
  let test = 2; 
  { 
    let test = 3; 
    bar() 
  } 
} 

var myName = "极客时间"; 
let myAge = 10; 
let test = 1; 
foo();

❓ 灵魂拷问: 为什么 bar 函数内部的 console.log(test) 会报错,而不是打印 32

🧠 V8 底层视角:词法环境栈(Lexical Environment)

在 V8 中,执行上下文(ExecutionContext)包含两个核心组件:

  1. Variable Environment(变量环境): 处理 var
  2. Lexical Environment(词法环境): 处理 let/const,它是一个栈结构。

执行过程拆解:

  1. 全局初始化:

    • 全局对象挂载 myName (var) 和 test (let, 值为 1)。
  2. foo 执行:

    • foo 入栈。foo 的 LexicalEnvironment 记录 myNametest (值为 2)。
    • 进入块级作用域 { let test = 3 }。V8 压入一个新的词法环境,test 被遮蔽(Shadowing)为 3。
    • 调用 bar()
  3. bar 执行(风暴中心):

    • bar 是全局声明的,它的作用域链是:bar -> Global

    • bar 内部有一个 if 块级作用域。

    • bar 寻找 test 时:

      • Step 1:bar 函数作用域内找?没有。
      • Step 2: 进入 bar 内部的 if 块级作用域找?没有(只有 myName)。
      • Step 3: 向上找全局?找到了 test 吗?No!
      • 真相: bar 的作用域链与 foo 完全隔离。bar 根本“看不见” foo 里的任何变量,包括那个 test

图解:

bcad064312cdc96fd4606bf6c435a064.png ✅ 最终结果:
ReferenceError: test is not defined
bar 试图访问一个在它作用域链上根本不存在的变量。

💡 核心总结:
let/const 创建的块级作用域是“围墙”,var 创建的函数作用域是“盒子”。作用域链是一条单向向上的路,跨盒子(函数)的围墙是无法逾越的。


🎒 第三章:闭包 —— 函数的“专属背包”

这是 JavaScript 最难懂的概念,也是重头戏。文档中有一段非常生动的描述:

“这个背包闭包,这个闭包里面的变量叫自由变量... foo 函数执行完后,其执行上下文从栈顶弹出了,但是由于返回的 setName, getName 使用了 foo 函数内部的变量... 这两个变量依然在内存中。”

让我们用 一个 例子 来打开这个“背包”。


function foo() {
  var myName = "极客时间"
  let test1 = 1
  
  var innerBar = {
    getName: function() {
      console.log(test1) 
      return myName 
    },
    setName: function(newName) {
      myName = newName 
    }
  }
  return innerBar 
}

var bar = foo() // foo 执行完,按理说该销毁了
bar.setName("极客邦")
console.log(bar.getName());

❓ 灵魂拷问: foo 函数执行完后,栈帧已经弹出(Pop),为什么 bar.getName() 还能访问到 myNametest1?V8 的垃圾回收器(GC)为什么不把它们收走?

🧠 V8 底层视角:内存的“逃逸分析”与“引用计数”

要理解闭包,必须理解 V8 的内存管理机制。

  1. 正常生命周期(无闭包):

    • 函数执行 -> 分配栈帧和堆内存 -> 函数结束 -> 栈帧弹出 -> 堆内存无引用 -> GC 回收。
  2. 闭包发生时(逃逸):

    • Step 1: 定义时的标记。 当 V8 编译 getNamesetName 时,发现它们引用了外部变量 myNametest1。V8 标记这些变量为自由变量(Free Variables)
    • Step 2: 返回时的“绑架”。 foo 返回了 innerBar 对象。这个对象包含了 getNamesetName 函数。
    • Step 3: 引用链的建立。 全局变量 bar 持有了 innerBar。而 innerBar 里的函数又持有了 foo 的自由变量。
    • Step 4: 垃圾回收的“特赦”。foo 执行完毕,V8 的 GC 准备回收内存。GC 发现:myNametest1 依然被 bar 间接引用着!
    • Step 5: 晋升为堆对象。 为了防止悬垂指针,V8 将这些自由变量从栈内存“晋升”(或直接分配在堆中),并将其绑定在一个新的数据结构上——这就是闭包(Closure)

✅ 最终结果:
bar 变成了一个拥有“专属背包”的对象。只要 bar 还存在于内存中,这个背包(Closure)里的 myNametest1 就永远不会被销毁。

💡 核心总结:
闭包的本质是函数对象携带了它定义时环境的引用
注意: 闭包虽然强大,但因为它阻止了内存回收,滥用会导致内存泄漏。记得在不需要时将 bar = null


📊 第四章:全景图 —— JS 语言工作的底层机制

我们将上述三个章节串联起来,绘制出 JavaScript 运行的全景图。

1. V8 引擎工作流

  • 编译阶段(Parsing & Compiling):

    • 生成 AST(抽象语法树)。
    • 确定词法作用域:这是最关键的一步。引擎决定了每个函数去哪找变量(Scope Chain)。
  • 执行阶段(Execution):

    • 创建执行上下文(ExecutionContext) ,压入调用栈(Call Stack)
    • 变量环境(Var)与词法环境(Let/Const)初始化。

2. 作用域链查找规则(Scope Chain)

  • 查找路径: 当前作用域 -> 外层作用域 -> ... -> 全局作用域。
  • 查找时机: 在编译阶段静态确定,运行时不可变。
  • 误区纠正: 作用域链不是由“函数在哪里调用”决定的,而是由“函数在哪里声明”决定的。

3. 闭包形成的三个条件

  1. 函数嵌套: 外层函数包含内层函数。
  2. 内部函数引用外部变量: 内层函数使用了外层函数的局部变量(自由变量)。
  3. 内部函数被外部访问: 通常通过 return 将内层函数暴露给全局,或者将其赋值给全局变量。

🚀 结语:掌握“道”与“术”

  • 词法作用域告诉我们:写代码时的结构决定了变量的归属。
  • 作用域链告诉我们:变量查找是一场由内向外的单向旅行。
  • 闭包告诉我们:函数可以像人一样,背负着出生环境的记忆去往任何地方。

理解了这些底层机制,你不仅能在面试中对答如流,更能在编写复杂应用(如 React Hooks、模块化设计)时,精准预判代码的运行结果,写出更健壮、更高效的代码。

最后送给大家我很喜欢的一句话:

代码的执行在栈上,变量的生命在堆里,而逻辑的灵魂,在于你对作用域的理解。