深入理解 JavaScript 作用域、闭包与执行上下文:从 V8 引擎说起

62 阅读7分钟

在前端开发中,JavaScript 的作用域、闭包、执行上下文等概念常常让人“似懂非懂”。为什么函数 bar()foo() 中调用时打印的是全局变量而不是局部变量?为什么一个函数执行完后,其内部变量还能被外部访问?这些看似玄学的问题,其实都源于 JavaScript 的词法作用域(Lexical Scope)和闭包(Closure)机制

本文将结合 V8 引擎底层原理、代码示例与深入剖析,带你彻底搞懂 JS 的作用域链、闭包形成条件、执行上下文栈等核心机制,并通过7张高清图解直观展示运行时的内存结构。


一、JS 是如何运行的?—— V8 引擎视角

JavaScript 是一门解释型语言,但现代 JS 引擎(如 V8)采用了**即时编译(JIT)**策略,在执行前会经历两个阶段:

  1. 编译阶段(Parsing & Compilation)

    • 解析代码生成 AST(抽象语法树)
    • 确定变量声明、函数声明的位置
    • 建立词法作用域链(Lexical Scope Chain)
  2. 执行阶段(Execution)

    • 创建执行上下文(Execution Context)
    • 维护调用栈(Call Stack)
    • 变量赋值、函数调用、作用域查找

📌 关键点:作用域链是在编译阶段就确定的,与函数在哪里被调用无关!


二、词法作用域 vs 动态作用域

很多初学者会误以为 JS 是“动态作用域”——即函数在哪儿调用,就用哪儿的变量。但事实恰恰相反。

示例 1:1.js

function bar() {
  console.log(myName);
}
function foo() {
  var myName = '极客邦';
  bar(); // 运行时调用
}
var myName = '极客时间';
foo(); // 输出:'极客时间'

问题bar() 是在 foo() 内部调用的,为什么输出的是全局的 '极客时间',而不是 foo 中的 '极客邦'

答案:因为 JS 采用词法作用域

  • bar 函数在全局作用域中声明,它的作用域链在编译时就固定为 [bar 的局部作用域 → 全局作用域]
  • 即使它在 foo 内部被调用,也不会“继承” foo 的作用域。

💡 记住:作用域由函数声明的位置决定,不是调用位置!

0075bf76ce964d209d29033a6115897f.png

✅ 如上图所示,bar() 的执行上下文虽然在 foo() 调用栈中,但其作用域链只指向全局,因此 myName 查找的是全局变量。


三、块级作用域与变量提升(Hoisting)

ES6 引入了 let/const,带来了真正的块级作用域

示例 2:2.js

function bar() {
  var myName = "极客世界";
  let test1 = 100;
  if (1) {
    let myName = "Chrome 浏览器";
    console.log(test); // ❌ ReferenceError: test is not defined
  }
}

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

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

分析

  • bar 中的 console.log(test) 报错,因为 test 在 bar 的作用域中未定义。
  • 尽管 foo 和全局都有 test,但 bar 的词法作用域只包含自身和全局,不包含 foo
  • { let test = 3 } 创建了一个新的块级作用域,但对 bar 完全不可见。

⚠️ 注意:var 有变量提升,但 let/const 有“暂时性死区”(TDZ),在声明前访问会报错。

5653cb25a6dc412684f650d2bd7acd9b.png

🔍 图中展示了 bar() 执行时的变量环境和词法环境,testbar 的词法环境中不存在,导致引用错误。


四、闭包:函数 + 自由变量 = 专属背包

闭包是 JS 最强大也最容易被误解的概念之一。

示例 3:3.js

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; // 返回对象,其中方法引用了 foo 内部变量
}

var bar = foo(); // foo 执行完毕,执行上下文出栈
bar.setName("极客邦");
bar.getName(); // 输出:1, "极客邦"

闭包形成的三个条件:

  1. 函数嵌套getName/setName 嵌套在 foo 内)
  2. 内部函数引用了外部函数的变量myNametest1
  3. 内部函数在外部被访问(通过 return 暴露)

闭包的本质

foo() 执行完毕,其执行上下文本应被销毁。但由于返回的对象中的方法仍引用着 myNametest1,V8 引擎会保留这些变量,形成一个“闭包环境”。

你可以把闭包想象成一个专属背包

  • 背包里装着函数创建时所在作用域的变量(自由变量)
  • 即使原函数已出栈,背包依然存在
  • 每次调用闭包函数,都能从背包中取值或修改

闭包 = 函数 + 词法环境(Lexical Environment)

cdee85739ffb4a26a7a50694cda76f46.png

🎯 上图清晰展示了 foo() 执行完毕后,其变量环境并未被释放,而是被 innerBar 对象中的方法所引用,形成了闭包


五、作用域链查找规则(重点!)

作用域链是一条静态的查找路径,遵循以下规则:

  1. 从当前函数的局部作用域开始查找
  2. 若未找到,沿词法作用域链向上查找(外层函数 → ... → 全局)
  3. 找到即返回;若到全局仍未找到,则报 ReferenceError

🔍 查找路径在函数声明时就已确定,与调用栈顺序无关!

88abb0384b3d43aa8afb9cbf9a87c5b9.png

🧩 如图所示,foo() 中的 count 优先查找自身作用域,再向上查找 main(),最后到全局。这种层级关系由代码结构决定,而非调用顺序。


六、执行上下文与调用栈详解

每个函数执行时都会创建一个执行上下文(Execution Context) ,包含:

  • 变量环境(Variable Environment) :存储变量和函数的值
  • 词法环境(Lexical Environment) :存储变量的绑定关系
  • outer:指向外层执行上下文(用于作用域链)

示例 4:4.js

function bar() {
  var myName = "浏览器";
  let test1 = 100;
  if (1) {
    let myName = "Chrome浏览器";
    console.log(test1);
  }
}

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

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

05db7ca3d0204fa698e421b77163eade.png

🔍 图中标注了五个关键点:

  1. bar 的词法环境中有 myName 和 test1
  2. bar 的变量环境中有 myName="浏览器" 和 outer 指向全局
  3. bar 的变量环境中的 myName 被重新赋值为 "浏览器"
  4. 全局词法环境包含所有全局变量
  5. 全局变量环境中的 myName="极客时间"

七、闭包生命周期:从创建到持久化

我们来观察 foo() 执行前后,闭包是如何保持状态的。

步骤 1:foo() 执行中

b1e747f8f8ff4bad81eba5818125813a.png

🟦 foo() 执行时,innerBar 对象被创建,其方法引用了 myNametest1

步骤 2:foo() 执行完毕,返回闭包

d7548fe2fe8245858be8091f68259fba.png

🟨 即使 foo() 的执行上下文已出栈,但 innerBar 的方法仍持有对 myNametest1 的引用,V8 引擎不会回收这些变量,从而形成持久化的闭包环境


八、总结:一张图看懂 JS 作用域机制

[全局作用域]
   │
   ├─ var myName = '极客时间'
   │
   └─ function foo()
        │
        ├─ var myName = '极客邦'
        │
        └─ function bar() → 作用域链指向 [全局],而非 foo!
  • 词法作用域:静态的,由代码结构决定
  • 闭包:让函数“记住”它出生时的环境
  • 执行上下文:动态的,随函数调用入栈/出栈
  • 作用域链 ≠ 调用栈:前者是查找路径,后者是执行顺序

九、思考题

  1. 如果在 3.js 中,innerBar 是通过 setTimeout 异步返回的,闭包还有效吗?
  2. 使用 let 替换 var myName,闭包行为会变化吗?
  3. 如何避免闭包导致的内存泄漏?

欢迎在评论区讨论!


结语

理解 JavaScript 的作用域与闭包,不仅是面试高频考点,更是写出健壮、可维护代码的基础。当你下次看到“变量找不到”或“闭包陷阱”时,不妨想想:这个函数是在哪里声明的?它的词法环境是什么?

掌握这些底层机制,你就能像 V8 引擎一样,“看透”每一行 JS 代码的真正意图。


🌟 延伸阅读

  • 《你不知道的 JavaScript(上卷)》

喜欢这篇文章?点赞 + 关注,不错过更多深度 JS 原理解析!