WinddSnow

JavaScript-Scope-and-Closures

字数统计: 3.5k阅读时长: 13 min
2026/07/30

第49课:作用域与闭包——词法环境、闭包原理、实战应用与陷阱

闭包是 JavaScript 最核心也最容易被误解的概念之一。它并非某种需要刻意创建的“特殊结构”,而是函数在词法环境中被定义时自然产生的内建特性。理解闭包的关键在于掌握作用域链词法环境的工作机制。本节课将带你从执行上下文和变量环境出发,深入到闭包的形成原理、内存影响、经典应用场景(模块化、函数工厂、部分应用),以及循环中闭包陷阱的多种解决方案。


1. 作用域与词法环境

1.1 三种作用域

JavaScript 中有三种作用域类型:

作用域类型 声明方式 范围
全局作用域 在任何函数或块之外声明 整个程序,<script> 或模块顶层。
函数作用域 var 声明的变量 整个函数体(无论嵌套多深,var 无视块)。
块级作用域 letconst 声明的变量 最近的 {} 块(iffor、裸块等)。
1
2
3
4
5
6
7
8
9
var a = 'global';
function test() {
var a = 'function';
if (true) {
var a = 'block'; // 同一个函数作用域,覆盖了外层的 a
}
console.log(a); // 'block'
}
test();

若将 var 换成 letif 块内部的 let a 只在块内有效,不影响外层。

1.2 词法环境(Lexical Environment)

根据 ECMAScript 规范,每次代码执行时引擎会创建一个执行上下文。每个执行上下文都有一个关联的词法环境,用于存储该作用域内的标识符绑定。

词法环境由两部分组成:

  • 环境记录:存储变量、函数声明的实际位置。
  • 对外部词法环境的引用(Outer):指向父级词法环境,形成作用域链
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let globalVar = 'I am global';

function outer() {
let outerVar = 'I am outer';

function inner() {
let innerVar = 'I am inner';
console.log(innerVar); // 在自己的环境记录中找到
console.log(outerVar); // 通过 outer 引用找到 outer 的环境记录
console.log(globalVar);// 通过 outer -> global 引用找到
}

inner();
}

outer();

inner 函数执行时,引擎先在 inner 自己的环境记录中查找 innerVar,找到即用。对于 outerVar,当前环境记录中不存在,便沿着 [[Outer]] 引用进入 outer 的环境记录查找。对于 globalVar 同理,逐层向上直到全局环境记录。这就是作用域链查找


2. 闭包的定义与形成条件

闭包(Closure)的精确定义:函数与其被创建时所在的词法环境的引用捆绑在一起,形成一个闭包。在 JavaScript 中,每当创建一个函数,该函数就会在其内部的 [[Environment]] 属性中保存对创建时所在的词法环境的引用。

2.1 闭包的形成条件

形成闭包需要满足两个条件:

  1. 有函数嵌套(内部函数引用外部函数的变量)。
  2. 内部函数被传递到其词法作用域之外(通过 return、赋值给外部变量、作为回调传递等),使得外部函数执行完毕后其词法环境仍被引用而无法被垃圾回收。
1
2
3
4
5
6
7
8
9
10
11
12
13
function createGreeter(greeting) {
// greeting 是 createGreeter 的局部变量
return function(name) {
// 内部函数引用 greeting,形成闭包
console.log(`${greeting}, ${name}!`);
};
}

const greetHello = createGreeter('Hello');
const greetHi = createGreeter('Hi');

greetHello('Alice'); // 'Hello, Alice!'
greetHi('Bob'); // 'Hi, Bob!'

createGreeter('Hello') 执行完毕后,其执行上下文从调用栈弹出。但返回的匿名函数仍持有 greeting = 'Hello' 所在词法环境的引用,因此该环境不会被垃圾回收。每个 createGreeter 调用都会创建独立的词法环境,所以 greetHellogreetHi 的闭包互不干扰。

2.2 闭包的内存视角

闭包使得外部函数的局部变量在外部函数返回后仍然存活。这带来了持久化状态的能力,但也意味着如果滥用闭包(如大量创建但不释放),会导致内存泄漏

1
2
3
4
5
6
7
8
9
function createHeavyArray() {
const largeData = new Array(1000000).fill('data');
return function getLength() {
return largeData.length;
};
}

const getLength = createHeavyArray();
// largeData 占据的内存不会被回收,因为 getLength 的闭包持有引用

何时需要手动释放:如果闭包引用的数据很大且不再需要,可将引用该闭包的变量设为 null

1
2
let getLength = createHeavyArray();
getLength = null; // 解除引用,largeData 可被垃圾回收

3. 闭包的实战应用

3.1 数据私有化与模块模式

在 ES6 模块出现之前,闭包是实现私有变量的经典手段(通过 IIFE 返回对象)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const counter = (function() {
let count = 0; // 私有变量,外部无法直接访问

return {
increment() { return ++count; },
decrement() { return --count; },
get value() { return count; } // getter 提供只读访问
};
})();

console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.value); // 2
// console.log(counter.count); // undefined —— 外部无法访问私有变量

现代 JavaScript 可以使用 ES2022 的私有字段 # 实现类似效果,但闭包模式在函数式编程和轻量级封装中仍然很常见。

3.2 函数工厂(生成定制函数)

闭包可以创建一系列功能相似但行为不同的函数,例如单位转换器:

1
2
3
4
5
6
7
8
9
10
11
function createConverter(fromUnit, toUnit, factor) {
return function(value) {
console.log(`${value} ${fromUnit} = ${value * factor} ${toUnit}`);
};
}

const inchToCm = createConverter('inch', 'cm', 2.54);
const mileToKm = createConverter('mile', 'km', 1.60934);

inchToCm(10); // '10 inch = 25.4 cm'
mileToKm(5); // '5 mile = 8.0467 km'

3.3 部分应用(Partial Application)

闭包可以将一个多参数函数逐步“填充”参数,生成新函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function multiply(a, b) {
return a * b;
}

function partial(fn, ...presetArgs) {
return function(...laterArgs) {
return fn(...presetArgs, ...laterArgs);
};
}

const double = partial(multiply, 2);
const triple = partial(multiply, 3);

console.log(double(5)); // 10
console.log(triple(5)); // 15

这是函数式编程中柯里化(Currying) 的简化版,闭包使得 presetArgs 在返回的函数中持续可用。


4. 循环中的闭包陷阱

当在循环中创建闭包(如事件监听、setTimeout 回调),如果使用 var 声明循环变量,所有闭包将共享同一个变量引用。

4.1 问题复现

1
2
3
4
5
for (var i = 1; i <= 3; i++) {
setTimeout(function() {
console.log(i); // 输出 4, 4, 4
}, i * 100);
}

var i 是函数作用域,循环结束后 i 变为 4。三个 setTimeout 回调共享同一个 i,当回调执行时,i 已经是 4。

4.2 解决方案一:使用 let

let 具有块级作用域,每次迭代都会创建一个新的绑定。

1
2
3
4
5
for (let i = 1; i <= 3; i++) {
setTimeout(function() {
console.log(i); // 1, 2, 3
}, i * 100);
}

4.3 解决方案二:使用 IIFE 捕获当前值

let 诞生之前,通过立即执行函数表达式(IIFE)捕获当前值:

1
2
3
4
5
6
7
for (var i = 1; i <= 3; i++) {
(function(j) {
setTimeout(function() {
console.log(j); // 1, 2, 3
}, j * 100);
})(i);
}

IIFE 创建了一个新的函数作用域,j 是每次迭代中 i 的副本。

4.4 解决方案三:利用 setTimeout 的第三个参数

setTimeout 可以从第三个参数开始向回调函数传递参数:

1
2
3
4
5
for (var i = 1; i <= 3; i++) {
setTimeout(function(j) {
console.log(j); // 1, 2, 3
}, i * 100, i);
}

5. 闭包与垃圾回收:避免内存泄漏

闭包本身不是内存泄漏。内存泄漏发生在不再需要的闭包仍然保持引用时。常见泄漏场景:

  • DOM 元素与闭包相互引用:在闭包中引用 DOM 元素,同时 DOM 元素通过事件监听器引用该闭包,形成循环引用(现代浏览器已能处理大部分情况,但仍需留意)。
  • 全局变量持有闭包引用:将闭包赋值给全局变量或模块顶层变量,且从未释放。
  • 定时器持有闭包setInterval 的回调若未被 clearInterval 清除,会持续持有其词法环境。
1
2
3
4
5
6
7
8
9
10
11
function setup() {
const largeData = new Array(1000000);
const element = document.getElementById('btn');

element.addEventListener('click', function() {
console.log(largeData.length);
});

// 如果 element 被从 DOM 中移除但没有清除事件监听器,
// 闭包中的 largeData 仍然不会被回收。
}

防护措施:在组件销毁(如 React useEffect 的清理函数、Vue onUnmounted)中移除事件监听器,清除定时器,解除闭包引用。


课后练习

一、概念自测(选择题 / 填空题)

  1. (单选) 以下关于闭包的描述,哪项是正确的?
    A. 闭包只能通过 return 创建。
    B. 闭包会造成内存泄漏,应避免使用。
    C. 闭包是函数及其所引用词法环境的组合,是 JavaScript 的天然特性。
    D. 箭头函数不会产生闭包。

  2. (单选) 以下代码的输出是?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function 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

  3. (填空) 要解决循环中 var 导致的闭包共享问题,可以使用 ______ 关键字声明循环变量,利用其块级作用域每次迭代创建新绑定。

  4. (多选) 以下哪些场景会形成闭包?
    A. 函数内部返回一个嵌套函数,且嵌套函数引用外部函数的变量。
    B. 将一个内部函数作为 DOM 事件监听器。
    C. 使用 setTimeout 传入一个回调函数,回调引用外部变量。
    D. 在函数内部直接调用另一个独立定义的函数。

二、AI 编程任务:编写面向 AI 的提示词

场景:你需要编写一个 JavaScript 函数 createUniqueIdGenerator(prefix),它返回一个生成唯一 ID 的函数。每次调用返回的生成函数时,应依次生成 prefix_1prefix_2prefix_3……计数器从 1 开始。要求如下:

  • 使用闭包存储计数器和前缀。
  • 返回的函数使用箭头函数语法。
  • 创建两个独立的生成器,验证它们互不干扰(计数器独立)。
  • 使用 JSDoc 注释说明参数和返回值。

任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 JavaScript 代码。提示词中需明确指定闭包的形成方式、箭头函数的使用、以及独立性的验证方式。

三、面试真题与参考答案

题目(字节跳动前端面试题):

请详细解释 JavaScript 中的闭包(Closure)是什么,以及它是如何形成的。闭包在实际开发中有哪些典型应用?同时,请说明闭包可能带来的内存问题以及如何避免。

参考答案

1. 定义与形成:闭包是指函数能够记住并访问其词法作用域(创建时所在的外部函数作用域),即使该函数在其词法作用域之外执行。形成原因是 JavaScript 函数在创建时会在内部 [[Environment]] 属性中保存对创建时词法环境的引用。当内部函数被传递到外部(返回、赋值、回调),外部函数执行完毕后,其词法环境因仍被引用而不会被垃圾回收,内部函数依然可以访问其中的变量。

2. 典型应用

  • 数据私有化(模块模式):通过 IIFE 返回对象暴露方法,内部变量不可被外部直接修改。
  • 函数工厂:根据参数生成不同行为的函数(如 createConverter)。
  • 部分应用与柯里化:预填充部分参数,返回接受剩余参数的新函数。
  • 事件处理与回调:在异步操作中保留对当时状态的引用。

3. 内存问题与规避:闭包会导致外部函数的变量在外部函数返回后仍然保留在内存中。如果这些变量引用了大量数据或 DOM 元素,且闭包本身未被释放(如全局变量持有、定时器未清除、事件监听器未移除),就会造成内存泄漏。避免方法:

  • 在不需要时解除闭包引用(将持有闭包的变量设为 null)。
  • 在组件销毁或页面卸载时清除定时器(clearInterval/clearTimeout)和移除事件监听器。
  • 使用现代框架的生命周期钩子(React 的 useEffect cleanup、Vue 的 onUnmounted)管理闭包生命周期。
  • 避免在闭包中引用不必要的庞大对象。

课后练习答案

一、概念自测答案

  1. C

    • 解析:闭包是 JavaScript 的天然特性,只要函数访问外部变量就可能形成。A 错误,传递闭包的方式不止 return;B 错误,合理使用不会造成泄漏;D 错误,箭头函数同样产生闭包。
  2. D

    • 解析:var i 是函数作用域,循环结束后 i 变为 3,所有闭包引用同一个 i
  3. let

    • 解析:let 声明的变量具有块级作用域,for 循环每次迭代会创建独立绑定,闭包各自捕获自己的值。
  4. A、B、C

    • 解析:A、B、C 均满足“内部函数引用外部变量并被传递到外部”的条件。D 只是普通函数调用,未传递内部函数,不形成闭包。

二、AI 编程任务参考答案(提示词示例)

示例提示词
“请编写一个 JavaScript 函数 createUniqueIdGenerator(prefix),它返回一个生成唯一 ID 的函数。要求:

  • 使用闭包保存 prefix 和内部计数器 count(初始值 1)。
  • 返回的函数使用箭头函数语法 () => ${prefix}_${count++}``,每次调用返回 prefix_1prefix_2 并自增计数器。
  • const generateUserId = createUniqueIdGenerator('user')const generateOrderId = createUniqueIdGenerator('order') 创建两个独立生成器,验证它们互不干扰。
  • 添加 JSDoc 注释:@param {string} prefix@returns {() => string}
  • 代码简洁,输出完整代码。”
CATALOG
  1. 1. 第49课:作用域与闭包——词法环境、闭包原理、实战应用与陷阱
    1. 1.1. 1. 作用域与词法环境
      1. 1.1.1. 1.1 三种作用域
      2. 1.1.2. 1.2 词法环境(Lexical Environment)
    2. 1.2. 2. 闭包的定义与形成条件
      1. 1.2.1. 2.1 闭包的形成条件
      2. 1.2.2. 2.2 闭包的内存视角
    3. 1.3. 3. 闭包的实战应用
      1. 1.3.1. 3.1 数据私有化与模块模式
      2. 1.3.2. 3.2 函数工厂(生成定制函数)
      3. 1.3.3. 3.3 部分应用(Partial Application)
    4. 1.4. 4. 循环中的闭包陷阱
      1. 1.4.1. 4.1 问题复现
      2. 1.4.2. 4.2 解决方案一:使用 let
      3. 1.4.3. 4.3 解决方案二:使用 IIFE 捕获当前值
      4. 1.4.4. 4.4 解决方案三:利用 setTimeout 的第三个参数
    5. 1.5. 5. 闭包与垃圾回收:避免内存泄漏
    6. 1.6. 课后练习
      1. 1.6.1. 一、概念自测(选择题 / 填空题)
      2. 1.6.2. 二、AI 编程任务:编写面向 AI 的提示词
      3. 1.6.3. 三、面试真题与参考答案
    7. 1.7. 课后练习答案
      1. 1.7.1. 一、概念自测答案
      2. 1.7.2. 二、AI 编程任务参考答案(提示词示例)