深度解析 JavaScript 作用域与作用域链

概述

作用域(Scope)与作用域链(Scope Chain)是 JavaScript 的核心概念,它们决定了变量的可访问范围、生命周期,以及代码运行时变量查找的规则,理解这两个概念,可以回答我们 “变量在这里为什么能访问”,“为什么这里访问到的变量值是 undefined” 等诸多疑问,同时还能帮助我们在开发过程中规避变量污染、提升代码的可维护性,对于 ES Module 等模块方案也可以有更好的理解。

作用域类型

全局作用域(Global Scope)

全局作用域是最顶层的作用域,代码中未被任何函数或块级结构包裹的变量 / 函数,都属于全局作用域。

  • 生命周期:与程序运行周期一致,页面加载时创建,页面关闭时销毁;
  • 浏览器环境:全局作用域的变量会挂载到 window​ 对象上(Node.js 环境挂载到 global 对象);
  • 访问范围:代码中的任何位置都能访问;
// 全局变量:属于全局作用域
const globalVar = "我是全局变量";

function bar() {
  // 可访问全局变量
  console.log(globalVar); // 输出:我是全局变量
}

bar();
console.log(window.globalVar); // 浏览器环境输出:我是全局变量

函数作用域(Function Scope)

函数作用域是指变量 / 函数仅在定义它们的函数内部可访问,函数外部无法直接访问。

  • 生命周期:函数调用时创建,函数执行结束后销毁(闭包除外);
  • 访问范围:仅函数内部及嵌套的子函数可访问;
  • 核心特性:函数参数也属于函数作用域的变量。
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 声明的变量会绑定到块级作用域。

  • 生命周期:代码块执行时创建,执行结束后销毁;
  • 访问范围:仅块内部可访问;
  • 核心特性:不存在变量提升(或称为暂时性死区),避免变量泄露。
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 的作用域是​静态作用域​(通常也称为词法作用域), 静态作用域是在代码定义阶段而非运行阶段确定的,通俗的说就是,你把变量写在代码的哪里,它的作用域就在哪个范围内,举个例子:

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 ,变量对象,作用域链等信息,在每次调用函数时都会创建新的执行上下文。

作用域链

作用域链,顾名思义,就是一个链表,是​由当前作用域和外层作用域组成的链表,用于解析变量引用。当代码在某个作用域访问一个变量时,会从当前作用域出发逐级向外层作用域去寻找变量,举个例子:

// 全局作用域
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 变量时就是沿着作用域链逐级查找的。

链的构建过程

  1. 函数定义时:JavaScript 引擎会为函数关联一个 [[Scopes]] 内部属性,存储函数定义时所处的所有外层作用域;

  2. 函数调用时:创建该函数的执行上下文,此时作用域链会被初始化:

    • 链的第一个元素是当前执行上下文的变量对象(存储当前作用域的变量、函数);
    • 后续元素是函数 [[Scopes]]​ 属性中的外层作用域变量对象,按从内到外的顺序排列;
  3. 作用域链固化:作用域链在执行上下文创建时确定,后续不会因代码执行而改变。

以本节开始的代码为例:

  • inner​ 函数定义时,引擎会为其添加一个 [[Scopes]]​ 属性,其中包含:外层函数作用域(outer)、全局作用域;
  • inner 调用时,会创建执行上下文,该上下文的作用域链为:[inner 变量对象({innerVar: "内层变量"})→ outer 变量对象({outerVar: "外层变量"})→ 全局变量对象({globalVar: "全局变量"})],箭头表示链表的方向及连接。

变量查找规则

当访问一个变量时,JavaScript 引擎的查找步骤为:

  1. 从作用域链的第一个元素(当前作用域)开始查找
  2. 若找到变量,直接返回其值(或引用),停止查找
  3. 若未找到,继续查找作用域链的下一个元素(外层作用域)
  4. 依次类推,直到找到变量或遍历完整个作用域链
  5. 若遍历完所有作用域仍未找到,抛出 ReferenceError(变量未定义)、

需要注意的是,修改是直接作用到这个查找到的变量上的,比如:

const x = 1; // 全局变量

function foo() {
  x = 2; // 修改的是全局变量x,而非创建局部变量
  console.log(x); // 输出:2
}

foo();
console.log(x); // 输出:2(全局变量被修改)

在过去不默认声明严格模式的时候,我们在 foo 函数里面 x =2 会隐式的创建一个全局变量,在编码的时候是一个很大的坑,但是现在的项目基本都默认严格模式了,这种使用方式就会报错,提前为我们规避一些问题。

应用

闭包

闭包应该是作用域链的一个最典型、广泛的应用了,他是由函数和定义时的词法作用域组合成的,他允许函数在外部作用域执行时,依旧能够访问到该函数定义时的局部变量(函数作用域内的变量),举个例子:

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 作用域变量对象 → 全局变量对象]。

由于这样的链式关系和引用的持有,最终形成了闭包。

变量遮蔽

内存作用域和外层作用域存在同名变量时,内层的变量会遮蔽外层变量,在查找时优先访问内层变量。

const x = 10; // 外层变量

function bar() {
  const x = 20; // 内层变量,遮蔽外层x
  console.log(x); // 输出:20(访问内层x)
}

bar();
console.log(x); // 输出:10(访问外层x)

模块化方案

ES Module 的核心就是模块级作用域:

  1. 每个模块都是一个独立的词法作用域,这意味着顶层的变量不会再自动挂载到 window 对象上了,模块内的变量/函数仅在模块内可以访问。

  2. 需要通过export 导出变量/函数,其他模块通过 import 导入。

    这里有没有感觉很像闭包,必须将函数作用域的内容 return 后,才可以在外层作用域使用。

一些问题

变量提升

在代码执行前,JavaScript 引擎会将 var​ 声明的变量提升到作用域顶部,提升后的值为undefined,将函数声明提升到作用域顶部,值为函数本身。

值得注意的是,变量提升仅在当前作用域上生效,不会出现跨作用域提升的情况,如下:

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​声明的变量不会被提升,而是存在​暂时性死区,即从作用域开始到变量声明前,访问该变量会报错。这是块级作用域的特性,避免了变量提升导致的逻辑混乱。

全局作用域污染

function badFunc() {
  // 未声明直接赋值,隐式创建全局变量
  unDeclaredVar = "我是污染的全局变量";
}

badFunc();
console.log(window.unDeclaredVar); // 输出:我是污染的全局变量

对于这种没有声明就直接赋值的写法,在非严格模式下,会隐式的创建一个全局变量,导致全局作用域被污染。

总结

理解了 JavaScript 的作用域和作用域链对我们理解闭包、模块化、高阶函数等特性能够有更好的帮助,也能让我们在实际开发中,更合理运用块级作用域(let/const)、模块化(ES Module),规避变量污染、逻辑混乱等问题,写出更健壮、可扩展的 JavaScript 代码。

作用域(Scope)与作用域链(Scope Chain)是 JavaScript 的核心概念,它们决定了变量的可访问范围、生命周期,以及代码运行时变量查找的规则,理解这两个概念,可以回答我们 “变量在这里为什么能访问”,“为什么这里的变量是 undefined” 等诸多疑问,还能帮助我们在开发过程中规避变量污染、提升代码的可维护性,对于 ES Module 等模块方案有更好的理解。

作用域的本质

很多开发者会把作用域简单理解为 “变量的存储位置”,但这只说对了一半。作用域的核心是一套 “变量访问规则” —— 它定义了在代码的哪个位置可以访问哪些变量,同时也隐含了变量的 “生存空间”(即变量何时被创建、何时被销毁)。

posted @ 2025-11-21 13:56  Achieve前端实验室  阅读(66)  评论(0)    收藏  举报