在 JavaScript 中,作用域链(Scope Chain) 是变量查找的路径,而 闭包(Closure) 则是作用域链在函数执行结束后依然“存活”的体现。理解二者之间的关系,是掌握 JavaScript 高级特性的关键。
本文将带你从 词法作用域 出发,深入剖析 作用域链如何构建,并自然过渡到 闭包的形成机制与实际意义,最终揭示:
🔑 闭包不是魔法,而是作用域链在特定条件下的延续。
🧭 一、起点:词法作用域(Lexical Scope)
在学习过程中,你可能接触过 词法环境(Lexical Environment) ,但它和 词法作用域 并非同一概念:
- 词法作用域 是一种 语言设计原则
- 词法环境 是引擎用来 实现该原则的运行时数据结构
✅ 什么是词法作用域?
变量的可访问范围由函数或代码块在源代码中的声明位置决定,而不是由运行时的调用位置决定。
这是一个 静态(编译期) 的概念。
JavaScript 采用 词法作用域(Lexical Scope) ,也称 静态作用域,这意味着:
💡 一个函数能访问哪些变量,在它被声明(定义)的时候就已经确定了,与它在哪里被调用无关。
📌 示例:调用栈 ≠ 作用域链
function bar() {
console.log(myname);
}
function foo() {
var myname = '极客帮';
bar(); // 运行时调用
}
var myname = '极客时间';
foo();
❓ 这段代码打印什么?
- 如果按 调用栈逻辑 推测:
bar被foo调用 → 应该看到foo中的myname→ 输出'极客帮'
调用栈示意图:
- 但这是错误的!
✅ 实际输出: '极客时间'
为什么?
因为 bar 函数是在 全局作用域中声明的,它的作用域链是:
bar 的局部作用域 → 全局作用域
它 无法访问 foo 的变量,哪怕是在 foo 内部调用的!因为它们两个函数都声明在全局执行上下文的变量环境,因此应该属于同级。
事实上,在函数声明时,JavaScript 引擎会为其创建一个
[[Outer]]引用,指向其声明位置所处的词法环境;变量查找时,正是通过这个[[Outer]]链沿着作用域链逐层向上追溯,从而确定变量的来源。
变量查找示意图:
🔗 二、作用域链:变量查找的静态路径
每当创建一个执行上下文(如函数调用),JavaScript 引擎会为其建立一条 作用域链(Scope Chain) ,用于变量查找。
🏗️ 作用域链的构成
作用域链是一个 链式结构,每个节点对应一个 词法环境(Lexical Environment) :
- 当前函数的词法环境
- 外层函数的词法环境(如果有)
- ……
- 全局词法环境
⚠️ 这条链在 函数声明时 就已固定,属于 编译阶段 的产物。
🔍 查找规则
- 从当前作用域(当前词法环境)开始查找变量
- 若未找到,沿
[[Outer]]引用向上查找 - 直到全局作用域
- 若仍未找到,抛出
ReferenceError
这就是作用链的查找:
🧪 示例 1:标准嵌套
function bar() {
var myName = '极客世界';
let text1 = 100;
if (1) {
let myName = "Chrome 浏览器";
console.log(text); // ← 查找 text
}
}
function foo() {
var myName = '极客帮';
let text = 2;
{
let text = 3;
bar(); // 调用全局声明的 bar
}
}
var myName = '极客时间';
let myAget = 10;
let text = 1;
foo(); // 输出:1
🔎 查找过程:
console.log(text)在bar的if块级词法环境中查找 → 无- 回退到
bar函数的词法环境 → 无 bar是在 全局作用域声明的,所以其[[Outer]]指向 全局词法环境- 在全局词法环境中找到
let text = 1→ 输出1
查找步骤示意图:
✅ 结论:作用域链由 代码结构(声明位置) 决定,与调用位置无关。
🧪 示例 2:块级作用域不会“穿透”
function bar() {
console.log(text); // ← 仍查找全局
}
function foo() {
let text = 2;
{
let text = 3;
bar();
}
}
var myName = '极客时间';
if (1) {
let text = 4; // ← 全局块中的 text
}
foo();
❓ 输出什么?
bar依然在 全局作用域声明- 全局作用域中 没有
let text声明(if块中的text属于该块,不可被外部访问) - 所以:
ReferenceError: text is not defined
📌 关键点:函数只能访问其声明位置可见的变量,不能“看到”调用处的块级变量。
🚪 三、转折点:当函数“逃逸”出其作用域
通常,函数执行完毕后,其执行上下文会被销毁,内部变量也会被垃圾回收。
但有一种特殊情况:内部函数被返回或传递到外部。
这时,神奇的事情发生了——外层变量 没有被回收!
🎒 四、闭包:作用域链的“持久化”
❓ 什么是闭包?
📘 闭包 = 函数 + 该函数声明时所在词法环境的引用
无论通过何种手段将内部函数传递到所在词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包 ——《你不知道的js》
换句话说,闭包是 作用域链在函数执行结束后依然保持活跃的状态。
✅ 闭包的三个必要条件
- 函数嵌套:内部函数存在于外部函数中
- 内部函数引用外层变量(称为“自由变量”)
- 内部函数在外部被访问(如
return、回调、事件监听等)(任何方式对函数类型的值进行传递都可以产生闭包)
💡 示例:经典闭包
function foo() {
var myName = "极客时间";
let text1 = 1;
const text2 = 2;
var innerBar = {
getName: function () {
console.log(text1); // 引用外层变量
return myName;
},
setName: function (newName) {
myName = newName;
}
};
return innerBar; // 返回对象,方法可被外部访问
}
var bar = foo(); // foo 执行完毕,上下文出栈
console.log(bar.getName()); // → 1, "极客时间"
bar.setName("极客帮");
console.log(bar.getName()); // → 1, "极客帮"
执行到foo时调用栈示意图:
🔍 发生了什么?
foo执行结束,按理应销毁myName、text1- 但
getName和setName引用了这些变量 - 引擎检测到:这些变量仍被需要 → 保留在内存中
- 这个“被保留的词法环境”就是 闭包
闭包示意图:
🎒 你可以把闭包想象成函数背上的“专属背包”,走到哪,变量就跟到哪。
❓ 如果把 innerBar 用 let 声明呢?
function foo() {
let innerBar = { /* ... */ };
return innerBar;
}
✅ 仍然形成闭包!
区别仅在于:
var innerBar:存放在 变量环境(Variable Environment)let innerBar:存放在 词法环境(Lexical Environment)
但 getName/setName 仍然是在 foo 内部 声明的函数,它们的作用域链依然是:
函数自身 → foo 的词法环境 → 全局
因此,闭包是否形成,与 var/let 无关,只取决于函数是否引用外层变量并在外部被使用。
🖼️ 五、作用域链 vs 闭包:一张图看懂关系
【声明阶段(编译期)】
foo()
└── 内部函数 f()
└── 作用域链:f → foo → global ← 静态确定 ✅
【执行阶段(运行时)】
1. foo() 被调用 → 创建 foo 的执行上下文
2. f() 被返回 → foo 上下文出栈
3. 但 f 仍持有对 foo 词法环境的引用 → 形成闭包 🎒
| 概念 | 角色 | 特性 |
|---|---|---|
| 作用域链 | 设计蓝图 | 静态、声明时确定 |
| 闭包 | 运行时实例 | 动态、延长变量生命周期 |
🔁 闭包 = 作用域链的运行时延续
🚫 六、常见误区澄清
❌ 误区1:“闭包就是返回函数”
不准确。只有当返回的函数 引用了外层变量,才形成有意义的闭包。
function noClosure() {
let x = 100;
return function () {
console.log('hello'); // 未引用 x
};
}
// 技术上存在闭包,但为空;x 会被正常回收。
❌ 误区2:“闭包会导致内存泄漏”
闭包本身是安全的。内存泄漏 通常是因为:
- 无意中长期持有大对象引用
- 未及时解除事件监听或定时器
合理使用闭包不会导致泄漏。
❌ 误区3:“作用域链由调用位置决定”
这是 动态作用域(如 Bash、早期 Lisp)的特点。
JavaScript 是 词法作用域,只看 声明位置!
🛠️ 七、实战:用闭包解决经典问题
🔄 问题:循环中的 setTimeout
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 输出:3, 3, 3
}
原因:所有回调共享同一个 i(var 声明在全局),循环结束时 i = 3。
✅ 解法1:利用闭包捕获当前值
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(() => console.log(j), 100);
})(i);
}
// 输出:0, 1, 2
- 每次循环创建一个新函数,形成独立闭包
j被“冻结”为当前i的值
✅ 解法2:使用 let(推荐)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 输出:0, 1, 2
}
let在每次循环创建 新的块级作用域- 每个
setTimeout回调形成 独立闭包,绑定各自的i
🌟 这正是
let解决循环变量问题的底层原理!
📊 八、总结:从作用域链到闭包的逻辑脉络
| 阶段 | 核心机制 | 关键特性 |
|---|---|---|
| 1. 声明函数 | 词法作用域建立 | 作用域链静态确定 ✅ |
| 2. 调用函数 | 执行上下文入栈 | 变量/词法环境初始化 |
| 3. 内部函数引用外层变量 | 自由变量产生 | 依赖外层词法环境 |
| 4. 内部函数被传出 | 外层上下文不应销毁 | 引擎保留词法环境 |
| 5. 闭包形成 | 作用域链持久化 | 变量生命周期延长 🎒 |
💎 闭包不是新概念,而是作用域链在函数“逃逸”场景下的自然延伸。
🌱 延伸思考
React Hooks(如 useState)的“状态记忆”能力,本质上也是闭包的巧妙应用:
function MyComponent() {
const [count, setCount] = useState(0);
// 每次渲染,setCount 都通过闭包“记住”对应的状态
}
每一次组件渲染,Hook 函数都通过闭包“记住”上一次的状态——这正是 词法作用域 + 闭包 赋予 JavaScript 的强大表达力。
🧠 掌握作用域链,你就掌握了 JavaScript 的灵魂;理解闭包,你就拥有了操控状态的艺术。