一问作用域链就开始支支吾吾,一写闭包就靠直觉蒙。这东西说难不难,但如果只是死记硬背概念,遇到稍微变形的题目就露馅。
今天咱们从 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)时:
- 先在 bar 自身作用域里找
myName—— 没找到- 顺着作用域链往上,去全局作用域找 —— 找到
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() 执行完出栈了,里面的
myName、test1应该被回收。但为什么后面还能访问?
从 outer 角度理解闭包
getName 和 setName 都声明在 foo 内部,所以它们的 outer 指向 foo 的作用域。
当
foo 执行完毕准备出栈时 ( bar = foo() ),V8 发现:
- getName 要被返回到外部
- getName 的 outer 还指着 foo 作用域里的变量
- 这些变量(
myName、test1)不能删!
于是 V8 把这些被引用的变量打包成一个 Closure 对象,挂在
getName 和 setName 身上。
bar.getName 执行时:
getName 执行上下文
│
└─ outer ─→ Closure (foo)
│
├─ myName: "极客邦"
└─ test1: 1
注意:test2 没有被任何内部函数引用,所以不在 Closure 里,该回收还是会回收。V8 很聪明,只保留有用的。
闭包的形成条件
- 函数嵌套:在一个函数内部定义了另一个函数
- 内部函数通过 outer 引用了外部函数的变量
- 内部函数被传递到外部使用(return、回调、赋值给外部变量等)
📌 面试要点3: 闭包的本质是内部函数的 outer 引用导致外部作用域的变量无法被回收。Closure 对象只保存被引用的变量,不是整个作用域。
执行流程详解
var bar = foo()
- 创建 foo 执行上下文,入栈
- 声明变量,创建
innerBar对象 - getName、setName 的 outer 指向 foo 作用域
- 发现它们要被 return 出去,且 outer 引用了变量 → 创建 Closure
- foo 出栈,但 Closure 保留
bar.setName('极客邦')
- 创建 setName 执行上下文
- 执行
myName = newName - 自身作用域没有
myName,顺着 outer 去 Closure 里找 - 找到并修改
myName = '极客邦'
bar.getName()
- 创建 getName 执行上下文
- 顺着 outer 在 Closure 里找到
test1和myName - 输出并返回
五、总结
| 概念 | 一句话总结 |
|---|---|
| 词法作用域 | 作用域在写代码时就定了,跟运行时在哪调用无关 |
| 作用域链 | 变量查找沿着声明位置的嵌套关系向外找,不是沿着调用栈找 |
| 闭包 | 内部函数带着"背包"逃出去,背包里是它引用的外部变量 |
核心机制
outer 引用:每个执行上下文都有 outer 指针,在编译时根据函数声明的位置确定,指向外层作用域。变量查找就是沿着 outer 链向上。
作用域链:由 outer 串联起来的链条。它是静态的,由代码的词法结构决定,与运行时的调用栈无关。
闭包:当内部函数被传递到外部时,它的 outer 引用会导致外部作用域的变量无法回收,这些变量被保存在 Closure 对象中。
面试高频考点
- 作用域链查找:看函数在哪声明(outer 指向哪),不是看在哪调用
- outer vs 调用栈:调用栈管执行顺序,outer 管变量查找路径
- 闭包形成条件:函数嵌套 + outer 引用外部变量 + 内部函数被暴露
- 闭包内存问题:只保存被引用的变量,用完记得解除引用
一句话记住
调用栈管执行顺序,outer 链管变量来源。变量查找跟着 outer 走,outer 只认"出生地",不认"打工地"。