一、引言:一个令人困惑的示例
先来看一段看似简单却容易出错的 JavaScript 代码:
javascript
// 全局环境
var myName = '极客时间';
let myAgent = 10;
let test = 1;
function bar(){
console.log(myName);
}
function foo(){
var myName = '极客邦';
bar();
}
foo(); // 输出什么?
直觉上,很多人会认为输出应该是 '极客邦',因为 bar() 是在 foo() 内部调用的。但实际上,这段代码输出的是 '极客时间'。
为什么会出现这样的结果?这就引出了 JavaScript 中一个核心概念------词法作用域链。
二、什么是词法作用域?
词法作用域 (Lexical Scope)指的是:变量的可见性由函数在源代码中的声明位置决定,而不是函数被调用的位置。
换句话说,解析变量名的"查找路径"(即作用域链)在代码的编译/解析阶段就已经确定好了,与运行时调用栈的顺序无关。这就是为什么 bar() 函数始终访问的是全局的 myName,因为它在源码中就是在全局作用域声明的。
三、更复杂的示例:混合作用域类型
让我们看一个更复杂的例子,包含 var、let 和块级作用域:
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 test = 1;
foo();
这段代码展示了:
var的函数级作用域let的块级作用域- 不同位置声明的变量如何相互影响
关键点在于:bar 在源码中声明的位置决定了它能访问的外层词法环境。即使 bar() 在 foo 里的某个块中被调用,它也无法看到 foo 的局部变量(除非 bar 是在 foo 内部声明的)。
四、JavaScript 引擎的内部机制
要真正理解作用域链,我们需要深入到 JavaScript 引擎(如 V8)的实现层面。
执行上下文的组成
每个执行上下文(Execution Context)包含三个核心部分:
- Variable Environment(变量环境) - 存储
var与function声明 - Lexical Environment(词法环境) - 存储
let / const / class声明 - ThisBinding(this 绑定) 及可执行代码
现代 JavaScript 引擎中,变量环境和词法环境是两套独立但协同工作的系统,它们各自维护环境记录(Environment Record),并共享相同的外层指针(outer),构成"并行的作用域链结构"。
编译阶段 vs 执行阶段
JavaScript 函数的执行分为两个关键阶段:
1. 编译阶段(Compilation)
在这个阶段,引擎会:
创建 Variable Environment:
- 登记
var声明(初始化为undefined) - 登记函数声明(初始化为对应函数对象)
创建 Lexical Environment:
- 登记
let / const / class声明,但保持在 TDZ(暂时性死区) 中 - 为块级作用域创建独立的词法环境
建立 outer 链接:
- 确定当前环境的外层环境引用
- 这个链接基于代码的静态结构,而非运行时调用
2. 执行阶段(Execution)
代码真正开始执行时:
-
访问变量时,查找顺序为:
- 先查 Lexical Environment(块级作用域 + let/const)
- 找不到则查 Variable Environment(var/function)
- 再沿着 outer 指针向外层环境查找,直到全局
-
环境记录中的值会被不断更新(赋值、初始化等)
执行上下文的内部结构
从实现角度看,执行上下文可以表示为:
javascript
Execution Context = {
EnvironmentRecord: {
Variable Environment,
Lexical Environment,
outer // 指向外层词法环境的引用
},
code // 可执行代码
}
不同类型的声明有不同的处理策略:
var:在编译阶段被初始化为undefinedfunction:在编译阶段被绑定为函数对象let/const:在词法环境中登记,但直到执行到声明语句才正式初始化
五、回到示例:为什么是全局的 myName?
现在我们可以完整解释开头的例子了:
bar在全局作用域声明,因此bar.[[Environment]]指向全局词法环境- 当
bar执行并访问myName时,查找路径是:bar的局部环境(没有找到)- 沿着
[[Environment]]到全局环境 - 找到
myName = '极客时间'
bar在foo内部调用的事实不改变 其[[Environment]]引用
这就是词法作用域(静态作用域)与动态作用域的核心区别。
六、闭包(closure)是如何"借用"词法作用域的
简单版结论:闭包是函数和其声明时关联的词法环境的组合。
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;
}
var bar = foo();
bar.setName('极客邦');
console.log(bar.getName()); // '极客邦'
分析:
getName/setName在foo内声明,因此它们的[[Environment]]指向foo的词法环境。- 当
foo返回innerBar后,foo的执行上下文弹出调用栈,但foo的词法环境并未被回收,因为innerBar中的函数仍然通过闭包引用该环境(环境是"可达"的)。这就是闭包保持自由变量存活的机制。
GC(垃圾回收)角度
- 只有当
foo的词法环境不再被任何可达对象(如返回的函数对象)引用时,才会被回收。 - 因此
bar(上例返回的对象)持有对那块环境的引用,导致myName、test1等变量继续存活。
七、常见面试/调试陷阱
- 函数在哪里声明,在哪里决定它的外部环境:无论何时调用,外部环境由声明位置决定。
- 调用栈 vs 环境:调用栈控制运行顺序和执行上下文的创建/销毁;环境控制变量解析路径,二者不同步。环境包括变量环境和词法环境。
var与let/const的差别 :var是函数级(或全局)绑定且会被提前初始化为undefined;let/const是块级绑定且存在 TDZ。- 闭包不等于内存泄漏 :闭包让外层环境继续可达,因此不会被 GC;需要手动断开引用(如把返回对象设为
null)来释放内存。
八、实践建议(写更容易理解、调试的代码)
- 尽量用
let/const而不是var,避免意外提升带来的迷惑。 - 函数如果需要访问周围变量,尽量把它在恰当的词法位置声明,这样阅读代码时能直观得知依赖关系。
- 对长期持有闭包引用的场景(如事件回调、定时器、长生命周期对象),显式释放引用或把需要缓存的数据放到显式的对象上,以便管理其生命周期。
九、小结(一句话回顾)
词法作用域链在编译阶段就决定了变量解析路径;闭包则是函数与其声明时词法环境的绑定,正是它使得某些局部变量在函数返回后仍然存活。