文章目录
- 一、为什么需要作用域?
- 二、什么是 JS 作用域?
- 2.1 什么是词法作用域和动态作用域?
- 1. 词法作用域(Lexical Scpoe)
- 2. 动态作用域
- 2.2 JS 的作用域
- 2.3 JS 作用域的分类
- 1. 全局作用域
- 2. 模块作用域
- 3. 函数作用域
- 4. 块级作用域 (ES6 引入)
- 5. 对比
- 2.4 JS 的作用域和词法作用域的关系
- 2.5 作用域链
- 1. 理解
- 2. 它是如何形成的呢?
- 3. 为什么需要作用域链?
- 4. 总结
- 三、闭包
- 1. 什么是闭包?
- 2. 通过例子理解
- 3. 总结
一、为什么需要作用域?
想象一下,如果在一个大型 JavaScript 项目中,所有变量都是全局变量会怎样?我觉得会有下面的问题:
- 不同文件/模块里可能不小心用到同名变量,导致冲突、覆盖;
- 没有边界,调试困难;
- 开发者需要记忆所有变量名字,增加心智负担。
所以,我的理解广泛来说就是限定变量的可见范围,避免命名冲突,实现数据隔离和封装,让代码更清晰、模块。
如果从设计者角度去思考的话,我觉得比较重要的原因是:
- JS(和大部分编程语言)需要通过作用域,让名字(标识符)和存储位置(内存)建立映射关系。
- 这样在编译或执行时,解释器/引擎能高效地定位、分配和管理内存。
- 同时保证封装性和可维护性,让复杂程序拆分成更小、互不干扰的模块。
所以,我认为作用域让变量只在需要的地方可见,防止冲突,让代码更清晰、可靠。 还能帮助编译器/解释器确定变量生命周期和存储位置的结构化机制。
二、什么是 JS 作用域?
在谈 JS 作用域之前,我们先来讨论一下词法作用域和动态作用域。
2.1 什么是词法作用域和动态作用域?
1. 词法作用域(Lexical Scpoe)
「Lexical」指的是词法(lexical analysis):在编译器前端,对源代码进行分词、语法分析的阶段。因此词法作用域也叫静态作用域(Static Scope):变量作用域在代码书写时就确定,不会被运行时的调用关系改变。
大多数现代语言(包括:JavaScript、C、C++、Java、Python、Go、Rust、TypeScript…)都是词法作用域语言。
关键点:
- 编译时就能确定:哪个名字属于哪个作用域。
- 执行时沿着「写在哪里」形成的作用域链找变量。
举例:
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 脚本等),变量的作用域不是在编译时确定,而是在运行时,根据函数是从哪里被调用决定。所以叫「动态」:变量查找时不是根据写在哪里,而是看当前的调用栈。
关键点:
- 调用栈决定作用域。
- 执行时如果在当前函数没找到变量,就在调用者(而非定义时的外层作用域)中找。
还是上述的那个例子: ⚠️ 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 解释:
- 作用域是指当前的执行上下文,在其中的值和表达式可以被访问。
通俗点说:作用域决定了程序的哪些部分可以 “看到” 和 “使用” 某个变量。
举个例子:
let x = 10
function test() {let y = 20console.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>
)里直接声明的变量,就属于全局作用域,全局可见。
// 全局作用域
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 模块时,每个模块文件默认是私有作用域,文件内部声明的变量只有本文件能访问。
// 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 定义的变量,只能在这个函数体内部访问。
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 声明的变量,只在这个块内有效。
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 引擎会根据这个结构把当前作用域对象放到链的最前面(顶端),外层作用域依次排在后面。
举个例子:
let a = 10
function outer() {let b = 20function inner() {let c = 30console.log(a, b, c)}inner()
}
outer()
执行 inner 时的作用域链:
[inner 的作用域(包含 c),outer 的作用域(包含 b),全局作用域(包含 a)
]
当 console.log(a, b, c) 执行:
- 先在 inner 的作用域里找 a,找不到;
- 再去 outer 的作用域找,找不到;
- 最后到全局作用域找到 a=10;
- 同理,b 在 outer 找到,c 在 inner 找到。
3. 为什么需要作用域链?
JS 必须在运行时知道的,一个变量到底属于那个作用域。
有了作用域链:就能:
- 保证变量隔离(内外变量不会冲突)
- 支持闭包(内部函数能访问外层变量)
- 高效的查找变量(只需从当前开始,逐层向外找)
4. 总结
作用域链是 JavaScript 在执行时用来按词法结构顺序查找变量的一条链,它让内部作用域可以访问到外层作用域的变量,而不是反过来。
三、闭包
都说到这了,我们顺便了解一下闭包。
1. 什么是闭包?
MDN 解释:
- 闭包是由捆绑起来(封闭的)的函数和函数周围状态(词法环境)的引用组合而成。换言之,闭包让函数能访问它的外部作用域。在 JavaScript 中,闭包会随着函数的创建而同时创建。
我觉得简单来说就是一个函数“记住了”它被创建时的外层作用域,即使这个函数在外层作用域已经结束后依然可以访问这些变量。
2. 通过例子理解
举个例子:
function outer() {let count = 0return function inner() {count++console.log(count)}
}const fn = outer()fn() // 1
fn() // 2
执行流程:
- 调用
outer()
:- 创建一个新的作用域 S1。
- 在 S1 的符号表中记录:
counter -> 内存地址A(初始值0)
。
- 在
outer
内部定义了inner
函数:inner
的作用域中捕获了外层作用域 S1。- 也就是
inner
会记住:当时counter
在 S1 的符号表里,对应内存地址A。
outer()
返回inner
函数:- 外层函数
outer
执行结束,理论上作用域 S1 应该销毁。但是,返回的inner
函数还引用着 S1 (通过作用域链),所以垃圾回收器不会销毁 S1,也不会释放内存地址 A。
- 外层函数
- 后续调用
fn
:fn
相当于调用inner
inner
会在 S1 中找到counter
,进行自增。- 所以每次输出:1、2、3…
为什么变量不被回收?
我们先看一下 JS 的垃圾回收(GC)的可达性(Reachability):从根出发,只要能沿着引用链访问到的对象,就叫做可达(reachable),不可达(unreachable)对象就认为“没用了”,可以被垃圾回收。
根(Root)一般是:
- 全局对象(比如
window / global
) - 当前调用栈中的局部变量(活动记录)
- 活动的闭包函数引用的变量
在这个例子中, inner
函数还引用着 外层作用域 S1,S1中的变量 counter
就还"活着"。所以可以出现1,2,3…这样的情况,一旦 fn 被销毁(比如赋值为 null),S1 就不再被引用,这是垃圾回收期就可以释放 S1 对应的内存,包括 counter
。
3. 总结
闭包让外层作用域中的变量保持活跃, 原因是内部函数把外层作用域放进了自己的作用域链里,所以变量依然可访问,不会被垃圾回收器回收。