导语
"明明变量就在眼前,为什么代码就是找不到?"
这是 JavaScript 开发者最常遇到的 "玄学问题"。本文通过一个经典案例,带你深入 JS 引擎底层,揭开变量查找的神秘面纱,彻底终结 "变量失踪" 难题。
一、案例重现:变量查找的 "跨时空迷案"
先看这段代码:
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
现象解析: bar函数内的console.log(test)输出了全局作用域的test=1,而非foo函数中的test=2或块级作用域的test=3。
核心疑问:变量查找为什么会 "跳过" 中间作用域?
二、JavaScript 执行机制详解
调用栈(Call Stack)
-
功能:记录函数的执行顺序,管理执行上下文和变量环境
-
特点:
- 后进先出(LIFO)的数据结构
- 全局上下文始终在栈底
- 每次函数调用会创建新的执行上下文并压入栈顶
- 函数执行完毕会从栈顶弹出
作用域(Scope)
-
类型:
- 全局作用域
- 函数作用域
- 块级作用域(ES6+)
作用域链(Scope Chain)
-
本质:变量查找的路径
-
变量查找规则:
- 当前作用域查找 → 依次向上层作用域查找 → 直到全局作用域 → 未找到则报错 ReferenceError
-
实现方式:通过词法环境的 outer 属性连接形成链式结构
词法作用域(Lexical Scope)
-
定义:函数定义时就确定的作用域(静态作用域)
-
实现:
- 每个执行上下文的词法环境中都包含一个外部引用 outer
- outer 指向定义该函数时的外部执行上下文
-
特点:
- 与函数调用位置无关
- 在代码编写阶段就已确定
执行上下文(Execution Context)
-
创建时机:函数调用时创建(注意:不是定义时)
-
组成:
- 变量环境(Variable Environment)
- 词法环境(Lexical Environment)
- this 绑定
-
生命周期:
- 创建 → 执行 → 销毁(垃圾回收)
变量环境(Variable Environment)
-
存储内容:
- var 声明的变量(会变量提升)
- 函数声明(整体提升)
-
特点:
- 没有块级作用域
- ES6 之前是唯一存储变量的地方
词法环境(Lexical Environment,ES6 新增)
-
存储内容:
- let/const 声明的变量
- 块级作用域(如 if/for 等代码块)
-
重要特性:
- 存在暂时性死区(TDZ)
- 包含 outer 属性(指向外部词法环境)
-
与变量环境的区别:
- 变量环境处理 var 和函数声明
- 词法环境处理 let/const 和块级作用域
outer 属性的演变
- ES6 前:存在于变量环境中(当时没有词法环境概念)
- ES6 后:存在于词法环境中
- 全局作用域:outer = null(表示作用域链终点)
关键点总结
调用栈管理执行顺序,作用域链管理变量访问
ES6 引入词法环境后,变量存储分为两部分:
- var → 变量环境
- let/const → 词法环境
outer 引用形成了作用域链,是实现闭包的基础
词法作用域(静态作用域)是 JavaScript 的核心特性之一
三、结合案例分析
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
我们结合上面的机制来详细解析这段代码:
一、编译阶段
二、执行阶段
三、 执行完毕后的出栈顺序
- bar 执行完毕,bar 执行上下文出栈
- foo 执行完毕,foo 执行上下文出栈
- 全局执行上下文最后出栈
出栈后进行垃圾回收
四、闭包
通过这个我们再进一步解释一下闭包:
闭包的作用域链分析
在这个例子中,innerBar 对象包含了两个闭包函数:getName 和 setName。它们的作用域链关系如下:
function foo() {
var myName = "极客时间" // 存储在foo的变量环境
let test1 = 1 // 存储在foo的词法环境
const test2 = 2 // 存储在foo的词法环境
var innerBar = {
getName: function() { // 闭包1
console.log(test1) // 通过outer找到foo的词法环境中的test1
return myName // 通过outer找到foo的变量环境中的myName
},
setName: function(newName) { // 闭包2
myName = newName // 修改foo的变量环境中的myName
}
}
return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName() // 1
console.log(bar.getName()) // 1 极客邦
outer 指向的具体机制
-
闭包函数的 outer 指向:
getName和setName函数定义在 foo 函数内部- 它们的
outer指针都指向foo函数的执行上下文环境(包含变量环境和词法环境)
-
变量查找路径:
(1)当调用
bar.setName("极客邦")时:-
先在
setName函数自身的词法环境查找myName(无) -
通过
outer找到foo的环境:myName在 foo 的变量环境中找到(myName="极客时间"),并将newName("极客邦") 赋值给myName
(2)当调用
bar.getName()时:-
先在
getName函数自身的词法环境查找(无) -
通过
outer找到foo的环境:test1在 foo 的词法环境中找到 (test1=1)myName在 foo 的变量环境中找到(myName="极客邦")
-
为什么能保持对 foo 变量的访问?
-
闭包的本质:
- 函数可以记住并访问所在的词法作用域
- 即使函数在其词法作用域之外执行
-
内存保持机制:
foo执行完毕后,正常情况下其执行上下文应该销毁(垃圾回收机制)- 但由于
innerBar的两个方法持有对foo环境的引用 - JavaScript 引擎会保持这个环境不被垃圾回收
根据词法作用域的规则,内部函数总是可以访问其外部作用域声明的变量(自由变量),当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,这些变量的集合就是闭包。
foo 外部函数,这些变量的集合为foo函数的闭包。
这些变量的集合(要在闭包中访问的外部函数的变量,未访问的就不算)也是内部函数运行的专属背包。
-
变量修改的可见性:
setName修改的myName和getName访问的myName是同一个变量- 因为它们都通过
outer指向同一个foo的变量环境
终极解决方案:可视化调试法
推荐使用 Chrome DevTools 的Scope面板可视化作用域链:
- 在
console.log(test)处打断点 - 查看
Local作用域(当前块级作用域) - 查看
Closure作用域(外层函数作用域) - 查看
Global作用域(全局作用域)
结语:理解背后的设计哲学
JavaScript的作用域机制体现了:
- 词法作用域的确定性
- 函数是一等公民的灵活性
- 向后兼容的历史包袱(var的存在)
理解这些原理,你就能:
✅ 准确预测代码行为
✅ 避免常见陷阱
✅ 写出更可靠的代码
✅ 深度掌握闭包等高级特性
下次当你遇到"这个变量从哪里来的?"的疑问时,记得沿着outer链一步步查找,谜底自然会揭开!