JS的作用域

文章目录

  • 一、为什么需要作用域?
  • [二、什么是 JS 作用域?](#二、什么是 JS 作用域?)
    • [2.1 什么是词法作用域和动态作用域?](#2.1 什么是词法作用域和动态作用域?)
      • [1. 词法作用域(Lexical Scpoe)](#1. 词法作用域(Lexical Scpoe))
      • [2. 动态作用域](#2. 动态作用域)
    • [2.2 JS 的作用域](#2.2 JS 的作用域)
    • [2.3 JS 作用域的分类](#2.3 JS 作用域的分类)
      • [1. 全局作用域](#1. 全局作用域)
      • [2. 模块作用域](#2. 模块作用域)
      • [3. 函数作用域](#3. 函数作用域)
      • [4. 块级作用域 (ES6 引入)](#4. 块级作用域 (ES6 引入))
      • [5. 对比](#5. 对比)
    • [2.4 JS 的作用域和词法作用域的关系](#2.4 JS 的作用域和词法作用域的关系)
    • [2.5 作用域链](#2.5 作用域链)
      • [1. 理解](#1. 理解)
      • [2. 它是如何形成的呢?](#2. 它是如何形成的呢?)
      • [3. 为什么需要作用域链?](#3. 为什么需要作用域链?)
      • [4. 总结](#4. 总结)
  • 三、闭包
      • [1. 什么是闭包?](#1. 什么是闭包?)
      • [2. 通过例子理解](#2. 通过例子理解)
      • [3. 总结](#3. 总结)

一、为什么需要作用域?

想象一下,如果在一个大型 JavaScript 项目中,所有变量都是全局变量会怎样?我觉得会有下面的问题:

  1. 不同文件/模块里可能不小心用到同名变量,导致冲突、覆盖;
  2. 没有边界,调试困难;
  3. 开发者需要记忆所有变量名字,增加心智负担。

所以,我的理解广泛来说就是限定变量的可见范围,避免命名冲突,实现数据隔离和封装,让代码更清晰、模块。

如果从设计者角度去思考的话,我觉得比较重要的原因是:

  1. JS(和大部分编程语言)需要通过作用域,让名字(标识符)和存储位置(内存)建立映射关系。
  2. 这样在编译或执行时,解释器/引擎能高效地定位、分配和管理内存。
  3. 同时保证封装性和可维护性,让复杂程序拆分成更小、互不干扰的模块。

所以,我认为作用域让变量只在需要的地方可见,防止冲突,让代码更清晰、可靠。 还能帮助编译器/解释器确定变量生命周期和存储位置的结构化机制。

二、什么是 JS 作用域?

在谈 JS 作用域之前,我们先来讨论一下词法作用域和动态作用域。

2.1 什么是词法作用域和动态作用域?

1. 词法作用域(Lexical Scpoe)

「Lexical」指的是词法(lexical analysis):在编译器前端,对源代码进行分词、语法分析的阶段。因此词法作用域也叫静态作用域(Static Scope):变量作用域在代码书写时就确定,不会被运行时的调用关系改变。

大多数现代语言(包括:JavaScript、C、C++、Java、Python、Go、Rust、TypeScript...)都是词法作用域语言。

关键点:

  1. 编译时就能确定:哪个名字属于哪个作用域。
  2. 执行时沿着「写在哪里」形成的作用域链找变量。

举例:

js 复制代码
let a = 1;

function foo() {
  console.log(a);
}

function bar() {
  let a = 2;
  foo();
}

bar(); // 输出 1

foo 定义时在全局,外层有 a = 1。执行时无论 foo 从哪里被调用(bar 调用也好),作用域链都不会变:foo 只会在定义处向外找 → 找到全局的 a。

2. 动态作用域

在早期或特殊的语言设计里(如 Lisp 某些方言、Perl 的 local、bash 脚本等),变量的作用域不是在编译时确定,而是在运行时,根据函数是从哪里被调用决定。所以叫「动态」:变量查找时不是根据写在哪里,而是看当前的调用栈。

关键点:

  1. 调用栈决定作用域。
  2. 执行时如果在当前函数没找到变量,就在调用者(而非定义时的外层作用域)中找。

还是上述的那个例子: ⚠️ JS 实际不支持动态作用域,但为了说明原理:

js 复制代码
let a = 1;

function foo() {
  console.log(a);
}

function bar() {
  let a = 2;
  foo();
}

bar(); // 输出 2

执行到 bar() 时,bar 调用 foo,foo 查找 a 会先去调用者 bar 的作用域 → 找到 a=2 → 输出 2。

了解完之后我们再看 JS 的作用域。

2.2 JS 的作用域

首先 JS 的作用域是词法作用域的一部分,再看 MDN 中对于 JS 作用域的解释。

根据 MDN 解释:

  • 作用域是指当前的执行上下文,在其中的值和表达式可以被访问。

通俗点说:作用域决定了程序的哪些部分可以 "看到" 和 "使用" 某个变量。

举个例子:

js 复制代码
let x = 10
function test() {
  let y = 20
  console.log(x) // 可以访问
  console.log(y) // 可以访问
}
test()
console.log(x) // 可以访问
console.log(y) // 报错:y is not define

y 只在函数内部可见,外部看不到。

2.3 JS 作用域的分类

JavaScript 中常见的四种作用域:

类型 简介 示例
全局作用域 脚本模式运行所有代码的默认作用域 var a = 1
模块作用域 模块模式中运行代码的作用域 export const c = 4
函数作用域 由函数创建的作用域 function foo() { let x = 2 }
块级作用域 用一对花括号(一个代码块)创建出来的作用域 { let b = 3 }

下面我们通过几个例子,更清楚感受一下各个作用域的实际效果。

1. 全局作用域

在脚本(或 HTML 的 <script>)里直接声明的变量,就属于全局作用域,全局可见。

js 复制代码
// 全局作用域 
const globalVar = 'I am global'
function sayHello() {
  console.log(globalVar) // 可以访问全局变量
}

sayHello(); // 输出:I am global
console.log(globalVar);  // 输出:I am global

globalVar 定义在最外层(文件最外层或 script 最外层),可以在整个文件或页面中访问到。

2. 模块作用域

当你用 export / import 或 .mjs 模块时,每个模块文件默认是私有作用域,文件内部声明的变量只有本文件能访问。

js 复制代码
// file: utils.js
const secret = 'hidden'
export const publicData = 'exported data'

// file: main.js
import { publicData } from './utils.js' 
console.log(publicData) // 输出:exported data
console.log(secret) // 报错:secret is not defined

secret 没有导出,只能在 utils.js 内使用,属于模块作用域。publicData 被 export 导出后才能在其他模块里访问。

3. 函数作用域

函数内部用var、let、const 定义的变量,只能在这个函数体内部访问。

js 复制代码
function greet() {
  let name = 'HopeBearer'
  console.log('Hello, ' + name)
}

greet() // 输出:Hello, HopeBearer
console.log(name) // 报错:name is not defined

name 只在 greet 函数里可见。函数作用域是最经典的作用域形式。

4. 块级作用域 (ES6 引入)

在 if、for、while、{} 块 内用 let 和 const 声明的变量,只在这个块内有效。

js 复制代码
if(true) {
  const message = 'inside block'
  console.log(message) // 输出:inside block
}
console.log(message) // 报错:message is not defined

块级作用域让你在小范围内声明变量,避免污染外层作用域。

注意:var 声明的变量没有块级作用域,只受函数作用域控制。

5. 对比

类型 关键词 可访问范围
全局作用域 无特殊关键词 全文件 / 页面
模块作用域 import/export 模块文件内部
函数作用域 var let const 函数体内部
块级作用域 let const 花括号内

2.4 JS 的作用域和词法作用域的关系

上面我们聊到 JS 的作用域是词法作用域的一部分,其实指的是: JS的作用域都是词法作用域体系的一部分。 简单来说,谁写在谁里面 -> 形成作用域链,执行时,JS 按照 词法结构(写在哪里)顺序查找变量,不会因为函数是从哪里被调用而改变作用域链。

2.5 作用域链

1. 理解

作用域链(Scope Chain)是 JavaScript 在运行时用来查找变量的一套机制和数据结构。

  • 本质上是一个链式结构 ,由当前执行上下文的变量对象(Variable Environment / Lexical Environment),以及外层(父级)的变量对象,一直串到全局作用域的变量对象。
  • 通过这条链,JS 引擎在需要解析变量名时,按顺序向外查找直到找到变量,或者到最外层(全局作用域)还没找到就报错。

2. 它是如何形成的呢?

作用域链不是写死的,而是根据代码的词法结构在函数定义时确定的。具体来说,就是:

当你在写一个函数时,这个函数「捕获」了它定义处的外层作用域(也就是词法作用域)。函数执行时,JS 引擎会根据这个结构把当前作用域对象放到链的最前面(顶端),外层作用域依次排在后面。

举个例子:

js 复制代码
let a = 10
function outer() {
  let b = 20
  function inner() {
    let c = 30
    console.log(a, b, c)
  }
  inner()
}
outer()

执行 inner 时的作用域链:

sql 复制代码
[
  inner 的作用域(包含 c),
  outer 的作用域(包含 b),
  全局作用域(包含 a)
]

当 console.log(a, b, c) 执行:

  1. 先在 inner 的作用域里找 a,找不到;
  2. 再去 outer 的作用域找,找不到;
  3. 最后到全局作用域找到 a=10;
  4. 同理,b 在 outer 找到,c 在 inner 找到。

3. 为什么需要作用域链?

JS 必须在运行时知道的,一个变量到底属于那个作用域。

有了作用域链:就能:

  1. 保证变量隔离(内外变量不会冲突)
  2. 支持闭包(内部函数能访问外层变量)
  3. 高效的查找变量(只需从当前开始,逐层向外找)

4. 总结

作用域链是 JavaScript 在执行时用来按词法结构顺序查找变量的一条链,它让内部作用域可以访问到外层作用域的变量,而不是反过来。

三、闭包

都说到这了,我们顺便了解一下闭包。

1. 什么是闭包?

MDN 解释:

  • 闭包是由捆绑起来(封闭的)的函数和函数周围状态(词法环境)的引用组合而成。换言之,闭包让函数能访问它的外部作用域。在 JavaScript 中,闭包会随着函数的创建而同时创建。

我觉得简单来说就是一个函数"记住了"它被创建时的外层作用域,即使这个函数在外层作用域已经结束后依然可以访问这些变量。

2. 通过例子理解

举个例子:

js 复制代码
function outer() {
  let count = 0
  return function inner() {
    count++
    console.log(count)
  }
}

const fn = outer()

fn() // 1
fn() // 2

执行流程:

  1. 调用 outer():
    • 创建一个新的作用域 S1。
    • 在 S1 的符号表中记录:counter -> 内存地址A(初始值0)
  2. outer 内部定义了 inner 函数:
    • inner 的作用域中捕获了外层作用域 S1。
    • 也就是 inner 会记住:当时 counter 在 S1 的符号表里,对应内存地址A。
  3. outer() 返回 inner 函数:
    • 外层函数 outer 执行结束,理论上作用域 S1 应该销毁。但是,返回的 inner 函数还引用着 S1 (通过作用域链),所以垃圾回收器不会销毁 S1,也不会释放内存地址 A。
  4. 后续调用fn:
    • fn 相当于调用 inner
    • inner 会在 S1 中找到 counter,进行自增。
    • 所以每次输出:1、2、3...

为什么变量不被回收?

我们先看一下 JS 的垃圾回收(GC)的可达性(Reachability):从根出发,只要能沿着引用链访问到的对象,就叫做可达(reachable),不可达(unreachable)对象就认为"没用了",可以被垃圾回收。

根(Root)一般是:

  1. 全局对象(比如 window / global
  2. 当前调用栈中的局部变量(活动记录)
  3. 活动的闭包函数引用的变量

在这个例子中, inner 函数还引用着 外层作用域 S1,S1中的变量 counter就还"活着"。所以可以出现1,2,3...这样的情况,一旦 fn 被销毁(比如赋值为 null),S1 就不再被引用,这是垃圾回收期就可以释放 S1 对应的内存,包括 counter

3. 总结

闭包让外层作用域中的变量保持活跃, 原因是内部函数把外层作用域放进了自己的作用域链里,所以变量依然可访问,不会被垃圾回收器回收。