搞懂这些,面试官再也难不倒你:JS作用域链与闭包的核心秘密

139 阅读7分钟

一问作用域链就开始支支吾吾,一写闭包就靠直觉蒙。这东西说难不难,但如果只是死记硬背概念,遇到稍微变形的题目就露馅。

今天咱们从 V8 引擎的视角,把作用域链和闭包彻底讲透。

一、从一道"诡异"的题目说起

function bar(){
    console.log(myName);
}
function foo(){
    var myName = '极客邦';
    bar();
}
var myName = '极客时间';
foo();

问:输出什么?

大部分人的第一反应:极客邦

理由是:bar() 在 foo() 里被调用,而 foo 里有 myName = '极客邦'

但答案是 极客时间

为什么?

这道题的关键在于:作用域链是在函数声明时确定的,跟函数在哪里被调用没有任何关系。

我们来分析这段代码:

// 全局作用域
function bar(){        // bar 声明在全局作用域
    console.log(myName);
}
function foo(){        // foo 声明在全局作用域  
    var myName = '极客邦';
    bar();             // 调用 bar,但这不影响 bar 的作用域链
}
var myName = '极客时间';
foo();

bar 函数是在哪声明的?全局作用域

所以 bar 的作用域链是:bar 自身作用域 → 全局作用域

当 bar 执行 console.log(myName) 时:

  1. 先在 bar 自身作用域里找 myName —— 没找到
  2. 顺着作用域链往上,去全局作用域找 —— 找到 myName = '极客时间'

它压根不会去 foo 的作用域里找,因为 bar 不是在 foo 里声明的。

二、作用域链的底层实现:outer 引用

那 JS 引擎是怎么知道"往哪找"的呢?靠的是每个执行上下文中的 outer 引用

outer 是什么

在编译阶段,每个函数都会确定一个 [[Scope]] 属性,指向它声明时所在的作用域。当函数执行时,创建的执行上下文会有一个 outer 指针,指向外部作用域的执行上下文。

简单说:outer 就是作用域链的"下一跳"

用 outer 视角重新分析

function bar(){
    console.log(myName);
}
function foo(){
    var myName = '极客邦';
    bar();
}
var myName = '极客时间';
foo();

编译阶段:

  • bar 声明在全局 → bar 的 outer 指向全局执行上下文
  • foo 声明在全局 → foo 的 outer 指向全局执行上下文

执行阶段:

调用栈:                        作用域链(通过 outer 连接):

┌──────────────────┐
│  bar 执行上下文   │ ─── outer ───→ 全局执行上下文
├──────────────────┤                    ↑
│  foo 执行上下文   │ ─── outer ─────────┘
├──────────────────┤
│  全局执行上下文   │ ─── outer ───→ null
└──────────────────┘

看到没?虽然调用栈是 全局 → foo → bar,但 

bar 的 outer 直接指向全局,跳过了 foo。

这就是"调用栈"和"作用域链"的本质区别:

  • 调用栈:按调用顺序排列,后进先出
  • 作用域链:按声明位置连接,通过 outer 形成链条

📌 面试要点1:  每个执行上下文都有一个 outer 引用,指向该函数声明时所在的外部作用域。变量查找就是沿着 outer 链一路向上,直到找到或到达全局作用域。

换个写法,outer 就不一样了

如果把 

bar 声明在 foo 内部:

function foo(){
    var myName = '极客邦';
    
    function bar(){  // 现在 bar 声明在 foo 内部
        console.log(myName);
    }
    
    bar();
}
var myName = '极客时间';
foo();  // 输出:极客邦

现在 

bar 的 outer 指向 foo 的执行上下文,作用域链变成:

bar 执行上下文 ─outer → foo 执行上下文 ─outer → 全局执行上下文

找 myName 时,顺着 outer 在 

foo 作用域就找到了,输出 极客邦

三、复杂场景:块级作用域 + 作用域链

再来一个复杂点的例子:

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

查找路径分析

查找 test:

1. if 块级作用域(词法环境栈顶)
   └─ 只有 let myName,没有 test ❌

2. bar 函数作用域
   └─ 有 var myName 和 let test1,没有 test ❌

3. 顺着 outer 到全局作用域
   └─ 有 let test = 1 ✅ 找到了!

关键点:

  • bar 的 outer 指向全局,不是指向 foo
  • 虽然 foo 里有 test = 2 和 test = 3,但 bar 的作用域链根本不经过 foo
  • 块级作用域内的查找会先在当前块找,找不到才往外层函数作用域找,最后通过 outer 去外部

📌 面试要点2:  let/const 创建块级作用域,但块级作用域的外层还是当前函数作用域,跨函数的查找仍然走 outer 链。

四、闭包:outer 引用带来的"副作用"

理解了 outer,闭包就是水到渠成的事。

看一段经典代码

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()  // foo 执行完毕,出栈
bar.setName('极客邦')
bar.getName()
console.log(bar.getName());  // 输出:极客邦

🎯灵魂拷问

foo() 执行完出栈了,里面的 myNametest1 应该被回收。但为什么后面还能访问?

从 outer 角度理解闭包

getName 和 setName 都声明在 foo 内部,所以它们的 outer 指向 foo 的作用域

当 

foo 执行完毕准备出栈时 ( bar = foo() ),V8 发现:

  • getName 要被返回到外部
  • getName 的 outer 还指着 foo 作用域里的变量
  • 这些变量(myNametest1)不能删!

于是 V8 把这些被引用的变量打包成一个 Closure 对象,挂在 

getName 和 setName 身上。

bar.getName 执行时:

getName 执行上下文
    │
    └─ outer ─→ Closure (foo)  
                  │
                  ├─ myName: "极客邦"
                  └─ test1: 1

注意:test2 没有被任何内部函数引用,所以不在 Closure 里,该回收还是会回收。V8 很聪明,只保留有用的。

闭包的形成条件

  1. 函数嵌套:在一个函数内部定义了另一个函数
  2. 内部函数通过 outer 引用了外部函数的变量
  3. 内部函数被传递到外部使用(return、回调、赋值给外部变量等)

📌 面试要点3:  闭包的本质是内部函数的 outer 引用导致外部作用域的变量无法被回收。Closure 对象只保存被引用的变量,不是整个作用域。

执行流程详解

var bar = foo()
  1. 创建 foo 执行上下文,入栈
  2. 声明变量,创建 innerBar 对象
  3. getName、setName 的 outer 指向 foo 作用域
  4. 发现它们要被 return 出去,且 outer 引用了变量 → 创建 Closure
  5. foo 出栈,但 Closure 保留
bar.setName('极客邦')
  1. 创建 setName 执行上下文
  2. 执行 myName = newName
  3. 自身作用域没有 myName,顺着 outer 去 Closure 里找
  4. 找到并修改 myName = '极客邦'
bar.getName()
  1. 创建 getName 执行上下文
  2. 顺着 outer 在 Closure 里找到 test1 和 myName
  3. 输出并返回

五、总结

概念一句话总结
词法作用域作用域在写代码时就定了,跟运行时在哪调用无关
作用域链变量查找沿着声明位置的嵌套关系向外找,不是沿着调用栈找
闭包内部函数带着"背包"逃出去,背包里是它引用的外部变量

核心机制

outer 引用:每个执行上下文都有 outer 指针,在编译时根据函数声明的位置确定,指向外层作用域。变量查找就是沿着 outer 链向上。

作用域链:由 outer 串联起来的链条。它是静态的,由代码的词法结构决定,与运行时的调用栈无关。

闭包:当内部函数被传递到外部时,它的 outer 引用会导致外部作用域的变量无法回收,这些变量被保存在 Closure 对象中。

面试高频考点

  1. 作用域链查找:看函数在哪声明(outer 指向哪),不是看在哪调用
  2. outer vs 调用栈:调用栈管执行顺序,outer 管变量查找路径
  3. 闭包形成条件:函数嵌套 + outer 引用外部变量 + 内部函数被暴露
  4. 闭包内存问题:只保存被引用的变量,用完记得解除引用

一句话记住

调用栈管执行顺序,outer 链管变量来源。变量查找跟着 outer 走,outer 只认"出生地",不认"打工地"。