深度解构JavaScript:作用域链与闭包的内存全景图
引言:看见不可见的执行世界
JavaScript 常常被误解为一门简单的脚本语言,但在其看似随性的语法背后,隐藏着一套严谨而精密的执行机制。当你写下 function 和 let 时,JavaScript 引擎正在幕后构建复杂的执行上下文(Execution Context) ,编织严密的作用域链(Scope Chain) ,并可能在不经意间制造出强大的闭包(Closure)。
很多开发者在面对"变量为什么找不到"、"闭包为什么内存泄漏"或者"this 指向为何诡异"等问题时感到困惑,根本原因在于缺乏对这套底层机制的直观认知。
本文将摒弃枯燥的定义堆砌,结合核心的代码案例与可视化的内存模型图,带您像调试器一样"透视"JavaScript 的运行过程。我们将通过七张关键的原理图,层层剥开作用域与闭包的神秘面纱。
第一章:执行的基石------执行上下文模型
1.1 代码运行的"容器"
在 JavaScript 中,任何代码的执行都发生在执行上下文中。你可以把它想象成一个容器,里面装着代码运行所需的所有信息。这个容器并非铁板一块,而是被精细地划分为两个核心区域:
- 变量环境(Variable Environment) :主要存储由
var声明的变量和函数声明。 - 词法环境(Lexical Environment) :主要存储由
let、const声明的变量以及代码块级作用域信息。
此外,每个上下文还持有一个指向外部环境的引用(Outer),这是形成作用域链的关键。

图解 1 :如上图所示,一个标准的执行上下文(如
setName函数)内部清晰地分为了"变量环境"和"词法环境"。注意右侧红色的foo(closure),它暗示了内部函数可能形成的闭包,保留了对外部变量的引用。这是理解后续所有复杂逻辑的基石。
1.2 全局上下文的初始化
当脚本加载时,首先建立的是全局执行上下文 。此时,全局变量被登记在册,而 outer 指针指向 null,因为它处于作用域链的顶端。
第二章:作用的层级------词法作用域链
2.1 嵌套的世界
JavaScript 采用词法作用域,这意味着函数的作用域在代码**编写(定义)**时就已经确定,而非运行时。当函数嵌套时,就形成了作用域链。
让我们看一个经典的嵌套模型:
javascript
let count = 1; // 全局作用域
function main() {
let count = 2; // main 作用域
function bar() {
let count = 3; // bar 作用域
function foo() {
let count = 4; // foo 作用域
}
}
}
在这个结构中,foo 可以访问 bar、main 甚至全局的 count,但查找顺序是严格的"由内向外"。

图解 2 :这张图生动地展示了作用域的嵌套关系。下方的箭头链条(词法作用域链)清晰地表明:
foo的作用域指向bar,bar指向main,最终指向全局。无论函数在哪里被调用,这条链在定义时就已经固化。
第三章:实战深潜------调用栈与变量查找迷雾
理论总是清晰的,但现实代码往往充满了陷阱。让我们进入一个复杂的实战场景,看看引擎如何在调用栈中处理变量遮蔽(Shadowing)和作用域查找。
3.1 复杂的变量查找案例
请仔细阅读以下代码,尝试判断 console.log(test) 的输出结果:
javascript
function foo() {
var myName = "极客邦";
let test = 2;
{
let test = 3; // 块级作用域遮蔽
bar(); // 在这里调用 bar
}
}
function bar() {
var myName = "极客世界";
let test1 = 100;
if (1) {
let myName = "Chrome浏览器";
console.log(test); // 问题核心:test 是多少?
}
}
var myName = "极客时间";
let test = 1; // 全局 test
foo();
直觉误区 :很多人认为 bar 是在 foo 内部调用的,所以应该能访问 foo 里的 test(值是 2 或 3)。 真相 :输出结果是 1。
为什么?因为 bar 函数是在全局作用域 定义的。根据词法作用域规则,bar 的作用域链直接指向全局,它与 foo 的执行上下文毫无关系,哪怕它是被 foo 调用的。

图解 3:这张图是理解本案例的"钥匙"。
- 左侧展示了当前的调用栈 :顶层是
bar,中间是foo,底部是全局。- 请注意红色的虚线箭头(作用域链指向):
bar的outer指针直接跳过了foo,指向了全局执行上下文(标记⑤)。- 因此,当
bar查找test时,它在自身环境和全局环境中找到了test=1(标记④),而完全无视了foo环境中的test=2或test=3。
3.2 常见的认知陷阱
为了进一步巩固这个概念,我们看一个更简化的例子,这也是面试题中的常客:
javascript
var myName = "极客时间";
function foo() {
var myName = "极客邦";
bar();
}
function bar() {
console.log(myName); // 这里打印什么?
}
foo();

图解 4 :图中的气泡提出了灵魂拷问:"myName 的值应该使用全局执行上下文的,还是使用 foo 函数执行上下文的?" 答案显而易见:全局 。因为
bar定义在全局,它的作用域链只连接全局。调用栈的压入(foo调用bar)不会改变bar的作用域链指向。
第四章:闭包的魔力------留住时间的变量
4.1 什么是闭包?
当函数返回后,通常其执行上下文会被销毁,局部变量随之消失。但是,如果返回的函数引用了外部函数的变量,JavaScript 引擎就会"网开一面",将这些变量保留在内存中。这就是闭包。
4.2 闭包的内存驻留
看这段代码:
javascript
function setName() {
var myName = "极客时间";
let test1 = 1;
function foo() {
console.log(myName);
}
return foo; // 返回内部函数
}
var closureFunc = setName(); // setName 执行完毕
closureFunc(); // 依然能访问 myName
当 setName 执行结束后,按理说它的上下文应该出栈。但因为 foo 被返回并赋值给了 closureFunc,且 foo 依赖 myName,引擎必须保留 setName 的变量环境。

图解 5 :注意看图中,调用栈(Call Stack)中已经没有了
setName的身影。但是,一个标记为foo(closure)的对象独立存在于内存中,它紧紧抱着myName = "极客时间"和test1 = 1。这就是闭包的本质:函数与其词法环境的组合。
4.3 综合场景:对象方法与闭包
闭包常用于创建私有变量或对象方法。考虑以下场景:
javascript
function foo() {
var myName = "极客时间";
let test1 = 1;
let test2 = 2;
// 返回一个包含方法的对象
return {
innerBar: function() {
console.log(myName);
}
};
}
var obj = foo();
obj.innerBar(); // 输出 "极客时间"

图解 6 :这张图展示了
foo函数执行上下文的细节,变量环境中不仅有基本类型,还有函数对象innerBar。当foo返回后,这些变量并没有立即消失,而是成为了闭包的一部分。
第五章:终极视角------指针的指向艺术
最后,我们需要从宏观视角审视整个内存模型。无论是普通函数调用,还是闭包,核心都在于那个看不见的 outer 指针。
- 如果函数在全局定义,
outer指向全局上下文。 - 如果函数在另一个函数内定义,
outer指向外部函数的上下文。 - 无论函数在哪里被调用,
outer指针在函数创建那一刻就已定格。

图解 7 :这张图用红色虚线明确标注了"指向全局执行上下文"。我们可以看到,
bar和foo虽然可能在不同的调用栈层级,但它们各自的outer指针都诚实地指向了它们定义时所在的环境。这解释了为什么作用域链不会被动态的调用栈所迷惑。
结语:从"知其然"到"知其所以然"
通过这七张图谱的深度解析,我们重新梳理了 JavaScript 的核心机制:
- 执行上下文 是舞台,区分了
var和let/const的存放位置。 - 作用域链 是导航图,它在代码定义时生成,决定了变量查找的路径,与调用位置无关。
- 闭包是时光机,它让函数能够跨越生命周期,继续访问定义时的环境变量。
理解这些,你就不再是在盲目地试错代码,而是在脑海中构建出了一幅清晰的内存地图。当下一次遇到作用域问题或闭包陷阱时,请在脑中画出那张"调用栈"与"红色虚线箭头"的图,答案自会浮现。