作用域链(Scope Chain):JS 变量查找的 “路线图”

作用域链是 JavaScript 查找变量的核心机制 —— 当代码需要访问一个变量时,JS 引擎会沿着 “当前作用域 → 父作用域 → 全局作用域” 的顺序层层查找,这条查找路径就像一张 “路线图”,指引引擎找到目标变量。

一、作用域链的本质:静态的查找路径

1.1 核心概念:从 “作用域” 到 “作用域链”

首先要明确:作用域是变量的 “可访问范围”(比如函数内部的变量只能在函数内访问),而 “作用域链” 是多个嵌套作用域组成的 “查找链条”。

比如函数嵌套场景,内部函数会形成包含父函数作用域、祖父函数作用域的链条,最终指向全局作用域:

1.2 关键特性:作用域链 “定义时确定,而非调用时”

这是理解作用域链的核心 —— 函数的作用域链在函数定义的那一刻就固定了,和函数什么时候调用、在哪里调用无关。这个特性也是闭包(👉 闭包(Closure):JavaScript 里的 “记忆小助手”)能 “记住外部变量” 的底层原因。

看个例子验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function outer() {
const outerVar = '我是外层变量'; // 父作用域的变量

// inner 函数在 outer 内部定义,此时就确定了作用域链:inner → outer → 全局
function inner() {
console.log(outerVar); // 能访问 outerVar,因为作用域链包含 outer 作用域
}

return inner; // 返回 inner 函数(此时 inner 已携带作用域链)
}

// 调用 outer,拿到 inner 函数(此时 outer 已执行完,但 inner 的作用域链还在)
const innerFunc = outer();
innerFunc(); // 输出“我是外层变量”(inner 按定义时的作用域链找到了 outerVar)

重点: 作用域链是 “静态” 的 —— 函数定义时就确定,不会因调用位置变化而改变。

二、作用域链的组成:变量查找的层级结构

作用域链由 “嵌套的作用域” 按顺序组成,通常分为以下几层,查找时严格遵循 “从内到外” 的顺序:

层级 作用域类型 描述 示例
1️⃣ 局部作用域 当前执行代码的作用域(如函数内部、if/for 块内) 函数内用 let 声明的变量
2️⃣ 父级作用域 包含当前作用域的外层作用域(如嵌套函数的父函数) 父函数内的变量
更多嵌套父级 层层向外的作用域(如祖父函数、曾祖父函数) 更外层函数的变量
🌍 全局作用域 最顶层作用域(浏览器中是 window,Node.js 中是 global 直接在脚本顶层声明的变量

实际查找过程:按链条顺序查找

看一段代码,直观感受变量查找的步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 全局作用域的变量 🌍
const globalVar = '我是全局变量';

function outer() {
// outer 作用域的变量(父作用域)
const outerVar = '我是外层变量';

function inner() {
// inner 作用域的变量(当前作用域)
const innerVar = '我是内层变量';

// 变量查找过程:
console.log(innerVar); // 1. 先查当前作用域 → 找到,直接使用
console.log(outerVar); // 2. 当前作用域没有 → 查父作用域(outer)→ 找到
console.log(globalVar); // 3. 父作用域没有 → 查全局作用域 → 找到
console.log(notExistVar); // 4. 全局作用域也没有 → 抛出 ReferenceError
}

inner();
}

outer();

三、作用域链 vs 执行上下文:别搞混的两个概念

很多人会把 “作用域链” 和 “执行上下文” 弄混,其实它们是完全不同的概念 —— 一个是 “静态查找路径”,一个是 “动态执行环境”。

特性 作用域链 执行上下文
创建时机 函数定义时(静态固定) 函数调用时(动态创建)
核心内容 变量的查找顺序(路径) 当前执行的代码、this 指向、变量对象等
是否变化 不变化(定义后固定) 每次调用都创建新的执行上下文(动态变化)
生命周期 和函数生命周期一致(函数存在则链存在) 函数执行期间存在,执行完后被销毁

简单说:作用域链决定 “变量能在哪里找到”,而执行上下文决定 “代码当前如何执行”。执行上下文会包含作用域链,但作用域链本身是独立的静态结构。

四、ES6 块级作用域:让作用域链更精细

ES6 之前,JS 只有 “函数作用域” 和 “全局作用域”,var 声明的变量会 “穿透”iffor 等块级结构。ES6 引入的 let/const 解决了这个问题,带来了 “块级作用域”—— 变量只在 {} 包裹的块内有效,也会加入作用域链。

4.1 块级作用域的影响:变量不再 “穿透”

1
2
3
4
5
6
7
{
let blockVar = '我只在块内有效'; // let 声明的块级变量
var funcVar = '我在函数内有效(穿透块)'; // var 声明的函数级变量
}

console.log(funcVar); // 输出“我在函数内有效(穿透块)”(var 不支持块级)
console.log(blockVar); // 报错 ReferenceError(let 限制在块内,作用域链找不到)

4.2 块级作用域的查找逻辑

块级作用域会成为作用域链的一环,查找时同样遵循 “从内到外”:

1
2
3
4
5
6
7
8
9
10
11
12
function blockExample() {
let topVar = '顶层变量(函数内)'; // 函数作用域的变量

if (true) {
let innerVar = '块内变量'; // 块级作用域的变量
console.log(topVar); // ✅ 块级作用域 → 函数作用域,找到 topVar
}

console.log(innerVar); // ❌ 函数作用域无法向下查找块级作用域,报错
}

blockExample();

五、作用域链的实战技巧:优化与封装

5.1 性能优化:减少作用域链查找次数

作用域链层级越深,查找变量的速度越慢。如果某个变量需要频繁访问(比如循环中),可以把它 “缓存” 到当前作用域,减少查找次数。在实际项目中,我经常会用到它来优化代码!

1
2
3
4
5
6
7
8
9
10
// 优化前:每次循环都要沿作用域链查找全局的 document(层级深,慢)
for (let i = 0; i < 1000; i++) {
document.getElementById(`item-${i}`).style.color = 'red';
}

// 优化后:把全局的 document 缓存到当前作用域(只查找一次,快)
const doc = document; // 一次查找全局作用域,缓存到当前作用域
for (let i = 0; i < 1000; i++) {
doc.getElementById(`item-${i}`).style.color = 'red'; // 直接访问当前作用域的 doc
}

5.2 模块模式:用作用域链实现 “私有变量”

JS 没有原生的 “私有变量” 语法,但可以通过 “立即执行函数(IIFE)” 创建独立作用域,利用作用域链的 “隔离性” 实现私有变量封装(外部无法访问函数内部变量)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const CounterModule = (function () {
// 私有变量:只能在 IIFE 内部访问(作用域链限制)
let privateCount = 0;

// 私有函数:同样只能内部访问
function privateIncrement() {
privateCount++;
}

// 暴露公共接口:外部只能通过这些方法操作私有变量
return {
increment: () => {
privateIncrement();
console.log('当前计数:', privateCount);
},
reset: () => {
privateCount = 0;
console.log('计数已重置');
},
};
})();

// 使用公共接口
CounterModule.increment(); // 输出“当前计数:1”
CounterModule.increment(); // 输出“当前计数:2”
CounterModule.reset(); // 输出“计数已重置”

// 尝试访问私有变量:无法找到(作用域链不包含 IIFE 内部)
console.log(CounterModule.privateCount); // undefined

六、常见陷阱:避开作用域链的坑

6.1 循环中的变量问题(var vs let)

ES6 之前用 var 声明循环变量,会因 var 没有块级作用域导致 “所有回调共享同一个变量”;用 let 则会为每次循环创建独立的块级作用域,解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 问题代码(var 无块级作用域)
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 输出 3、3、3(所有回调共享全局的 i,循环结束后 i=3)
}, 100);
}

// 解决方案1:用 let 创建块级作用域
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 输出 0、1、2(每次循环有独立的 i)
}, 100);
}

// 解决方案2:ES5 兼容方案(立即执行函数创建作用域)
for (var i = 0; i < 3; i++) {
(function (j) {
// j 是每次循环的 i 的副本,存在独立作用域
setTimeout(() => {
console.log(j); // 输出 0、1、2
}, 100);
})(i);
}

6.2 变量遮蔽(Variable Shadowing)

如果内层作用域的变量名和外层作用域一致,内层变量会 “遮蔽” 外层变量(查找时找到内层变量后就停止,不会继续向上找)。

1
2
3
4
5
6
7
8
9
10
const message = '全局消息'; // 外层变量

function showMessage() {
const message = '局部消息'; // 内层变量,遮蔽外层的 message

console.log(message); // 输出“局部消息”(找到内层变量后停止查找)
}

showMessage();
console.log(message); // 输出“全局消息”(外层变量未被影响)

注意: 遮蔽是 “暂时性” 的,只影响内层作用域的查找,不会修改外层变量。

七、作用域链与内存管理:避免内存泄漏

7.1 作用域链与垃圾回收

JS 的垃圾回收机制(GC)会回收 “不再被引用的变量”。如果作用域链断裂(比如函数执行完后,没有闭包引用其内部变量),作用域内的变量就会被 GC 清理。

流程如下:

7.2 常见内存泄漏场景

(1)意外创建全局变量

忘记用 var/let/const 声明变量,变量会自动成为全局变量,挂载到 window 上,作用域链一直包含它,导致无法回收:

1
2
3
4
5
6
function createLeak() {
// 错误:忘记写 let,leak 成为全局变量
leak = new Array(1000000).fill('大量数据');
}

createLeak(); // 执行后,leak 一直存在于全局作用域,无法被 GC 回收

(2)闭包导致的内存泄漏

如果闭包长期被引用(比如挂载到全局),它引用的外层变量(即使很大)也会一直存在于作用域链中,无法回收:

1
2
3
4
5
6
7
8
9
10
11
12
function createBigClosure() {
// 大数组:占用大量内存
const hugeArray = new Array(1000000).fill('我是大数据');

// 闭包:引用了 hugeArray
return () => {
console.log(hugeArray.length);
};
}

// 闭包被全局变量引用,导致 hugeArray 一直存在于作用域链
const globalClosure = createBigClosure();

解决办法:不再需要闭包时,手动将其设为 nullglobalClosure = null),断开引用,让 GC 能回收 hugeArray


作用域链是 JS 变量查找的 “底层规则”,理解它不仅能帮你避开 “变量找不到”“变量值不对” 的坑,还能让你更清晰地理解闭包、模块模式等高级特性。
记住核心:作用域链是静态的,定义时确定;查找时从内到外,找到即停 —— 掌握这个规则,就能轻松驾驭 JS 的变量访问逻辑!