JavaScript 作用域与闭包(Scope & Closures)

JavaScript 核心底层概念:作用域、闭包、var/let/const、词法作用域、作用域链、暂时性死区、闭包实战与陷阱的完整权威讲解。
一、开篇:两个核心问题
为什么有些变量随处可访问,有些却 “消失不见”?为什么函数在父函数执行完毕后,还能 “记住” 父函数里的变量?
function createCounter() {
let count = 0 // 被封闭的变量
return function() {
count++
return count
}
}
const counter = createCounter()
counter() // 1
counter() // 2 —— 它记住了!
答案就是:作用域(Scope) 和 闭包(Closure)。
- 作用域:决定变量在哪里可见
- 闭包:让函数记住诞生时的环境
二、本文你将学到
- 3 种作用域:全局、函数、块级
var/let/const核心区别- 词法作用域、作用域链
- 闭包原理与用途
- 数据私有化、工厂函数、记忆化(memoization)
- 经典闭包陷阱与避坑方法
前置知识:调用栈(call stack)。
三、什么是作用域?
作用域 = 变量的可见范围。它是一套规则,决定变量在哪里能被访问、不能被访问。
作用域可以嵌套:内层能访问外层,外层绝不能访问内层。
办公楼类比(最易懂)
- 大厅 = 全局作用域
- 走廊 = 函数作用域
- 私人办公室 = 块级作用域
你在私人办公室里:能看到自己桌上的文件、走廊、大厅。但大厅里的人看不到你办公室内部。
这就是 JS 作用域的规则:内层向外看,外层不能向内看。
四、作用域的三大意义
-
防止命名冲突不同作用域可以用同名变量。
-
内存管理作用域结束,变量可被垃圾回收。
-
封装与安全隐藏内部实现,防止意外修改。
五、JS 的三种作用域
1. 全局作用域
不在任何函数 / 块里的变量,任何地方都能访问。
const appName = "MyApp" // 全局
function fn() { console.log(appName) }
⚠️ 尽量少用全局变量,避免全局污染。
2. 函数作用域
var 声明的变量只在函数内有效。
function fn() {
var a = 1 // 函数作用域
}
console.log(a) // 报错
3. 块级作用域
let / const 在 {} 内有效(if、for、while)。
if (true) {
let a = 1
}
console.log(a) // 报错
六、var /let/const 终极对比
TDZ 是 Temporal Dead Zone(暂时性死区) 的缩写
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数级 | 块级 | 块级 |
| 变量提升 | 提升并赋值 undefined | 提升但 TDZ | 提升但 TDZ |
| 重复声明 | 允许 | 报错 | 报错 |
| 重新赋值 | 允许 | 允许 | 不允许 |
| 必须初始化 | 否 | 否 | 是 |
现代最佳实践
- 默认用 const
- 需要变用 let
- 永远不用 var
七、变量提升与暂时性死区(TDZ)
变量提升
var 会被提升到顶部,值为 undefined。
console.log(a) // undefined
var a = 1
暂时性死区(TDZ)
let/const 提升了,但不能访问,直到声明行。
console.log(name) // 报错!
let name = "Alice"
这段不能访问的区域就叫 暂时性死区。
八、词法作用域(Lexical Scope)
词法作用域 = 静态作用域变量的作用域由代码写在哪决定,不是运行时决定。
内层函数永远能访问外层函数的变量。
function outer() {
let a = 1
function inner() {
console.log(a) // 能访问
}
}
九、作用域链(Scope Chain)
JS 查找变量的顺序:
- 当前作用域
- 父作用域
- 向上一直找…
- 全局作用域
- 找不到 → 报错
十、变量遮蔽(Variable Shadowing)
内层同名变量会 “遮住” 外层变量。
const name = "global"
function fn() {
const name = "function"
console.log(name) // function
}
十一、什么是闭包(Closure)?
闭包 = 函数 + 它诞生时的词法环境
即使外层函数执行完毕,内部函数依然可以访问并保留外层函数的变量。
简单说:函数记着它 “老家” 的变量,永远忘不了。
闭包执行步骤
- 调用外部函数
- 创建内部函数
- 内部函数记住词法环境
- 外部函数结束
- 内部函数依旧能访问外部变量
十二、闭包四大实战用途
1. 数据私有化(封装)
真正的私有变量,无法从外部篡改。
function createCounter() {
let count = 0 // 私有
return {
inc: () => count++,
get: () => count
}
}
2. 函数工厂
批量生成带不同配置的函数。
function createMultiplier(m) {
return n => n * m
}
const double = createMultiplier(2)
double(5) // 10
3. 回调与事件保存状态
function setupBtn() {
let count = 0
btn.onclick = () => {
count++
}
}
4. 记忆化(缓存计算结果)
function memoize(fn) {
const cache = {}
return arg => {
if (cache[arg]) return cache[arg]
cache[arg] = fn(arg)
return cache[arg]
}
}
十三、经典闭包面试题(必看)
问题
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100)
}
// 输出:3、3、3
原因
var 是函数级作用域,共用同一个 i。等定时器执行时,i 已经变成 3。
三种正确解法
- let(最简单)
- IIFE
- forEach
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100)
}
// 输出 0、1、2
十四、闭包可能造成内存泄漏
闭包会保留对外层变量的引用,导致无法被垃圾回收。解决方法:
- 不需要时清空引用
- 及时 removeEventListener
十五、核心总结
- 作用域:变量可见范围
- 3 种作用域:全局、函数、块级
- let/const:块级、有 TDZ、不污染
- var:函数级、易出错、别用
- 词法作用域:代码位置决定访问权
- 闭包:函数记住诞生环境
- 闭包用途:私有化、工厂、缓存、状态保存
- 经典坑:for 循环用 var → 换成 let
常见误区与陷阱(完整原文翻译)
理解作用域与闭包,也意味着要知道哪里最容易出错。这些错误甚至会难倒经验丰富的开发者。
闭包界 #1 面试题
这是最经典的闭包陷阱,几乎所有人第一次都会答错。
问题:
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
大部分人以为输出:0, 1, 2真实输出:3, 3, 3
为什么会这样?
var i是函数作用域,整个循环只有 一个 i。- 循环瞬间执行完,
i已经变成3。 - 三个定时器回调共享同一个 i。
- 1 秒后执行时,它们读到的都是最终值
3。
三种解决方案
方案 1:使用 let(现代最佳)let 是块级作用域,每次循环都会创建一个新 i。
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 1000);
}
// 输出:0,1,2
方案 2:使用 IIFE(老式方法)
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(() => console.log(j), 1000);
})(i);
}
方案 3:使用 forEach
[0,1,2].forEach(i => {
setTimeout(() => console.log(i), 1000);
});
闭包导致的内存泄漏
闭包非常强大,但也要负责任地使用。因为闭包会持有外部作用域变量的引用,这些变量无法被垃圾回收。
例子:
function createHeavyClosure() {
const hugeData = new Array(1000000);
return function() {
console.log(hugeData.length);
};
}
const leakyFn = createHeavyClosure();
hugeData 会一直留在内存中,因为闭包一直引用它。
如何避免内存泄漏
- 不再使用时,解除引用
- 及时移除事件监听
removeEventListener - 不要在闭包中捕获不必要的大型对象
cleanup(); // 手动解除引用,让GC可以回收
最佳实践
- 默认使用 const,变化使用 let,永远不要用 var
- 最小化作用域,把变量写在离使用最近的地方
- 避免不必要的全局变量
- 避免变量遮蔽(容易引发 BUG)
- 闭包中只捕获需要的变量
- 及时清理事件监听与定时器
- 循环异步优先使用
let,绝对不要用var
核心要点总结
- Scope = 变量的可见范围
- 三种作用域:全局、函数、块级
- var → 函数作用域;let/const → 块级作用域
- 词法作用域:由代码位置决定,与调用位置无关
- 作用域链:由内向外查找变量
- TDZ(暂时性死区):let/const 声明前不可访问
- 闭包 = 函数 + 词法环境
- 闭包让函数永远记得诞生时的作用域
- 闭包用途:私有变量、工厂函数、状态保存、记忆化缓存
- 循环陷阱:
var共享变量 → 换成let立即解决 - 闭包可能造成内存泄漏,必须及时清理
知识自测
Q1:JavaScript 中有哪三种作用域?
全局作用域、函数作用域、块级作用域。
Q2:什么是暂时性死区 TDZ?
指 let/const 从进入作用域到声明行之间,无法被访问的区域。访问会报错。
Q3:什么是词法作用域?
作用域由代码书写位置决定,而非运行时调用位置。内层可以访问外层。
Q4:什么是闭包?
函数与其词法环境的组合。函数即使在外部执行,依然能访问定义时所在的作用域。
Q5:下面代码输出什么?为什么?
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
输出:3,3,3因为 var 是函数作用域,共用一个 i。
Q6:实际开发中闭包用于哪些场景?
- 数据私有化
- 工厂函数
- 事件回调 / 异步状态保存
- 记忆化缓存(memoization)
常见问题 FAQ
Q:作用域和闭包的区别?
作用域是规则:变量在哪里可访问。闭包是现象:函数能记住并访问定义时的作用域。
Q:为什么要用 let/const,不要用 var?
let/const是块级作用域- 不存在变量污染
- 有暂时性死区,减少错误
const明确不可变
Q:闭包如何工作?
函数在定义时捕获词法环境,即使外部函数执行完毕,内部函数依然持有该环境引用。
Q:闭包会导致内存泄漏吗?
会,如果闭包长期持有不必要的大对象引用,就会产生内存泄漏。
Q:JS 是词法作用域还是动态作用域?
JS 是词法作用域(静态作用域)。
🧩 概念汇总
1. 核心概念
- Scope(作用域)同类:变量可见性、命名空间、上下文、访问范围
- Closure(闭包)同类:词法环境捕获、状态保留、函数携带作用域
- Lexical Scope(词法作用域)同类:静态作用域、书写位置决定作用域
- Scope Chain(作用域链)同类:变量查找路径、由内向外查找
2. 作用域类型
- Global Scope(全局作用域)同类:顶层作用域、全局变量
- Function Scope(函数作用域)同类:函数级可见性、var 作用域
- Block Scope(块级作用域)同类:{} 作用域、let/const 作用域
- Module Scope(模块作用域)同类:ESM 文件级作用域、文件隔离
3. 变量声明相关
- var同类:老式声明、函数级、提升、可重复声明
- let同类:块级、可变、不可重复、TDZ
- const同类:块级、不可重赋值、引用可变、TDZ
- Hoisting(变量提升)同类:声明提前、预解析
- Temporal Dead Zone(TDZ 暂时性死区)同类:声明前不可访问、安全检查
4. 闭包相关机制
- Lexical Environment(词法环境)同类:作用域记录、变量环境
- Variable Shadowing(变量遮蔽)同类:同名覆盖、内层覆盖外层
- Encapsulation(封装)同类:数据私有化、信息隐藏
- Memoization(记忆化)同类:缓存计算结果、优化性能
- Function Factory(函数工厂)同类:批量生成函数、配置化函数
5. 内存与垃圾回收
- Garbage Collection(GC 垃圾回收)同类:自动内存管理、释放无用变量
- Memory Leak(内存泄漏)同类:无用对象残留、闭包引用未释放
6. 经典问题与模式
- IIFE(立即执行函数)同类:老式模块化、创建私有作用域
- Loop Closure Bug(循环闭包 BUG)同类:var 共享变量、异步延迟问题
更多推荐

所有评论(0)