0%

闭包

JavaScript 闭包详解:原理、特性与实战应用

闭包(Closure)是 JavaScript 中最核心且易混淆的特性之一,其本质是 “有权访问另一个函数作用域中变量的函数”。通过闭包,内部函数可以突破自身作用域的限制,访问甚至修改外部函数的变量,即使外部函数已经执行完毕。三个核心特性(引用外部变量、返回内部函数、更新外部变量),从 “定义与本质→核心特性→形成原理→实战场景→常见误区” 五个维度,彻底讲透闭包的底层逻辑与实际价值。

闭包的核心定义与本质

在理解特性前,先明确闭包的本质:
当一个函数(内部函数)被定义在另一个函数(外部函数)的作用域内,且内部函数被外部函数以外的地方引用时,就会形成闭包。
闭包的核心价值在于:延长外部函数中变量的生命周期(即使外部函数执行完毕,其变量仍能被内部函数访问),并创建私有变量(外部无法直接访问,只能通过内部函数间接操作)。

闭包的三大核心特性

特性 1:内部函数可引用外部函数的变量

闭包的基础能力是 “跨作用域访问”—— 内部函数可以访问外部函数作用域中的变量(包括参数和局部变量),这源于 JavaScript 的 “作用域链” 规则(函数执行时会优先查找自身作用域变量,找不到则向上查找外部作用域,直到全局作用域)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function make() {
// 外部函数的局部变量
var base = "base";

// 内部函数:定义在 make 作用域内
function appendWord(word) {
// 访问外部函数的变量 base(跨作用域访问,形成闭包的基础)
return base + " and " + word;
}

// 调用内部函数并返回结果
return appendWord("bi");
}

// 执行结果:"base and bi"
alert(make());
原理拆解:
  1. make() 执行时,会创建一个 “函数执行上下文”(包含 base 变量和 appendWord 函数);
  2. appendWord 函数定义时,会 “记住” 其外部作用域(make 的作用域),形成作用域链;
  3. 调用 appendWord("bi") 时,appendWord 先查找自身作用域的 word(找到,值为 “bi”),再向上查找 base(在 make 作用域找到,值为 “base”);
  4. 最终返回拼接结果,此时虽然形成了闭包的 “基础条件”(内部函数访问外部变量),但内部函数未被外部引用,make 执行完毕后,其作用域会被垃圾回收(base 变量销毁)。

特性 2:外部函数可返回内部函数,后续调用仍能访问外部变量

这是闭包最关键的特性 ——外部函数执行完毕后,其局部变量不会被销毁,因为返回的内部函数仍在引用这些变量。此时内部函数被外部引用(如赋值给全局变量),形成 “完整闭包”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function make() {
var base = "base"; // 外部函数的局部变量

// 内部函数:引用 base
function appendWord(word) {
return base + " and " + word;
}

// 不调用内部函数,而是返回其引用(关键:内部函数被外部持有)
return appendWord;
}

// 1. 执行 make():返回内部函数 appendWord,赋值给全局变量 func
var func = make();

// 2. 后续调用 func:仍能访问 make 中的 base 变量
console.log(func("bi")); // 结果:"base and bi"
console.log(func("bao")); // 结果:"base and bao"
原理拆解(闭包的核心机制):
  1. 执行 make() 时,创建 make 的执行上下文,定义 baseappendWord
  2. make() 返回 appendWord 函数的引用,赋值给全局变量 func—— 此时内部函数 appendWord 被外部(全局作用域)引用
  3. make() 执行完毕后,其执行上下文会从 “执行栈” 中弹出,但由于 appendWord 仍引用 base 变量,JavaScript 垃圾回收机制(GC)不会销毁 make 的作用域(base 变量得以保留);
  4. 后续调用 func("bi") 时,appendWord 执行,通过作用域链找到 make 作用域中的 base 变量,完成拼接 —— 这就是闭包 “延长变量生命周期” 的核心体现。

特性 3:闭包可更新外部函数变量的值

闭包不仅能 “读取” 外部变量,还能 “修改” 其值 —— 因为内部函数引用的是外部变量的内存地址,而非变量的副本。修改后的值会被保留,后续调用内部函数时能获取到更新后的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 外部函数:创建计数器
function createCounter() {
// 外部变量:计数器初始值(私有,外部无法直接访问)
let count = 0;

// 返回内部函数对象:包含“读取”和“修改”count 的方法
return {
// 方法1:增加 count(修改外部变量)
increment: function() {
count++; // 闭包修改外部变量
return count;
},
// 方法2:获取当前 count(读取外部变量)
getCount: function() {
return count;
}
};
}

// 1. 创建计数器实例(形成闭包,count 被保留)
const counter = createCounter();

// 2. 调用方法修改并读取 count
console.log(counter.increment()); // 结果:1(count 从 0 变为 1)
console.log(counter.increment()); // 结果:2(count 从 1 变为 2)
console.log(counter.getCount()); // 结果:2(读取更新后的值)

// 3. 再创建一个计数器实例(独立闭包,count 互不影响)
const counter2 = createCounter();
console.log(counter2.getCount()); // 结果:0(与 counter 的 count 无关)
关键结论:
  • 闭包修改的是外部变量的 “原值”,而非副本;
  • 每个闭包实例(如 countercounter2)的外部变量是独立的 —— 因为每次调用外部函数(createCounter())都会创建一个新的作用域,变量互不干扰(这是闭包实现 “私有变量” 的基础)。

闭包的形成条件与底层原理

要彻底理解闭包,需结合 JavaScript 的 “作用域” 和 “垃圾回收” 两大机制:

1. 闭包的形成条件(缺一不可)

  1. 函数嵌套:存在内部函数定义在外部函数作用域内;
  2. 跨作用域引用:内部函数引用了外部函数的变量(或参数);
  3. 外部引用内部函数:内部函数被外部函数以外的作用域引用(如返回给全局变量、作为参数传递给其他函数)。

2. 底层原理:作用域链与垃圾回收

(1)作用域链(Scope Chain)
  • 每个函数在定义时,会创建一个 “[[Scopes]]” 内部属性,记录其外部作用域的引用(形成作用域链的基础);
  • 函数执行时,会创建 “执行上下文”,其中的 “作用域链” 由 “自身作用域”+“[[Scopes]] 记录的外部作用域” 组成;
  • 闭包的内部函数执行时,作用域链会包含外部函数的作用域,因此能访问其变量。
(2)垃圾回收(Garbage Collection, GC)
  • JavaScript 会自动回收 “不再被引用” 的内存(如执行完毕且无外部引用的函数作用域);
  • 闭包的内部函数若被外部引用,其引用的外部函数作用域(及变量)会被标记为 “正在使用”,不会被 GC 回收 —— 这就是变量生命周期被延长的原因。

闭包的实战应用场景

闭包并非 “语法技巧”,而是解决实际问题的核心工具,常见应用场景如下:

1. 实现私有变量与模块化

JavaScript 没有原生的 “私有变量” 语法(ES6 类的 # 私有属性是后来新增的),闭包是早期实现私有变量的唯一方式 —— 外部无法直接访问变量,只能通过内部函数提供的 “接口” 操作。

示例:模块化工具函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 闭包实现“私有工具函数”的模块化
const mathUtils = (function() {
// 私有变量:外部无法直接访问
const PI = 3.1415926;

// 私有函数:仅内部使用
function validateNumber(num) {
return typeof num === "number" && !isNaN(num);
}

// 暴露公共接口(闭包:引用私有变量和函数)
return {
calculateCircleArea: function(radius) {
if (!validateNumber(radius)) return 0;
return PI * radius * radius; // 引用私有变量 PI
},
calculateCircleCircumference: function(radius) {
if (!validateNumber(radius)) return 0;
return 2 * PI * radius;
}
};
})();

// 外部只能通过公共接口访问,无法直接访问 PI 或 validateNumber
console.log(mathUtils.calculateCircleArea(5)); // 结果:78.539815
console.log(mathUtils.PI); // 结果:undefined(私有变量无法访问)

2. 防抖(Debounce)与节流(Throttle)

闭包常用于 “记忆函数执行状态”,实现防抖(多次触发只执行最后一次)和节流(固定时间内只执行一次),避免频繁触发事件(如滚动、输入)导致性能问题。

示例:输入框防抖(搜索联想场景)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 防抖函数:利用闭包记忆定时器 ID
function debounce(fn, delay) {
let timerId = null; // 闭包变量:记忆定时器 ID

// 内部函数:实际执行的函数
return function(...args) {
// 每次触发时,清除之前的定时器
clearTimeout(timerId);

// 重新设置定时器,延迟 delay 后执行 fn
timerId = setTimeout(() => {
fn.apply(this, args); // 绑定 this 指向(如输入框元素)
}, delay);
};
}

// 实际业务函数:模拟搜索请求
function search(keyword) {
console.log("搜索关键词:", keyword);
// 发送 AJAX 请求...
}

// 给输入框绑定防抖事件
const input = document.querySelector("input");
input.addEventListener("input", debounce(search, 500));

原理:闭包变量 timerId 记录定时器 ID,每次输入触发时清除旧定时器,重新计时 —— 确保只有停止输入 500ms 后,才执行搜索,减少请求次数。

3. 函数工厂(Function Factory)

通过闭包动态生成具有 “特定上下文” 的函数,每个生成的函数持有独立的外部变量。

示例:生成带前缀的日志函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 函数工厂:生成带固定前缀的日志函数
function createLogger(prefix) {
// 外部变量:日志前缀(每个生成的函数持有独立前缀)
return function(message) {
const time = new Date().toLocaleTimeString();
console.log(`[${time}] [${prefix}] ${message}`);
};
}

// 生成不同前缀的日志函数
const errorLogger = createLogger("ERROR");
const infoLogger = createLogger("INFO");

// 调用时,各自的前缀被保留(闭包作用)
errorLogger("服务器连接失败"); // 结果:[14:30:00] [ERROR] 服务器连接失败
infoLogger("用户登录成功"); // 结果:[14:30:05] [INFO] 用户登录成功

闭包的常见误区与注意事项

误区 1:闭包会导致内存泄漏

真相:合理使用的闭包不会导致内存泄漏,只有 “不必要的闭包引用” 才会(如全局变量长期持有内部函数,且不再使用)。
解决方案:不再使用闭包时,主动解除外部引用(如 func = null),让 GC 能回收外部函数作用域。

1
2
3
let func = make(); // 持有闭包引用
// 使用完毕后,主动解除引用
func = null; // 此时 make 的作用域会被 GC 回收

误区 2:循环中的闭包会导致变量共享问题

问题示例(经典循环闭包陷阱):

1
2
3
4
5
6
// 错误示例:循环中创建闭包,所有函数共享同一个 i
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 期望输出 0,1,2,实际输出 3,3,3
}, 1000);
}

原因var i 是全局变量(函数作用域),循环中创建的闭包都引用同一个 i,1 秒后执行时 i 已变成 3。
解决方案

  • let 替代 varlet 是块级作用域,每次循环创建新的 i);
  • 用立即执行函数(IIFE)创建独立作用域。
1
2
3
4
5
6
// 正确示例:用 let 实现块级作用域
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 输出:0,1,2
}, 1000);
}

注意:闭包中的 this 指向问题

闭包中的 this 不指向外部函数的 this,而是默认指向全局对象(浏览器中是 window,Node.js 中是 global),需注意绑定 this 指向。

示例与解决方案:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const obj = {
name: "Alice",
getNameFunc: function() {
// 外部函数的 this 指向 obj
return function() {
// 闭包中的 this 默认指向 window(非严格模式)
console.log(this.name); // 结果:undefined(window 没有 name 属性)
};
}
};

const getName = obj.getNameFunc();
getName(); // 输出:undefined

// 解决方案1:用变量保存外部 this(that/self)
obj.getNameFunc = function() {
const that = this; // 保存外部 this(指向 obj)
return function() {
console.log(that.name); // 结果:"Alice"
};
};

// 解决方案2:用箭头函数(箭头函数无自身 this,继承外部 this)
obj.getNameFunc = function() {
return () => {
console.log(this.name); // 结果:"Alice"(继承外部 this)
};
};

总结

闭包是 JavaScript 语言的 “灵魂特性”,其核心总结如下:

  1. 本质:内部函数引用外部函数变量,且被外部引用,形成跨作用域的变量访问机制;
  2. 核心能力:延长变量生命周期、创建私有变量、绑定函数上下文;
  3. 关键应用:模块化、防抖节流、函数工厂、状态记忆;
  4. 使用原则:避免不必要的全局引用(防止内存泄漏),注意 this 指向和作用域问题

欢迎关注我的其它发布渠道