❗爆肝解析!为什么你的 JS 变量总是 "找错人"?从作用域链看变量查找迷局

82 阅读6分钟

导语

"明明变量就在眼前,为什么代码就是找不到?"

这是 JavaScript 开发者最常遇到的 "玄学问题"。本文通过一个经典案例,带你深入 JS 引擎底层,揭开变量查找的神秘面纱,彻底终结 "变量失踪" 难题。

一、案例重现:变量查找的 "跨时空迷案"

先看这段代码:

function bar() {
    var myName = "极客世界"
    let test1 = 100
    if (1) {
        let myName = "Chrome浏览器"
        console.log(test) 
    }
}
function foo() {
    var myName = "极客邦"
    let test = 2
    { 
        let test = 3 
        bar()
    }
}
var myName = "极客时间"
let myAge = 10
let test = 1
foo() //1

现象解析bar函数内的console.log(test)输出了全局作用域的test=1,而非foo函数中的test=2或块级作用域的test=3

核心疑问:变量查找为什么会 "跳过" 中间作用域?

二、JavaScript 执行机制详解

调用栈(Call Stack)

  • 功能:记录函数的执行顺序,管理执行上下文和变量环境

  • 特点

    • 后进先出(LIFO)的数据结构
    • 全局上下文始终在栈底
    • 每次函数调用会创建新的执行上下文并压入栈顶
    • 函数执行完毕会从栈顶弹出

作用域(Scope)

  • 类型

    • 全局作用域
    • 函数作用域
    • 块级作用域(ES6+)

作用域链(Scope Chain)

  • 本质:变量查找的路径

  • 变量查找规则

    • 当前作用域查找 → 依次向上层作用域查找 → 直到全局作用域 → 未找到则报错 ReferenceError
  • 实现方式:通过词法环境的 outer 属性连接形成链式结构

词法作用域(Lexical Scope)

  • 定义:函数定义时就确定的作用域(静态作用域)

  • 实现

    • 每个执行上下文的词法环境中都包含一个外部引用 outer
    • outer 指向定义该函数时的外部执行上下文
  • 特点

    • 与函数调用位置无关
    • 在代码编写阶段就已确定

执行上下文(Execution Context)

  • 创建时机:函数调用时创建(注意:不是定义时)

  • 组成

    • 变量环境(Variable Environment)
    • 词法环境(Lexical Environment)
    • this 绑定
  • 生命周期

    • 创建 → 执行 → 销毁(垃圾回收)

变量环境(Variable Environment)

  • 存储内容

    • var 声明的变量(会变量提升)
    • 函数声明(整体提升)
  • 特点

    • 没有块级作用域
    • ES6 之前唯一存储变量的地方

词法环境(Lexical Environment,ES6 新增)

  • 存储内容

    • let/const 声明的变量
    • 块级作用域(如 if/for 等代码块)
  • 重要特性

    • 存在暂时性死区(TDZ)
    • 包含 outer 属性(指向外部词法环境)
  • 与变量环境的区别

    • 变量环境处理 var 和函数声明
    • 词法环境处理 let/const 和块级作用域

outer 属性的演变

  • ES6 前:存在于变量环境中(当时没有词法环境概念)
  • ES6 后:存在于词法环境中
  • 全局作用域outer = null(表示作用域链终点)

关键点总结

  1. 调用栈管理执行顺序,作用域链管理变量访问

  2. ES6 引入词法环境后,变量存储分为两部分:

    • var → 变量环境
    • let/const → 词法环境
  3. outer 引用形成了作用域链,是实现闭包的基础

  4. 词法作用域(静态作用域)是 JavaScript 的核心特性之一

三、结合案例分析

function bar() {
    var myName = "极客世界"
    let test1 = 100
    if (1) {
        let myName = "Chrome浏览器"
        console.log(test) 
    }
}
function foo() {
    var myName = "极客邦"
    let test = 2
    { 
        let test = 3 
        bar()
    }
}
var myName = "极客时间"
let myAge = 10
let test = 1
foo() //1

我们结合上面的机制来详细解析这段代码:

一、编译阶段

image.png

二、执行阶段

image.png

三、 执行完毕后的出栈顺序

  1. bar 执行完毕,bar 执行上下文出栈
  2. foo 执行完毕,foo 执行上下文出栈
  3. 全局执行上下文最后出栈

出栈后进行垃圾回收

四、闭包

通过这个我们再进一步解释一下闭包:

闭包的作用域链分析

在这个例子中,innerBar 对象包含了两个闭包函数:getName 和 setName。它们的作用域链关系如下:

function foo() {
    var myName = "极客时间"    // 存储在foo的变量环境
    let test1 = 1             // 存储在foo的词法环境
    const test2 = 2           // 存储在foo的词法环境
    
    var innerBar = {
        getName: function() {  // 闭包1
            console.log(test1) // 通过outer找到foo的词法环境中的test1
            return myName      // 通过outer找到foo的变量环境中的myName
        },
        setName: function(newName) { // 闭包2
            myName = newName   // 修改foo的变量环境中的myName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName("极客邦") 
bar.getName() // 1
console.log(bar.getName()) // 1 极客邦

outer 指向的具体机制

  1. 闭包函数的 outer 指向

    • getName 和 setName 函数定义在 foo 函数内部
    • 它们的 outer 指针都指向 foo 函数的执行上下文环境(包含变量环境和词法环境)
  2. 变量查找路径

    (1)当调用 bar.setName("极客邦")  时:

    • 先在 setName 函数自身的词法环境查找myName(无)

    • 通过 outer 找到 foo 的环境:

      • myName 在 foo 的变量环境中找到(myName="极客时间"),并将newName("极客邦") 赋值给myName

    (2)当调用 bar.getName() 时:

    • 先在 getName 函数自身的词法环境查找(无)

    • 通过 outer 找到 foo 的环境:

      • test1 在 foo 的词法环境中找到 (test1=1)
      • myName 在 foo 的变量环境中找到(myName="极客邦"

为什么能保持对 foo 变量的访问?

  1. 闭包的本质

    • 函数可以记住并访问所在的词法作用域
    • 即使函数在其词法作用域之外执行
  2. 内存保持机制

    • foo 执行完毕后,正常情况下其执行上下文应该销毁(垃圾回收机制)
    • 但由于 innerBar 的两个方法持有对 foo 环境的引用
    • JavaScript 引擎会保持这个环境不被垃圾回收

image.png

根据词法作用域的规则,内部函数总是可以访问其外部作用域声明的变量(自由变量),当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,这些变量的集合就是闭包。

foo 外部函数,这些变量的集合为foo函数的闭包。

这些变量的集合(要在闭包中访问的外部函数的变量,未访问的就不算)也是内部函数运行的专属背包。

  1. 变量修改的可见性

    • setName 修改的 myName 和 getName 访问的 myName 是同一个变量
    • 因为它们都通过 outer 指向同一个 foo 的变量环境

终极解决方案:可视化调试法

推荐使用 Chrome DevTools 的Scope面板可视化作用域链:

  1. console.log(test)处打断点
  2. 查看Local作用域(当前块级作用域)
  3. 查看Closure作用域(外层函数作用域)
  4. 查看Global作用域(全局作用域)

9e19da640231eb4a767e01435fe1b4d.png

df72c317f6e97167b4324de5a74fe12.png

结语:理解背后的设计哲学

JavaScript的作用域机制体现了:

  • 词法作用域的确定性
  • 函数是一等公民的灵活性
  • 向后兼容的历史包袱(var的存在)

理解这些原理,你就能:
✅ 准确预测代码行为
✅ 避免常见陷阱
✅ 写出更可靠的代码
✅ 深度掌握闭包等高级特性

下次当你遇到"这个变量从哪里来的?"的疑问时,记得沿着outer链一步步查找,谜底自然会揭开!