第49课:作用域与闭包——词法环境、闭包原理、实战应用与陷阱
闭包是 JavaScript 最核心也最容易被误解的概念之一。它并非某种需要刻意创建的“特殊结构”,而是函数在词法环境中被定义时自然产生的内建特性。理解闭包的关键在于掌握作用域链和词法环境的工作机制。本节课将带你从执行上下文和变量环境出发,深入到闭包的形成原理、内存影响、经典应用场景(模块化、函数工厂、部分应用),以及循环中闭包陷阱的多种解决方案。
1. 作用域与词法环境
1.1 三种作用域
JavaScript 中有三种作用域类型:
| 作用域类型 | 声明方式 | 范围 |
|---|---|---|
| 全局作用域 | 在任何函数或块之外声明 | 整个程序,<script> 或模块顶层。 |
| 函数作用域 | var 声明的变量 |
整个函数体(无论嵌套多深,var 无视块)。 |
| 块级作用域 | let、const 声明的变量 |
最近的 {} 块(if、for、裸块等)。 |
1 | var a = 'global'; |
若将 var 换成 let,if 块内部的 let a 只在块内有效,不影响外层。
1.2 词法环境(Lexical Environment)
根据 ECMAScript 规范,每次代码执行时引擎会创建一个执行上下文。每个执行上下文都有一个关联的词法环境,用于存储该作用域内的标识符绑定。
词法环境由两部分组成:
- 环境记录:存储变量、函数声明的实际位置。
- 对外部词法环境的引用(Outer):指向父级词法环境,形成作用域链。
1 | let globalVar = 'I am global'; |
当 inner 函数执行时,引擎先在 inner 自己的环境记录中查找 innerVar,找到即用。对于 outerVar,当前环境记录中不存在,便沿着 [[Outer]] 引用进入 outer 的环境记录查找。对于 globalVar 同理,逐层向上直到全局环境记录。这就是作用域链查找。
2. 闭包的定义与形成条件
闭包(Closure)的精确定义:函数与其被创建时所在的词法环境的引用捆绑在一起,形成一个闭包。在 JavaScript 中,每当创建一个函数,该函数就会在其内部的 [[Environment]] 属性中保存对创建时所在的词法环境的引用。
2.1 闭包的形成条件
形成闭包需要满足两个条件:
- 有函数嵌套(内部函数引用外部函数的变量)。
- 内部函数被传递到其词法作用域之外(通过
return、赋值给外部变量、作为回调传递等),使得外部函数执行完毕后其词法环境仍被引用而无法被垃圾回收。
1 | function createGreeter(greeting) { |
createGreeter('Hello') 执行完毕后,其执行上下文从调用栈弹出。但返回的匿名函数仍持有 greeting = 'Hello' 所在词法环境的引用,因此该环境不会被垃圾回收。每个 createGreeter 调用都会创建独立的词法环境,所以 greetHello 和 greetHi 的闭包互不干扰。
2.2 闭包的内存视角
闭包使得外部函数的局部变量在外部函数返回后仍然存活。这带来了持久化状态的能力,但也意味着如果滥用闭包(如大量创建但不释放),会导致内存泄漏。
1 | function createHeavyArray() { |
何时需要手动释放:如果闭包引用的数据很大且不再需要,可将引用该闭包的变量设为 null。
1 | let getLength = createHeavyArray(); |
3. 闭包的实战应用
3.1 数据私有化与模块模式
在 ES6 模块出现之前,闭包是实现私有变量的经典手段(通过 IIFE 返回对象)。
1 | const counter = (function() { |
现代 JavaScript 可以使用 ES2022 的私有字段 # 实现类似效果,但闭包模式在函数式编程和轻量级封装中仍然很常见。
3.2 函数工厂(生成定制函数)
闭包可以创建一系列功能相似但行为不同的函数,例如单位转换器:
1 | function createConverter(fromUnit, toUnit, factor) { |
3.3 部分应用(Partial Application)
闭包可以将一个多参数函数逐步“填充”参数,生成新函数:
1 | function multiply(a, b) { |
这是函数式编程中柯里化(Currying) 的简化版,闭包使得 presetArgs 在返回的函数中持续可用。
4. 循环中的闭包陷阱
当在循环中创建闭包(如事件监听、setTimeout 回调),如果使用 var 声明循环变量,所有闭包将共享同一个变量引用。
4.1 问题复现
1 | for (var i = 1; i <= 3; i++) { |
var i 是函数作用域,循环结束后 i 变为 4。三个 setTimeout 回调共享同一个 i,当回调执行时,i 已经是 4。
4.2 解决方案一:使用 let
let 具有块级作用域,每次迭代都会创建一个新的绑定。
1 | for (let i = 1; i <= 3; i++) { |
4.3 解决方案二:使用 IIFE 捕获当前值
在 let 诞生之前,通过立即执行函数表达式(IIFE)捕获当前值:
1 | for (var i = 1; i <= 3; i++) { |
IIFE 创建了一个新的函数作用域,j 是每次迭代中 i 的副本。
4.4 解决方案三:利用 setTimeout 的第三个参数
setTimeout 可以从第三个参数开始向回调函数传递参数:
1 | for (var i = 1; i <= 3; i++) { |
5. 闭包与垃圾回收:避免内存泄漏
闭包本身不是内存泄漏。内存泄漏发生在不再需要的闭包仍然保持引用时。常见泄漏场景:
- DOM 元素与闭包相互引用:在闭包中引用 DOM 元素,同时 DOM 元素通过事件监听器引用该闭包,形成循环引用(现代浏览器已能处理大部分情况,但仍需留意)。
- 全局变量持有闭包引用:将闭包赋值给全局变量或模块顶层变量,且从未释放。
- 定时器持有闭包:
setInterval的回调若未被clearInterval清除,会持续持有其词法环境。
1 | function setup() { |
防护措施:在组件销毁(如 React useEffect 的清理函数、Vue onUnmounted)中移除事件监听器,清除定时器,解除闭包引用。
课后练习
一、概念自测(选择题 / 填空题)
(单选) 以下关于闭包的描述,哪项是正确的?
A. 闭包只能通过return创建。
B. 闭包会造成内存泄漏,应避免使用。
C. 闭包是函数及其所引用词法环境的组合,是 JavaScript 的天然特性。
D. 箭头函数不会产生闭包。(单选) 以下代码的输出是?
1
2
3
4
5
6
7
8
9function createFunctions() {
var result = [];
for (var i = 0; i < 3; i++) {
result.push(function() { return i; });
}
return result;
}
const funcs = createFunctions();
console.log(funcs[0]());A.
0
B.1
C.2
D.3(填空) 要解决循环中
var导致的闭包共享问题,可以使用______关键字声明循环变量,利用其块级作用域每次迭代创建新绑定。(多选) 以下哪些场景会形成闭包?
A. 函数内部返回一个嵌套函数,且嵌套函数引用外部函数的变量。
B. 将一个内部函数作为 DOM 事件监听器。
C. 使用setTimeout传入一个回调函数,回调引用外部变量。
D. 在函数内部直接调用另一个独立定义的函数。
二、AI 编程任务:编写面向 AI 的提示词
场景:你需要编写一个 JavaScript 函数 createUniqueIdGenerator(prefix),它返回一个生成唯一 ID 的函数。每次调用返回的生成函数时,应依次生成 prefix_1、prefix_2、prefix_3……计数器从 1 开始。要求如下:
- 使用闭包存储计数器和前缀。
- 返回的函数使用箭头函数语法。
- 创建两个独立的生成器,验证它们互不干扰(计数器独立)。
- 使用 JSDoc 注释说明参数和返回值。
任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 JavaScript 代码。提示词中需明确指定闭包的形成方式、箭头函数的使用、以及独立性的验证方式。
三、面试真题与参考答案
题目(字节跳动前端面试题):
请详细解释 JavaScript 中的闭包(Closure)是什么,以及它是如何形成的。闭包在实际开发中有哪些典型应用?同时,请说明闭包可能带来的内存问题以及如何避免。
参考答案:
1. 定义与形成:闭包是指函数能够记住并访问其词法作用域(创建时所在的外部函数作用域),即使该函数在其词法作用域之外执行。形成原因是 JavaScript 函数在创建时会在内部 [[Environment]] 属性中保存对创建时词法环境的引用。当内部函数被传递到外部(返回、赋值、回调),外部函数执行完毕后,其词法环境因仍被引用而不会被垃圾回收,内部函数依然可以访问其中的变量。
2. 典型应用:
- 数据私有化(模块模式):通过 IIFE 返回对象暴露方法,内部变量不可被外部直接修改。
- 函数工厂:根据参数生成不同行为的函数(如
createConverter)。 - 部分应用与柯里化:预填充部分参数,返回接受剩余参数的新函数。
- 事件处理与回调:在异步操作中保留对当时状态的引用。
3. 内存问题与规避:闭包会导致外部函数的变量在外部函数返回后仍然保留在内存中。如果这些变量引用了大量数据或 DOM 元素,且闭包本身未被释放(如全局变量持有、定时器未清除、事件监听器未移除),就会造成内存泄漏。避免方法:
- 在不需要时解除闭包引用(将持有闭包的变量设为
null)。 - 在组件销毁或页面卸载时清除定时器(
clearInterval/clearTimeout)和移除事件监听器。 - 使用现代框架的生命周期钩子(React 的
useEffectcleanup、Vue 的onUnmounted)管理闭包生命周期。 - 避免在闭包中引用不必要的庞大对象。
课后练习答案
一、概念自测答案
C
- 解析:闭包是 JavaScript 的天然特性,只要函数访问外部变量就可能形成。A 错误,传递闭包的方式不止
return;B 错误,合理使用不会造成泄漏;D 错误,箭头函数同样产生闭包。
- 解析:闭包是 JavaScript 的天然特性,只要函数访问外部变量就可能形成。A 错误,传递闭包的方式不止
D
- 解析:
var i是函数作用域,循环结束后i变为 3,所有闭包引用同一个i。
- 解析:
let- 解析:
let声明的变量具有块级作用域,for循环每次迭代会创建独立绑定,闭包各自捕获自己的值。
- 解析:
A、B、C
- 解析:A、B、C 均满足“内部函数引用外部变量并被传递到外部”的条件。D 只是普通函数调用,未传递内部函数,不形成闭包。
二、AI 编程任务参考答案(提示词示例)
示例提示词:
“请编写一个 JavaScript 函数createUniqueIdGenerator(prefix),它返回一个生成唯一 ID 的函数。要求:
- 使用闭包保存
prefix和内部计数器count(初始值 1)。- 返回的函数使用箭头函数语法
() =>${prefix}_${count++}``,每次调用返回prefix_1、prefix_2并自增计数器。- 用
const generateUserId = createUniqueIdGenerator('user')和const generateOrderId = createUniqueIdGenerator('order')创建两个独立生成器,验证它们互不干扰。- 添加 JSDoc 注释:
@param {string} prefix、@returns {() => string}。- 代码简洁,输出完整代码。”