JavaScript中的变量查找并不是运行时随机扫描全局对象,而是严格按照一条在函数创建阶段就已经确定好的链式结构逐层向外检查,这条结构就是作用域链。无论是闭包能持续访问外部变量,还是同名变量产生覆盖,背后都是作用域链在起作用。

一、作用域链从哪里来:词法作用域决定结构
作用域链并不是函数调用时临时拼出来的,它的核心规则来自词法作用域。所谓词法作用域,指的是一个函数能访问哪些变量,在代码书写阶段就已经确定,而不是由运行时的调用位置决定。JavaScript引擎在解析函数定义时,会把当前外层词法环境的引用保存到函数的内部属性 [[Scope]] 中。这个内部属性保存了一条从内到外的静态作用域链。
当函数真正执行时,引擎会创建一个新的执行上下文,并生成当前函数的活动对象,里面包含参数、局部变量和内部函数声明。随后,引擎把活动对象插入到之前保存的 [[Scope]] 链的最前端,形成一条完整的动态查找链。换句话说,执行期间的作用域链等于当前活动对象加上书写时已经确定的外部词法环境链。正因如此,下面这段代码会输出 2 而不是 1。
var a = 1;
function outer() {
var a = 2;
function inner() {
console.log(a);
}
inner();
}
outer();
这里 inner 函数在 outer 函数内部创建,所以它的 [[Scope]] 中先保存了 outer 的活动对象,再往外才是全局对象。即使 inner 在别处被调用,只要作用域链没有被人为改写,它仍然会先沿着 outer 层查找变量 a。如果 JavaScript 采用动态作用域,结果则可能依赖调用栈,但 JavaScript 明确使用词法作用域,因此作用域链的层级关系在书写时就已经固定下来。
二、变量查找的具体过程:从当前活动对象逐级向外
当代码读取一个变量时,引擎会从当前执行上下文的活动对象开始,检查该活动对象中是否存在这个变量名。如果存在,则直接取值;如果不存在,就沿着作用域链的下一层继续查找,直到全局对象。如果在整条链上都没有找到,读取操作会抛出 ReferenceError。这个由内到外的顺序决定了内部变量会覆盖外部同名变量。
以下面这段代码为例:
var total = 100;
function calc() {
var total = 10;
function add() {
var step = 1;
return total + step;
}
return add();
}
console.log(calc());
add 函数中查找 total 时,先在自己的活动对象里找,没有找到;接着进入 calc 的活动对象,发现有 total 且值为 10,于是停止查找并返回 11。全局的 total 虽然也存在,但不会被访问。这种机制能让不同层级使用同名变量而不互相干扰,但也要求开发者清楚每一层作用域中变量的分布,否则很容易出现变量遮蔽导致的逻辑错误。
赋值操作与读取操作略有不同。在非严格模式下,对一个未声明变量赋值会在全局对象上隐式创建该变量;而在严格模式下,同样操作会直接抛出 ReferenceError。不过函数内的局部变量赋值仍然会沿作用域链找到对应变量后进行更新。如果当前层没有,但外层有同名变量,赋值会修改外层变量,而不是在当前层创建新变量,除非使用 let、const 或 var 在当前层重新声明。
三、闭包如何借助作用域链保留变量
闭包是作用域链最典型也最常用的表现。闭包指的是一个函数能够记住并访问它创建时的词法作用域,即使这个函数在外部函数执行结束后才被调用。原理并不神秘:外部函数执行完毕后,其执行上下文虽然会从调用栈弹出,但只要内部函数仍引用着外部函数的变量,内部函数的 [[Scope]] 链就会继续指向外部函数的活动对象,这部分内存不会被垃圾回收。
function createCounter() {
var count = 0;
return function increment() {
count++;
return count;
};
}
var counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
increment 函数的作用域链最前端是它自己的活动对象,第二层是 createCounter 的活动对象,第三层是全局对象。每次调用 counter 时,count 的读取和更新都发生在第二层。由于 createCounter 返回后,这个活动对象仍被 increment 引用,所以 count 不会被销毁。这样闭包就实现了状态保持。
块级作用域出现后,作用域链的层级变得更细。let 和 const 声明不会挂到函数或全局对象上,而是进入独立的词法环境。每当程序进入一个大括号块,就会创建一个新的词法环境,并被插入作用域链的最前端,离开块后再从链上移除。下面代码中的两个 x 就处于不同层级:
let x = 'global';
{
let x = 'block';
console.log(x);
}
console.log(x);
块内部查找 x 时,先在块级词法环境中找到 block,所以优先输出;离开块后,外层 x 仍保持 global。这种机制让作用域链不再只是函数级,而是可以细化到任意块,减少了用 var 时常见的变量提升和污染问题。
四、常见误区与调试技巧
一个很常见的误区是循环中 setTimeout 输出异常。下面这段代码最终会输出 3 3 3,而不是 0 1 2:
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 0);
}
原因在于 var 声明的 i 位于循环所在函数或全局作用域,而不是每次迭代单独创建。三个回调函数共享同一个作用域链,当定时器执行时循环早已结束,此时 i 已经变成 3。解决方式之一是把 var 改成 let。
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 0);
}
let 会在每次迭代中创建一个新的块级词法环境,并把当前的 i 值绑定到该环境中。每个回调函数的作用域链会各自指向不同的迭代环境,因此能保留当时的 i 值,最终输出 0 1 2。理解这个差别,对处理异步循环中的变量捕获很有帮助。
调试作用域链时,可以在浏览器开发者工具的 Sources 面板中打断点。断点暂停后,右侧 Scope 区域通常会显示 Local、Closure、Script 和 Global 等层级。Local 对应当前函数活动对象,Closure 表示被闭包引用的外部变量环境,Script 是模块或脚本中的 let、const 词法环境,Global 则是全局对象。查看这些层级,可以快速判断一个变量当前到底取自哪一层作用域,帮助定位变量覆盖、闭包引用和未定义问题。
从性能角度看,作用域链越长,变量查找可能需要经过的层级就越多。现代 JavaScript 引擎会通过隐藏类和内联缓存等优化技术减少实际查找成本,但如果一个变量在很深的链上,并且被高频访问,仍然建议将其缓存到局部变量中。这样查找可以直接命中当前活动对象,避免逐层向上扫描。举例如下:
var config = {
timeout: 5000,
retry: 3
};
function run() {
var timeout = config.timeout;
var retry = config.retry;
for (var i = 0; i < retry; i++) {
console.log(timeout);
}
}
run();
这里把 config 的属性读取到局部变量 timeout 和 retry 后,循环内部每次访问都只查找当前活动对象,不再重复向外层作用域链回溯。虽然这不是绝对必要的优化,但在大量循环或频繁调用的场景下,能帮助保持代码清晰并减少不必要的查找。
总结一下,作用域链是 JavaScript 变量查找的核心机制,它由词法作用域决定,在执行时以当前活动对象为起点向外逐级查找。闭包、块级作用域、变量提升等现象都可以统一到这条链上理解。掌握作用域链,不只是为了应对面试题,更是为了在日常编码中减少变量冲突,写出行为可预测、更易调试的 JavaScript 程序。