深入理解 JavaScript 闭包:从原理到实战
本文将带你彻底搞懂闭包,包含底层原理、实际应用场景以及高频面试题解析。
什么是闭包
闭包(Closure)是 JavaScript 中最重要但也最容易让人困惑的概念之一。简单来说,闭包是指函数能够记住并访问它的词法作用域,即使这个函数在当前作用域之外执行。
先看一个经典例子:
function makeCounter() {
let count = 0
return function() {
return ++count
}
}
const counter = makeCounter()
console.log(counter()) // 1
console.log(counter()) // 2
console.log(counter()) // 3
这里的 counter 就是一个闭包。它记住了 makeCounter 函数内部的 count 变量,即使 makeCounter 已经执行完毕。
闭包的底层原理
要理解闭包,必须先理解 JavaScript 的作用域链和垃圾回收机制。
词法作用域
JavaScript 采用词法作用域(静态作用域),函数的作用域在定义时就确定了:
const value = 'global'
function outer() {
const value = 'local'
function inner() {
console.log(value) // 'local' - 定义时的作用域
}
return inner
}
const fn = outer()
fn() // 输出 'local',而不是 'global'
为什么变量没有被回收?
正常情况下,函数执行完毕后,其内部变量会被垃圾回收器释放。但闭包的情况不同:
function createHugeArray() {
const hugeArray = new Array(1000000).fill('x') // 占用大量内存
return function() {
console.log(hugeArray.length)
}
}
const closure = createHugeArray()
// createHugeArray 执行完毕,但 hugeArray 没有被释放!
因为返回的函数仍然引用着 hugeArray,所以垃圾回收器不会回收这块内存。这就是闭包保持对外部变量引用的本质。
闭包的实际应用场景
1. 数据私有化(模拟私有变量)
JavaScript 没有原生私有属性,闭包可以实现类似效果:
function createPerson(name) {
// _age 是私有变量,外部无法直接访问
let _age = 0
return {
getName: () => name,
getAge: () => _age,
setAge: (age) => {
if (age >= 0 && age <= 150) {
_age = age
}
}
}
}
const person = createPerson('张三')
person.setAge(25)
console.log(person.getAge()) // 25
console.log(person._age) // undefined,无法直接访问
2. 函数柯里化
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args)
} else {
return function(...args2) {
return curried.apply(this, args.concat(args2))
}
}
}
}
// 使用示例
function sum(a, b, c) {
return a + b + c
}
const curriedSum = curry(sum)
console.log(curriedSum(1)(2)(3)) // 6
console.log(curriedSum(1, 2)(3)) // 6
3. 防抖与节流
// 防抖
function debounce(fn, delay) {
let timer = null
return function(...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
// 节流
function throttle(fn, interval) {
let lastTime = 0
return function(...args) {
const now = Date.now()
if (now - lastTime >= interval) {
lastTime = now
fn.apply(this, args)
}
}
}
// 实际应用
const handleScroll = throttle(() => {
console.log('scroll event')
}, 100)
window.addEventListener('scroll', handleScroll)
4. 单例模式
const Singleton = (function() {
let instance = null
function createInstance() {
return {
data: [],
add: function(item) {
this.data.push(item)
}
}
}
return {
getInstance: function() {
if (!instance) {
instance = createInstance()
}
return instance
}
}
})()
const s1 = Singleton.getInstance()
const s2 = Singleton.getInstance()
console.log(s1 === s2) // true
常见面试题解析
面试题 1:循环中的闭包问题
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i)
}, 100)
}
// 输出什么?如何修改?
答案:输出 3 3 3,因为 setTimeout 是异步执行,循环结束时 i 已经是 3。
解决方案:
// 方案 1:使用 let(块级作用域)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100) // 0 1 2
}
// 方案 2:使用闭包
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(() => console.log(j), 100) // 0 1 2
})(i)
}
// 方案 3:使用 bind
for (var i = 0; i < 3; i++) {
setTimeout(console.log.bind(null, i), 100) // 0 1 2
}
面试题 2:闭包与 this
const obj = {
name: 'Object',
getName: function() {
return this.name
},
getNameArrow: () => {
return this.name
},
delayedGetName: function() {
setTimeout(function() {
console.log(this.name)
}, 100)
}
}
console.log(obj.getName()) // ?
console.log(obj.getNameArrow()) // ?
obj.delayedGetName() // ?
答案:
obj.getName()→'Object'(this 指向 obj)obj.getNameArrow()→undefined(箭头函数 this 继承自外层,指向全局或 undefined)obj.delayedGetName()→undefined(setTimeout 回调中 this 指向全局)
修复 delayedGetName:
delayedGetName: function() {
// 方案 1:保存 this
const self = this
setTimeout(function() {
console.log(self.name)
}, 100)
// 方案 2:使用箭头函数
setTimeout(() => {
console.log(this.name)
}, 100)
// 方案 3:使用 bind
setTimeout(function() {
console.log(this.name)
}.bind(this), 100)
}
面试题 3:内存泄漏
function leaky() {
const hugeData = new Array(1000000).fill('leak')
return function() {
console.log('I have access to hugeData')
}
}
const leaks = []
for (let i = 0; i < 100; i++) {
leaks.push(leaky()) // 每次调用都创建新的闭包,持有 hugeData
}
问题:即使不需要 hugeData,它也不会被释放。
解决方案:
function notLeaky() {
const hugeData = new Array(1000000).fill('leak')
// 使用闭包只暴露需要的数据
const result = processData(hugeData) // 只保留处理后的结果
return function() {
console.log(result) // 只引用 result,不引用 hugeData
}
}
闭包的性能注意事项
1. 内存占用
闭包会持有外部变量的引用,可能导致内存占用增加:
// 不推荐:持有大量数据
function createDataProcessor(data) {
// data 可能很大,但闭包只用到一部分
return function(id) {
return data.find(item => item.id === id)
}
}
// 推荐:只持有需要的数据
function createDataProcessor(data) {
const index = new Map(data.map(item => [item.id, item]))
return function(id) {
return index.get(id) // 只持有 Map,不持有原始 data
}
}
2. 避免过度使用
不是所有情况都需要闭包:
// 过度使用
const addOne = (function() {
const n = 1
return function(x) {
return x + n
}
})()
// 简单场景直接用普通函数
const addOne = x => x + 1
3. 及时释放引用
let closure = createLargeClosure()
// 使用 closure...
// 使用完毕后释放引用,让垃圾回收器回收内存
closure = null
总结
闭包是 JavaScript 中强大而灵活的特性,掌握它对于写出高质量的代码至关重要:
| 要点 | 说明 |
|---|---|
| 本质 | 函数 + 词法作用域的组合 |
| 核心 | 记住并访问定义时的作用域 |
| 应用 | 数据私有化、柯里化、防抖节流、模块化 |
| 注意 | 内存管理、this 指向、循环问题 |
理解闭包不仅能帮你通过面试,更能让你在实际开发中写出更优雅、更模块化的代码。