在前端开发中,JavaScript 的作用域、闭包、执行上下文等概念常常让人“似懂非懂”。为什么函数 bar() 在 foo() 中调用时打印的是全局变量而不是局部变量?为什么一个函数执行完后,其内部变量还能被外部访问?这些看似玄学的问题,其实都源于 JavaScript 的词法作用域(Lexical Scope)和闭包(Closure)机制。
本文将结合 V8 引擎底层原理、代码示例与深入剖析,带你彻底搞懂 JS 的作用域链、闭包形成条件、执行上下文栈等核心机制,并通过7张高清图解直观展示运行时的内存结构。
一、JS 是如何运行的?—— V8 引擎视角
JavaScript 是一门解释型语言,但现代 JS 引擎(如 V8)采用了**即时编译(JIT)**策略,在执行前会经历两个阶段:
-
编译阶段(Parsing & Compilation)
- 解析代码生成 AST(抽象语法树)
- 确定变量声明、函数声明的位置
- 建立词法作用域链(Lexical Scope Chain)
-
执行阶段(Execution)
- 创建执行上下文(Execution Context)
- 维护调用栈(Call Stack)
- 变量赋值、函数调用、作用域查找
📌 关键点:作用域链是在编译阶段就确定的,与函数在哪里被调用无关!
二、词法作用域 vs 动态作用域
很多初学者会误以为 JS 是“动态作用域”——即函数在哪儿调用,就用哪儿的变量。但事实恰恰相反。
示例 1:1.js
function bar() {
console.log(myName);
}
function foo() {
var myName = '极客邦';
bar(); // 运行时调用
}
var myName = '极客时间';
foo(); // 输出:'极客时间'
问题:bar() 是在 foo() 内部调用的,为什么输出的是全局的 '极客时间',而不是 foo 中的 '极客邦'?
✅ 答案:因为 JS 采用词法作用域。
bar函数在全局作用域中声明,它的作用域链在编译时就固定为[bar 的局部作用域 → 全局作用域]。- 即使它在
foo内部被调用,也不会“继承”foo的作用域。
💡 记住:作用域由函数声明的位置决定,不是调用位置!
✅ 如上图所示,
bar()的执行上下文虽然在foo()调用栈中,但其作用域链只指向全局,因此myName查找的是全局变量。
三、块级作用域与变量提升(Hoisting)
ES6 引入了 let/const,带来了真正的块级作用域。
示例 2:2.js
function bar() {
var myName = "极客世界";
let test1 = 100;
if (1) {
let myName = "Chrome 浏览器";
console.log(test); // ❌ ReferenceError: test is not defined
}
}
function foo() {
var myName = "极客邦";
let test = 2;
{
let test = 3;
bar(); // 调用 bar
}
}
var myName = "极客时间";
let myAge = 10;
let test = 1;
foo();
分析:
bar中的console.log(test)报错,因为test在bar的作用域中未定义。- 尽管
foo和全局都有test,但bar的词法作用域只包含自身和全局,不包含foo。 { let test = 3 }创建了一个新的块级作用域,但对bar完全不可见。
⚠️ 注意:
var有变量提升,但let/const有“暂时性死区”(TDZ),在声明前访问会报错。
🔍 图中展示了
bar()执行时的变量环境和词法环境,test在bar的词法环境中不存在,导致引用错误。
四、闭包:函数 + 自由变量 = 专属背包
闭包是 JS 最强大也最容易被误解的概念之一。
示例 3:3.js
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; // 返回对象,其中方法引用了 foo 内部变量
}
var bar = foo(); // foo 执行完毕,执行上下文出栈
bar.setName("极客邦");
bar.getName(); // 输出:1, "极客邦"
闭包形成的三个条件:
- 函数嵌套(
getName/setName嵌套在foo内) - 内部函数引用了外部函数的变量(
myName,test1) - 内部函数在外部被访问(通过
return暴露)
闭包的本质
当 foo() 执行完毕,其执行上下文本应被销毁。但由于返回的对象中的方法仍引用着 myName 和 test1,V8 引擎会保留这些变量,形成一个“闭包环境”。
你可以把闭包想象成一个专属背包:
- 背包里装着函数创建时所在作用域的变量(自由变量)
- 即使原函数已出栈,背包依然存在
- 每次调用闭包函数,都能从背包中取值或修改
✅ 闭包 = 函数 + 词法环境(Lexical Environment)
🎯 上图清晰展示了
foo()执行完毕后,其变量环境并未被释放,而是被innerBar对象中的方法所引用,形成了闭包。
五、作用域链查找规则(重点!)
作用域链是一条静态的查找路径,遵循以下规则:
- 从当前函数的局部作用域开始查找
- 若未找到,沿词法作用域链向上查找(外层函数 → ... → 全局)
- 找到即返回;若到全局仍未找到,则报
ReferenceError
🔍 查找路径在函数声明时就已确定,与调用栈顺序无关!
🧩 如图所示,
foo()中的count优先查找自身作用域,再向上查找main(),最后到全局。这种层级关系由代码结构决定,而非调用顺序。
六、执行上下文与调用栈详解
每个函数执行时都会创建一个执行上下文(Execution Context) ,包含:
- 变量环境(Variable Environment) :存储变量和函数的值
- 词法环境(Lexical Environment) :存储变量的绑定关系
- outer:指向外层执行上下文(用于作用域链)
示例 4:4.js
function bar() {
var myName = "浏览器";
let test1 = 100;
if (1) {
let myName = "Chrome浏览器";
console.log(test1);
}
}
function foo() {
var myName = "极客邦";
let test = 2;
{
let test = 3;
bar();
}
}
var myName = "极客时间";
let myAge = 10;
let test = 1;
foo();
🔍 图中标注了五个关键点:
bar的词法环境中有myName和test1bar的变量环境中有myName="浏览器"和outer指向全局bar的变量环境中的myName被重新赋值为"浏览器"- 全局词法环境包含所有全局变量
- 全局变量环境中的
myName="极客时间"
七、闭包生命周期:从创建到持久化
我们来观察 foo() 执行前后,闭包是如何保持状态的。
步骤 1:foo() 执行中
🟦
foo()执行时,innerBar对象被创建,其方法引用了myName和test1。
步骤 2:foo() 执行完毕,返回闭包
🟨 即使
foo()的执行上下文已出栈,但innerBar的方法仍持有对myName和test1的引用,V8 引擎不会回收这些变量,从而形成持久化的闭包环境。
八、总结:一张图看懂 JS 作用域机制
[全局作用域]
│
├─ var myName = '极客时间'
│
└─ function foo()
│
├─ var myName = '极客邦'
│
└─ function bar() → 作用域链指向 [全局],而非 foo!
- 词法作用域:静态的,由代码结构决定
- 闭包:让函数“记住”它出生时的环境
- 执行上下文:动态的,随函数调用入栈/出栈
- 作用域链 ≠ 调用栈:前者是查找路径,后者是执行顺序
九、思考题
- 如果在
3.js中,innerBar是通过setTimeout异步返回的,闭包还有效吗? - 使用
let替换var myName,闭包行为会变化吗? - 如何避免闭包导致的内存泄漏?
欢迎在评论区讨论!
结语
理解 JavaScript 的作用域与闭包,不仅是面试高频考点,更是写出健壮、可维护代码的基础。当你下次看到“变量找不到”或“闭包陷阱”时,不妨想想:这个函数是在哪里声明的?它的词法环境是什么?
掌握这些底层机制,你就能像 V8 引擎一样,“看透”每一行 JS 代码的真正意图。
🌟 延伸阅读:
- 《你不知道的 JavaScript(上卷)》
喜欢这篇文章?点赞 + 关注,不错过更多深度 JS 原理解析!