概述
作用域(Scope)与作用域链(Scope Chain)是 JavaScript 的核心概念,它们决定了变量的可访问范围、生命周期,以及代码运行时变量查找的规则,理解这两个概念,可以回答我们 "变量在这里为什么能访问","为什么这里访问到的变量值是 undefined" 等诸多疑问,同时还能帮助我们在开发过程中规避变量污染、提升代码的可维护性,对于 ES Module 等模块方案也可以有更好的理解。
作用域类型
全局作用域(Global Scope)
全局作用域是最顶层的作用域,代码中未被任何函数或块级结构包裹的变量 / 函数,都属于全局作用域。
- 生命周期:与程序运行周期一致,页面加载时创建,页面关闭时销毁;
- 浏览器环境 :全局作用域的变量会挂载到
window对象上(Node.js 环境挂载到global对象); - 访问范围:代码中的任何位置都能访问;
typescript
// 全局变量:属于全局作用域
const globalVar = "我是全局变量";
function bar() {
// 可访问全局变量
console.log(globalVar); // 输出:我是全局变量
}
bar();
console.log(window.globalVar); // 浏览器环境输出:我是全局变量
函数作用域(Function Scope)
函数作用域是指变量 / 函数仅在定义它们的函数内部可访问,函数外部无法直接访问。
- 生命周期:函数调用时创建,函数执行结束后销毁(闭包除外);
- 访问范围:仅函数内部及嵌套的子函数可访问;
- 核心特性:函数参数也属于函数作用域的变量。
typescript
function foo() {
// 函数作用域变量:仅foo内部可访问
const funcVar = "我是函数作用域变量";
// 嵌套函数可访问外层函数作用域的变量
function inner() {
console.log(funcVar); // 输出:我是函数作用域变量
}
inner();
}
foo();
console.log(funcVar); // 报错:funcVar is not defined(外部无法访问)
块级作用域(Block Scope)
块级作用域是 ES6 引入的特性,由 { } 包裹的代码块(如 if、for、while、try/catch,以及直接用 { } 定义的块)形成,仅 let/const 声明的变量会绑定到块级作用域。
- 生命周期:代码块执行时创建,执行结束后销毁;
- 访问范围:仅块内部可访问;
- 核心特性 :不存在变量提升(或称为暂时性死区),避免变量泄露。
typescript
if (true) {
// let声明的变量:绑定到块级作用域
let blockVar = "我是块级作用域变量";
const blockConst = "块级常量";
// var声明的变量:不绑定块级作用域,属于外层作用域(如全局)
var nonBlockVar = "我不属于块级作用域";
}
console.log(blockVar); // 报错:blockVar is not defined
console.log(blockConst); // 报错:blockConst is not defined
console.log(nonBlockVar); // 输出:我不属于块级作用域(全局变量)
虽然我这里划分了三种类型的作用域,但其实是两个大类型:全局和局部,函数作用域和块级作用域就属于是局部的作用域。
作用域的本质
作用域本质上是定义了一套变量的访问规则,用于确定在代码执行过程中,某个变量何时被创建、何时被销毁,在何处可以被访问、修改。
JavaScript 的作用域是静态作用域 (通常也称为词法作用域), 静态作用域是在代码定义阶段而非运行阶段确定的,通俗的说就是,你把变量写在代码的哪里,它的作用域就在哪个范围内,举个例子:
typescript
function outer() {
const a = 1; // 定义在 outer 作用域中的变量
function inner() {
console.log(a); // inner函数定义时,嵌套在outer内部,所以可以访问这个 a 变量
}
return inner;
}
console.log(a); // 这个 log 是在全局作用域下,是在 outer 作用域外的,所以访问不到 outer 中的变量
// 但这里有一个注意点,a 虽然访问不到 outer 中的变量 a,但是他可以访问到全局作用域的 a,由于未定义,所以输出是 undefined (非严格模式下)
const fn = outer();
fn(); // 输出:1(即使 fn 在 outer 外部执行,仍能访问 outer 的 a,这就是经典的闭包)
一般和作用域同时提到的还有执行上下文,这是两个概念,需要注意区分:
- 作用域是静态的,代码定义时确定,不关注代码的执行
- 执行上下文是动态的,在代码执行的过程中动态的创建,包含
this,变量对象,作用域链等信息,在每次调用函数时都会创建新的执行上下文。
作用域链
作用域链,顾名思义,就是一个链表,是由当前作用域和外层作用域组成的链表,用于解析变量引用。当代码在某个作用域访问一个变量时,会从当前作用域出发逐级向外层作用域去寻找变量,举个例子:
typescript
// 全局作用域
const globalVar = "全局变量";
function outer() {
// 外层函数作用域
const outerVar = "外层变量";
function inner() {
// 内层函数作用域
const innerVar = "内层变量";
console.log(innerVar); // 查找链:【找到】inner 作用域
console.log(outerVar); // 查找链:inner 作用域 -> 【找到】outer 作用域
console.log(globalVar); // 查找链:innter 作用域 -> outer 作用域 -> 【找到】全局作用域
}
inner();
}
outer();
在 inner 函数中访问 globalVar 变量时就是沿着作用域链逐级查找的。
链的构建过程
-
函数定义时 :JavaScript 引擎会为函数关联一个
[[Scopes]]内部属性,存储函数定义时所处的所有外层作用域; -
函数调用时:创建该函数的执行上下文,此时作用域链会被初始化:
- 链的第一个元素是当前执行上下文的变量对象(存储当前作用域的变量、函数);
- 后续元素是函数
[[Scopes]]属性中的外层作用域变量对象,按从内到外的顺序排列;
-
作用域链固化:作用域链在执行上下文创建时确定,后续不会因代码执行而改变。
以本节开始的代码为例:
inner函数定义时,引擎会为其添加一个[[Scopes]]属性,其中包含:外层函数作用域(outer)、全局作用域;inner调用时,会创建执行上下文,该上下文的作用域链为:[inner 变量对象({innerVar: "内层变量"})→ outer 变量对象({outerVar: "外层变量"})→ 全局变量对象({globalVar: "全局变量"})],箭头表示链表的方向及连接。
变量查找规则
当访问一个变量时,JavaScript 引擎的查找步骤为:
- 从作用域链的第一个元素(当前作用域)开始查找
- 若找到变量,直接返回其值(或引用),停止查找
- 若未找到,继续查找作用域链的下一个元素(外层作用域)
- 依次类推,直到找到变量或遍历完整个作用域链
- 若遍历完所有作用域仍未找到,抛出
ReferenceError(变量未定义)、
需要注意的是,修改是直接作用到这个查找到的变量上的,比如:
typescript
const x = 1; // 全局变量
function foo() {
x = 2; // 修改的是全局变量x,而非创建局部变量
console.log(x); // 输出:2
}
foo();
console.log(x); // 输出:2(全局变量被修改)
在过去不默认声明严格模式的时候,我们在 foo 函数里面 x =2 会隐式的创建一个全局变量,在编码的时候是一个很大的坑,但是现在的项目基本都默认严格模式了,这种使用方式就会报错,提前为我们规避一些问题。
应用
闭包
闭包应该是作用域链的一个最典型、广泛的应用了,他是由函数和定义时的词法作用域组合成的,他允许函数在外部作用域执行时,依旧能够访问到该函数定义时的局部变量(函数作用域内的变量),举个例子:
typescript
function createCounter() {
let count = 0; // 外层函数作用域变量
// 内部函数引用了count,且被返回(导出到外部)
return function increment() {
count++;
console.log(count);
};
}
// increment在createCounter作用域之外执行
const counter = createCounter();
counter(); // 输出:1
counter(); // 输出:2
counter(); // 输出:3
从作用域的视角上看:
increment函数定义的时候,其对应的[[Scopes]]属性存储的是createCounter的作用域和一个最外层的全局作用域,也就是说这时候相关的作用域就已经被这个increment函数持有了。- 在
createCounter执行结束后,该函数的上下文环境被销毁,但是由于increment依旧持有这个函数的作用域,而且increment被外部的 counter 变量所引用,不能被 GC,所以increment这时候也还存在着,并且此时有个别名counter。 - 当
counter被调用的时创建的执行上下文的作用域链为:[increment 变量对象 → createCounter 作用域变量对象 → 全局变量对象]。
由于这样的链式关系和引用的持有,最终形成了闭包。
变量遮蔽
内存作用域和外层作用域存在同名变量时,内层的变量会遮蔽外层变量,在查找时优先访问内层变量。
typescript
const x = 10; // 外层变量
function bar() {
const x = 20; // 内层变量,遮蔽外层x
console.log(x); // 输出:20(访问内层x)
}
bar();
console.log(x); // 输出:10(访问外层x)
模块化方案
ES Module 的核心就是模块级作用域:
-
每个模块都是一个独立的词法作用域,这意味着顶层的变量不会再自动挂载到 window 对象上了,模块内的变量/函数仅在模块内可以访问。
-
需要通过export 导出变量/函数,其他模块通过 import 导入。
这里有没有感觉很像闭包,必须将函数作用域的内容 return 后,才可以在外层作用域使用。
一些问题
变量提升
在代码执行前,JavaScript 引擎会将 var 声明的变量提升到作用域顶部,提升后的值为undefined,将函数声明提升到作用域顶部,值为函数本身。
值得注意的是,变量提升仅在当前作用域上生效,不会出现跨作用域提升的情况,如下:
typescript
console.log(a); // 输出:undefined(var声明的变量提升)
var a = 1;
function foo() {
console.log(b); // 输出:undefined(函数作用域内的变量提升)
var b = 2;
}
foo();
console.log(b); // 报错:b is not defined(b的提升仅在foo作用域内)
自 ES6 后,很少会在代码中再使用 var 关键字了,基本用的是 const/let 来声明变量,因为let/const声明的变量不会被提升,而是存在暂时性死区,即从作用域开始到变量声明前,访问该变量会报错。这是块级作用域的特性,避免了变量提升导致的逻辑混乱。
全局作用域污染
typescript
function badFunc() {
// 未声明直接赋值,隐式创建全局变量
unDeclaredVar = "我是污染的全局变量";
}
badFunc();
console.log(window.unDeclaredVar); // 输出:我是污染的全局变量
对于这种没有声明就直接赋值的写法,在非严格模式下,会隐式的创建一个全局变量,导致全局作用域被污染。
总结
理解了 JavaScript 的作用域和作用域链对我们理解闭包、模块化、高阶函数等特性能够有更好的帮助,也能让我们在实际开发中,更合理运用块级作用域(let/const)、模块化(ES Module),规避变量污染、逻辑混乱等问题,写出更健壮、可扩展的 JavaScript 代码。
作用域(Scope)与作用域链(Scope Chain)是 JavaScript 的核心概念,它们决定了变量的可访问范围、生命周期,以及代码运行时变量查找的规则,理解这两个概念,可以回答我们 "变量在这里为什么能访问","为什么这里的变量是 undefined" 等诸多疑问,还能帮助我们在开发过程中规避变量污染、提升代码的可维护性,对于 ES Module 等模块方案有更好的理解。
作用域的本质
很多开发者会把作用域简单理解为 "变量的存储位置",但这只说对了一半。作用域的核心是一套 "变量访问规则" ------ 它定义了在代码的哪个位置可以访问哪些变量,同时也隐含了变量的 "生存空间"(即变量何时被创建、何时被销毁)。