首页 番外 作用域与作用域链
文章
取消

番外 作用域与作用域链

作用域与作用域链是 JavaScript 中标识符解析的底层机制。这两个概念直接关联到变量访问规则、闭包行为、this 绑定和内存管理,构成从执行上下文到垃圾回收的完整逻辑链条。本文从执行上下文出发,逐步推导作用域链的创建过程、标识符查找规则,以及闭包与作用域链之间的确切关系。

作用域:变量与函数的可见边界

作用域规定了一段代码中变量和函数的可访问范围。在 ES6 之前,JavaScript 仅存在两种作用域:全局作用域和函数作用域。全局作用域中的变量在整个程序的任何位置均可访问,函数作用域中的变量仅在函数体内部可见。ES6 引入了 letconst,带来了第三种作用域:块级作用域,由最近的一对花括号 {} 界定。

作用域的关键约束是单向的。内部作用域可以访问外部作用域中的变量,外部作用域无法访问内部作用域中的变量。这一约束并非出于安全策略,而是来自标识符解析的机械过程:引擎只能从当前位置沿一条预先确定的路径向外搜索,反向路径不存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var color = 'blue';

function changeColor() {
    let anotherColor = 'red';

    function swapColors() {
        let tempColor = anotherColor;  // 访问外层变量
        anotherColor = color;          // 访问更外层的变量
        color = tempColor;             // 修改全局变量
    }

    swapColors();
}

changeColor();

以上代码涉及三个上下文:全局上下文、changeColor 的局部上下文、swapColors 的局部上下文。swapColors 内部的 tempColor 在两个外层上下文中均不可见;changeColor 内部的 anotherColor 在全局上下文中不可见。而 swapColors 可以访问三个上下文中的所有变量,因为它位于嵌套的最内层。

作用域与执行上下文的辨析

作用域和执行上下文是两个常被混用但维度完全不同的概念。

作用域回答的问题是”这个标识符在哪一段代码里有效”。它是编译时确定的、静态的、词法的属性:函数写在全局就是全局作用域的子集,嵌套在另一个函数里就是外层作用域的延伸。代码不动,作用域就不变。作用域链则将这些”地盘”按嵌套关系串成一条从内到外的有序搜索路径,存储于 [[Scope]] 内部槽位,随函数定义一次性固定,与代码是否正在运行无关。

执行上下文回答的问题是”当前谁在执行、拥有哪些运行时资源”。它纯粹是运行时的产物:每次调用函数,引擎都会创建一个全新的执行上下文并推入调用栈,返回时弹出销毁。上下文携带的运行时资源包括:一个变量对象(存储局部变量和函数声明)、当前的 this 绑定、以及一条从 [[Scope]] 复制来的作用域链。作用域是地图,描述”哪里能看到什么”;执行上下文是车,描述”此刻谁在跑”。二者在代码编写阶段(作用域)和代码执行阶段(上下文)分处不同维度,互不取代:同一作用域可以对应多次调用产生的多个执行上下文,每个上下文共享同一条 [[Scope]] 链,但拥有独立的变量对象和 this 绑定。

 作用域执行上下文
本质变量名的可见性规则代码执行的运行时环境
确定时机函数定义时(编译阶段)函数调用时(运行阶段)
生命周期定义后永不改变调用时创建,返回时销毁
物理位置函数的 [[Scope]] 内部槽位调用栈上的栈帧
包含内容一条指向变量对象的指针链变量对象 + this 绑定 + 复制的链
决定性因素函数写在哪里(词法嵌套)函数被怎么调用

以下示意图描述了同一段代码中三个执行上下文与它们共享的作用域链之间的关系。调用栈中的每个栈帧承载独立的执行上下文,而作用域链(虚线箭头)贯穿它们,作为标识符解析的统一路径。

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
调用栈(运行时动态创建/销毁)
┌────────────────────────────┐
│ swapColors 执行上下文        │
│  变量对象(活动对象)         │
│  ├ tempColor               │ ← [0] 搜索起点
│  └ this                    │
│                            │
│  ├─ 作用域链 ────────────── │ ─ ─ ─ → changeColor 活动对象
│  │                         │         ┌─────────────────────┐
├─ ↓ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │         │ changeColor 执行上下文│
│ changeColor 执行上下文       │ ───→ │  变量对象(活动对象)  │
│  变量对象(活动对象)         │  [1]  │  ├ anotherColor       │
│  ├ anotherColor            │       │  └ this               │
│  └ this                    │       │                        │
│                            │       │  ├─ 作用域链 ────────── │
│  ├─ 作用域链 ────────────── │ ─ ─ ─ → 全局变量对象          │
├─ ↓ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │       └───────────────────────┘
│  全局执行上下文(栈底)       │                  ▲
│  变量对象 = window          │                  │ [2]
│  ├ color                   │                  │
│  ├ changeColor             │ ─────────────────┤
│  └ this                    │
└────────────────────────────┘
    
作用域链(compile-time 固定,贯穿所有调用):
  交换颜色的活动对象 [0] → changeColor 的活动对象 [1] → 全局变量对象 [2]
  ↑ 这条路径在函数定义时即锁定,每次调用复制使用,不因调用栈变化而改变

这个对比揭示了 varlet/const 时代的一个关键差异。var 时期,作用域以函数为边界,执行上下文也以函数为边界,二者天然一一对应,作用域链的层级数与调用栈深度完全吻合。let/const 引入块级作用域后,这一对应关系被打破:{} 创建作用域但不创建执行上下文,一个函数上下文内部可以嵌套任意多个块级作用域,作用域链的层级数因此可以大于调用栈帧数。

进一步看作用域链与活动对象的依存关系。作用域链的每个节点是指向变量对象的指针,变量对象是执行上下文的组成部分,链依存于上下文而存在。但闭包制造了一个例外:外层执行上下文从调用栈弹出后,其运行时创建的那条作用域链随之一同销毁;然而内层函数在定义时已从外层上下文中复制了一份链存入 [[Scope]],这份副本独立于外层上下文的生命周期。外层上下文的链是复制源,复制完成后二者再无关联。外层销毁时烧毁的是源链,[[Scope]] 中的副本仍然举着火把,指向外层活动对象的指针完好无损。活动对象也因这份独立的引用而留在堆内存中,不被 GC 回收。

总结:外层执行上下文消失后,存活的不是同一根链的延续,而是定义时已独立复制的那份副本;活动对象存活的原因不是上下文在等待,而是内层 [[Scope]] 的指针让它从根可达。

执行上下文与变量对象

作用域并非独立存在的抽象概念,它附着在执行上下文之上。每当 JavaScript 引擎切换执行环境(进入全局代码、调用一个函数、执行 eval),就会创建对应的执行上下文并推入调用栈。每个执行上下文都携带一个关联的变量对象(Variable Object),该上下文中定义的所有变量和函数均作为属性存储于这个变量对象之上。

全局上下文的变量对象在浏览器环境中即为 window 对象本身,随应用程序启动创建,仅在关闭网页或退出浏览器时销毁。函数上下文的变量对象存在一个特殊之处:它在函数被调用时创建,且额外包含一个 arguments 对象,记录调用时传入的所有实参。由于这个差异,函数上下文的变量对象被单独称为活动对象(Activation Object),以区别于全局上下文的静态变量对象。

两个术语的本质关系如下:

  • 变量对象:全局上下文中的叫法,随代码执行持续存在
  • 活动对象:函数局部上下文中的叫法,仅在函数执行期间存在,本质是附加了 arguments 的变量对象

函数执行完毕后,其执行上下文从调用栈弹出,活动对象随之销毁。全局上下文的变量对象则持续存在直至程序终止。

作用域链:标识符查找的路径

作用域链由一组指向变量对象的指针按嵌套顺序排列而成,是标识符解析的唯一通道。当代码访问一个标识符时,引擎从作用域链的最前端开始搜索,逐一检查每个变量对象中是否存在同名的标识符,找到即停止,不再继续向后搜索。

红宝书及早期规范使用 [[Scope]] 作为函数存储外部环境引用的内部属性名称。ECMAScript 现行规范中对应的标准术语为 [[Environment]],二者指向相同的机制:函数定义时所在词法环境的引用。本文沿用红宝书的 [[Scope]] 写法,对其作为内部属性的详细讨论参见番外篇《JavaScript内部属性([[]])简述》。

[[Scope]] 即作用域链本身,并非链之外的另一实体。它是一个内部属性,值是一组有序的指针,每个指针指向一个变量对象。链不独立漂浮于执行上下文之外,它以列表形式存储于 [[Scope]] 槽位中,新函数定义时从此处复制、再推入自身的活动对象,形成属于自己的那条链。

变量对象的本体存于各自的执行上下文内部。每个执行上下文创建时,其变量对象(全局的变量对象或函数的激活对象)同步生成,二者共享同一生命周期:上下文从调用栈弹出,变量对象随之销毁。作用域链中的指针,正是从当前执行上下文的作用域链出发,指向其他执行上下文的变量对象本体。链是路径,变量对象是路径尽头真正存储 arguments、局部变量和内部函数声明的地方。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
全局执行上下文(随应用启动创建,关闭时销毁)
┌─────────────────────────────────┐
│ 变量对象(浏览器中即为 window)   │
│  ├ this                        │
│  ├ result                      │
│  └ compare ← 函数声明存于此     │
└─────────────────────────────────┘
         ▲
         │ compare.[[Scope]][0] 指向这里

compare 调用时的执行上下文(调用时创建,返回后销毁)
┌─────────────────────────────────┐
│ 活动对象                         │
│  ├ arguments                    │
│  ├ value1                       │
│  └ value2                       │
│                                  │
│ 作用域链 = [活动对象▲, 全局变量对象]
└─────────────────────────────────┘

作用域链的创建分为两个阶段。

第一阶段在函数定义时发生。引擎根据函数的静态嵌套位置确定其外部环境,将该外部环境的变量对象引用预装载进一条链,存入函数的内部属性 [[Scope]]。此时函数尚未被调用,[[Scope]] 仅为一条不含自身活动对象的引用链。

第二阶段在函数调用时发生。引擎创建新的执行上下文,从 [[Scope]] 复制整条链作为当前上下文的作用域链,然后创建当前函数的活动对象并将其推入链的最前端。

以全局上下文中调用一个普通函数为例:

1
2
3
4
5
6
7
function compare(value1, value2) {
    if (value1 < value2) return -1;
    else if (value1 > value2) return 1;
    else return 0;
}

let result = compare(5, 10);

compare 定义时,[[Scope]] 仅包含一个元素:全局变量对象的引用。compare(5, 10) 调用时,引擎执行以下步骤:

  1. 创建执行上下文
  2. [[Scope]] 复制链:[全局变量对象]
  3. 创建活动对象:{ arguments, value1: 5, value2: 10 }
  4. 将活动对象推入链前端:[活动对象, 全局变量对象]

函数体内部的代码在访问 value1 时,引擎从链的 [0] 位置(活动对象)找到该标识符,搜索终止。若访问 result,引擎在活动对象中未找到,继续搜索 [1] 位置(全局变量对象),找到后终止。若访问一个不存在的标识符,引擎搜索完整个链仍未找到,抛出 ReferenceError

关键细节:作用域链中的对象也可能拥有原型链,标识符搜索会沿着每个变量对象的原型链进一步搜索。不过这一路径在绝大多数实际场景中不影响结果,因为变量对象上属性查找的原型链终点通常是 Object.prototype,而自行定义的变量不会出现在原型链上。

块级作用域在作用域链中的表现

ES6 的 letconst 引入了块级作用域,但未改变作用域链的基本机制。引擎在进入一个由花括号界定的块时,会在当前词法环境中创建一个新的绑定记录,块内的 let/const 声明挂载于该记录而不是外层的变量对象。

标识符查找的规则不变:从最近的绑定记录开始向外搜索,找到即停。块级作用域仅是增加了搜索路径中的层级数量:

1
2
3
4
5
6
7
8
9
10
11
var color = 'blue';

function getColor() {
    let color = 'red';
    {
        let color = 'green';
        return color;  // 搜索:块级绑定 → 找到 'green' → 停止
    }
}

getColor();  // 'green'

引擎在 return color 处的搜索路径为:块级绑定记录 { color: 'green' } → 函数级绑定记录 { color: 'red' } → 全局变量对象 { color: 'blue' }。第一步即命中,后续层级不予检查。

作用域链的临时增强

两种语句会在作用域链前端临时插入一个额外的变量对象,在代码执行完毕后移除。

with 语句将指定对象的属性展开为作用域链前端的标识符。catch 语句创建一个包含错误对象声明的变量对象,该对象仅在 catch 块内部可见。

1
2
3
4
5
6
7
try {
    throw new Error('something went wrong');
} catch (e) {
    // 作用域链前端临时插入了 { e: Error }
    console.log(e.message);
}
// 此处 e 已不可见

这一机制在现代代码中使用频率很低。with 语句在严格模式下被禁止,catch 的块级绑定自 ES2019 起已成为可选语法。但它们揭示了一个重要事实:作用域链并非函数定义后便完全固定,而是在特定语句下可以被动态修改。

闭包:作用域链存活时间的延长

正常函数执行完毕后,其执行上下文被销毁,活动对象也随之释放。但闭包制造了一个例外。

当内部函数被返回并在外部继续持有时,尽管外层函数的执行上下文已经销毁,外层函数的活动对象却无法被垃圾回收。原因在于内部函数的 [[Scope]] 中仍然保存着对该活动对象的引用。

以下例分析闭包与作用域链的完整生命周期:

1
2
3
4
5
6
7
8
9
10
11
12
function createComparisonFunction(propertyName) {
    return function(object1, object2) {
        let value1 = object1[propertyName];  // 访问外部变量
        let value2 = object2[propertyName];
        if (value1 < value2) return -1;
        else if (value1 > value2) return 1;
        else return 0;
    };
}

let compare = createComparisonFunction('name');
let result = compare({ name: 'Alice' }, { name: 'Matt' });

作用域链在每一步的演变如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
createComparisonFunction 定义时:
  [[Scope]] = [全局变量对象]

createComparisonFunction('name') 调用时:
  作用域链 = [自己的活动对象{ propertyName: 'name' }, 全局变量对象]

匿名函数定义时(在 createComparisonFunction 内部):
  [[Scope]] = [createComparisonFunction的活动对象, 全局变量对象]
  ↑ 此处复制外层整条链,而非仅复制全局部分

createComparisonFunction 返回后:
  执行上下文销毁,作用域链销毁
  但活动对象仍驻留内存(被匿名函数的 [[Scope]] 引用)

compare({ name: 'Alice' }, { name: 'Matt' }) 调用时:
  作用域链 = [自己的活动对象, ccf的活动对象, 全局变量对象]
  activityObject 从 [[Scope]] 复制链后前插自己

compare = null:
  解除匿名函数的唯一引用
  ccf的活动对象失去所有引用 → 被垃圾回收

闭包的作用域链之所以包含外部函数的变量对象,并非某种特殊机制,而是作用域链创建规则的自然推论:内部函数定义时,从外部函数的执行环境中复制整条链存入 [[Scope]]。外部函数的执行上下文事后销毁,不影响内部函数已经持有的引用。

this 与 arguments 不在作用域链上

thisarguments 常常被误认为参与作用域链查找。实际上,两者的行为与作用域链无关。

arguments 确实存在于每个函数的局部活动对象中,但作用域链的搜索是从自身开始向外查找。内部函数的活动对象中自动拥有自己的 arguments,因此同名标识符的查找命中自身即停止,外层的 arguments 永远不会被搜索到。这不是作用域链的缺陷,而是作用域链查找规则的必然结果:找到即停,不继续搜索外层。

this 的情况更为根本:this 根本不在活动对象中,不存储在变量对象上,它是一个独立于变量对象体系的执行上下文槽位。每个函数的 this 由调用方式单独确定(作为方法调用、裸函数调用、call/apply/bind 强制绑定、new 构造调用),不受作用域链的任何影响。内部函数无法通过作用域链”找到”外部函数的 this,因为外部函数的 this 从未进入过搜索路径。

在闭包中访问外部函数的 this 的唯一方式是将其保存到一个普通变量中,使该变量进入作用域链:

1
2
3
4
5
6
7
8
9
10
11
window.identity = 'The Window';
let object = {
    identity: 'My Object',
    getIdentityFunc() {
        let that = this;  // 将 this 保存为普通变量,进入作用域链
        return function() {
            return that.identity;  // 通过作用域链访问 that
        };
    }
};
object.getIdentityFunc()();  // 'My Object'

that 作为一个普通变量被定义在 getIdentityFunc 的活动对象中,内部匿名函数通过作用域链向上搜索即可命中。this 本身从未参与这条链路。

变量声明的三种方式与作用域的关系

varletconst 三种声明在作用域层面的行为差异可由作用域链的创建规则解释。

var 声明的变量挂载于最近的函数作用域或全局作用域。var 声明存在提升(hoisting):引擎在进入执行上下文时,先将所有 var 声明提升至函数或全局作用域的顶部,赋值为 undefined,然后逐行执行赋值语句。这意味着在声明语句之前访问该变量不会触发 ReferenceError,而是返回 undefined

letconst 声明的变量挂载于最近的块级作用域。它们在技术上也会被提升,但在声明语句执行之前,标识符处于”暂时性死区”(Temporal Dead Zone),任何访问尝试均抛出 ReferenceError。从实际编码的角度看,letconst 的行为表现为”不提升”。

const 额外引入了一项约束:声明时必须初始化,且不可重新赋值。对于原始值,这意味着值本身不变;对于对象,引用不变但对象的属性可变。Object.freeze 可将这一约束扩展至对象的所有属性。

总结

作用域是变量可见性的规则边界,作用域链是引擎在多层嵌套的变量对象之间解析标识符的查找路径。二者的关系不是并列的,而是因果的:作用域定义了”哪些变量理论上可见”,作用域链则是”引擎实际如何在层层变量对象中搜索”的机械过程。

理解这一对概念,能够准确解释 JavaScript 中的变量遮蔽(shadowing)、闭包的内存驻留行为、this 与作用域链的分离、暂时性死区的成因,以及 with/catch 对查找路径的临时篡改。这些看似分散的语法行为共享着同一个操作基础:作用域链的前端插入、逐级查找、找到即停。

本文由作者按照 CC BY 4.0 进行授权

番外 原型链的设计由来

-