深入 V8 底层:从词法作用域到闭包的硬核实战
作为一名 JavaScript 开发者,你是否遇到过以下困惑?
- 为什么函数在别处调用,却打印了全局变量,而不是调用者的局部变量?
- 为什么
var声明的变量会"提升"到顶部,而let/const却不行? - 函数执行完毕出栈后,为什么它内部的变量还能被外部访问(闭包)?
这些问题的根源,都在于我们只看到了代码的执行顺序 (调用栈),而忽略了代码的静态结构(词法作用域)。
本文将带你透过 JavaScript 代码的表象,深入 V8 引擎的底层,结合具体的代码案例,彻底搞懂 词法作用域 (Lexical Scope) 、作用域链 (Scope Chain) 以及 闭包 (Closure) 的工作原理。
🏗 第一章:词法作用域 ------ 变量的"出生地"决定命运
JavaScript提到一个核心铁律:词法作用域是静态的,只和函数声明的位置相关,在编译阶段就决定好了,和调用没有关系。
这听起来很反直觉。让我们用一个例子来说明:
ini
function bar() {
console.log(myName);
}
function foo() {
var myName = '极客邦';
bar();
}
var myName = '极客时间';
foo();
❓ 灵魂拷问: 这段代码会打印什么?
- 猜测 A:
'极客邦'。理由:bar是在foo里面执行的,foo里有myName。 - 猜测 B:
'极客时间'。理由:全局有myName。
如果你选了 A,说明你陷入了"动态作用域"的思维陷阱。JavaScript 采用的是词法作用域(静态作用域) 。
V8 底层视角:编译阶段的"指针"
在 V8 引擎中,作用域的确定发生在编译阶段,而不是执行阶段。
-
编译期(静态分析):
- V8 扫描代码,发现
bar函数是在**全局(Global)**环境下声明的。 - 此时,引擎为
bar创建了一个内部指针(``),指向它的"出生地"------全局作用域。 - 结论:
bar的查找路径被锁定为:bar自身 ->Global。
- V8 扫描代码,发现
-
执行期(调用栈):
- 执行
foo(),foo入栈。 foo内部执行bar(),bar入栈。- 关键点: 虽然
bar是在foo的栈帧里被调用的,但这改变不了bar在编译期就确定的"血统"(作用域链)。
- 执行
最终结果:
bar 在查找 myName 时,跳过了 foo,直接找到了全局的 '极客时间'。
图解:

核心总结:
函数在哪里声明,决定了它去哪找变量;函数在哪里调用,只影响调用栈的顺序,不影响作用域链。
第二章:作用域链与块级作用域 ------ V8 的"查找地图"
随着 ES6 的到来,JavaScript 的作用域机制变得更加复杂,"作用域链是变量的查找路径,按函数声明的时候(编译)已经决定"。
接下来我将用一个例子来展示了函数作用域 与块级作用域在 V8 引擎中的共存与查找逻辑。
ini
function bar () {
var myName = "极客世界";
let test1 = 100;
if (1) {
let myName = "Chrome 浏览器";
console.log(test) // 注意:这里引用了 test
}
}
function foo() {
var myName = "极客邦";
let test = 2;
{
let test = 3;
bar()
}
}
var myName = "极客时间";
let myAge = 10;
let test = 1;
foo();
❓ 灵魂拷问: 为什么 bar 函数内部的 console.log(test) 会报错,而不是打印 3 或 2?
🧠 V8 底层视角:词法环境栈(Lexical Environment)
在 V8 中,执行上下文(ExecutionContext)包含两个核心组件:
- Variable Environment(变量环境): 处理
var。 - Lexical Environment(词法环境): 处理
let/const,它是一个栈结构。
执行过程拆解:
-
全局初始化:
- 全局对象挂载
myName(var) 和test(let, 值为 1)。
- 全局对象挂载
-
foo执行:foo入栈。foo的 LexicalEnvironment 记录myName和test(值为 2)。- 进入块级作用域
{ let test = 3 }。V8 压入一个新的词法环境,test被遮蔽(Shadowing)为 3。 - 调用
bar()。
-
bar执行(风暴中心):-
bar是全局声明的,它的作用域链是:bar->Global。 -
bar内部有一个if块级作用域。 -
当
bar寻找test时:- Step 1: 在
bar函数作用域内找?没有。 - Step 2: 进入
bar内部的if块级作用域找?没有(只有myName)。 - Step 3: 向上找全局?找到了
test吗?No! - 真相:
bar的作用域链与foo完全隔离。bar根本"看不见"foo里的任何变量,包括那个test。
- Step 1: 在
-
图解:
✅ 最终结果:
ReferenceError: test is not defined。
bar 试图访问一个在它作用域链上根本不存在的变量。
💡 核心总结:
let/const 创建的块级作用域是"围墙",var 创建的函数作用域是"盒子"。作用域链是一条单向向上的路,跨盒子(函数)的围墙是无法逾越的。
🎒 第三章:闭包 ------ 函数的"专属背包"
这是 JavaScript 最难懂的概念,也是重头戏。文档中有一段非常生动的描述:
"这个背包闭包,这个闭包里面的变量叫自由变量... foo 函数执行完后,其执行上下文从栈顶弹出了,但是由于返回的 setName, getName 使用了 foo 函数内部的变量... 这两个变量依然在内存中。"
让我们用 一个 例子 来打开这个"背包"。
javascript
function foo() {
var myName = "极客时间"
let test1 = 1
var innerBar = {
getName: function() {
console.log(test1)
return myName
},
setName: function(newName) {
myName = newName
}
}
return innerBar
}
var bar = foo() // foo 执行完,按理说该销毁了
bar.setName("极客邦")
console.log(bar.getName());
❓ 灵魂拷问: foo 函数执行完后,栈帧已经弹出(Pop),为什么 bar.getName() 还能访问到 myName 和 test1?V8 的垃圾回收器(GC)为什么不把它们收走?
🧠 V8 底层视角:内存的"逃逸分析"与"引用计数"
要理解闭包,必须理解 V8 的内存管理机制。
-
正常生命周期(无闭包):
- 函数执行 -> 分配栈帧和堆内存 -> 函数结束 -> 栈帧弹出 -> 堆内存无引用 -> GC 回收。
-
闭包发生时(逃逸):
- Step 1: 定义时的标记。 当 V8 编译
getName和setName时,发现它们引用了外部变量myName和test1。V8 标记这些变量为自由变量(Free Variables) 。 - Step 2: 返回时的"绑架"。
foo返回了innerBar对象。这个对象包含了getName和setName函数。 - Step 3: 引用链的建立。 全局变量
bar持有了innerBar。而innerBar里的函数又持有了foo的自由变量。 - Step 4: 垃圾回收的"特赦"。 当
foo执行完毕,V8 的 GC 准备回收内存。GC 发现:myName和test1依然被bar间接引用着! - Step 5: 晋升为堆对象。 为了防止悬垂指针,V8 将这些自由变量从栈内存"晋升"(或直接分配在堆中),并将其绑定在一个新的数据结构上------这就是闭包(Closure) 。
- Step 1: 定义时的标记。 当 V8 编译
✅ 最终结果:
bar 变成了一个拥有"专属背包"的对象。只要 bar 还存在于内存中,这个背包(Closure)里的 myName 和 test1 就永远不会被销毁。
💡 核心总结:
闭包的本质是函数对象携带了它定义时环境的引用 。
注意: 闭包虽然强大,但因为它阻止了内存回收,滥用会导致内存泄漏 。记得在不需要时将 bar = null。
📊 第四章:全景图 ------ JS 语言工作的底层机制
我们将上述三个章节串联起来,绘制出 JavaScript 运行的全景图。
1. V8 引擎工作流
-
编译阶段(Parsing & Compiling):
- 生成 AST(抽象语法树)。
- 确定词法作用域:这是最关键的一步。引擎决定了每个函数去哪找变量(Scope Chain)。
-
执行阶段(Execution):
- 创建执行上下文(ExecutionContext) ,压入调用栈(Call Stack) 。
- 变量环境(Var)与词法环境(Let/Const)初始化。
2. 作用域链查找规则(Scope Chain)
- 查找路径: 当前作用域 -> 外层作用域 -> ... -> 全局作用域。
- 查找时机: 在编译阶段静态确定,运行时不可变。
- 误区纠正: 作用域链不是由"函数在哪里调用"决定的,而是由"函数在哪里声明"决定的。
3. 闭包形成的三个条件
- 函数嵌套: 外层函数包含内层函数。
- 内部函数引用外部变量: 内层函数使用了外层函数的局部变量(自由变量)。
- 内部函数被外部访问: 通常通过
return将内层函数暴露给全局,或者将其赋值给全局变量。
🚀 结语:掌握"道"与"术"
- 词法作用域 告诉我们:写代码时的结构决定了变量的归属。
- 作用域链 告诉我们:变量查找是一场由内向外的单向旅行。
- 闭包 告诉我们:函数可以像人一样,背负着出生环境的记忆去往任何地方。
理解了这些底层机制,你不仅能在面试中对答如流,更能在编写复杂应用(如 React Hooks、模块化设计)时,精准预判代码的运行结果,写出更健壮、更高效的代码。
最后送给大家我很喜欢的一句话:
代码的执行在栈上,变量的生命在堆里,而逻辑的灵魂,在于你对作用域的理解。