闭包与作用域
涵盖闭包原理、数据私有化、循环 var 问题、let 与闭包区别、内存泄漏场景与避免。原文件中"闭包"标题两处内容(朴素讲解 + 面试格式)合并到本文。
一、闭包本质
闭包,本质上是上级作用域内变量的生命周期,因为被下级作用域引用,没有得到释放,需要等到下级作用域执行完后才得到释放。
闭包的变量存在堆中,即解释了函数调用之后为什么闭包还能引用到函数内的变量。
二、标准面试回答(1 分钟)
闭包是指一个函数能够"记住"并访问它定义时所在的作用域,即使这个函数是在其作用域之外被调用。简单来说,就是"函数 + 词法作用域"的组合。
核心原理是 JavaScript 的词法作用域机制:当函数被定义时,它会保存一个对当前作用域链的引用。内部函数可以访问外部函数的变量,而外部函数执行完后,这些变量不会立即被垃圾回收,因为内部函数的引用依然存在。
闭包最常见的两种形式
- 函数作为返回值:外部函数返回内部函数,内部函数保留了对外部函数变量的访问
- 函数作为参数传递:将函数作为参数传入另一个函数,它依然能访问定义时的作用域
实际应用场景
- 数据私有化:通过闭包模拟私有变量,外部无法直接访问,只能通过暴露的方法操作
- 函数工厂:根据参数生成不同的函数,如
createMultiplier(n)返回乘以 n 的函数 - 防抖/节流:利用闭包保存定时器 ID,实现延迟执行
- 循环中的异步问题:用闭包解决
var声明带来的变量共享问题(或者直接用let)
需要注意的问题:闭包会导致变量无法被垃圾回收,如果滥用可能造成内存泄漏。比如在循环中大量创建闭包,或者 DOM 元素的事件回调中持有大对象的引用,都会导致内存占用过高。
20 秒极简版
闭包是函数记住定义时作用域的能力,让内部函数可以访问外部函数的变量,即使外部函数已执行完。常用于数据私有化、函数工厂、防抖节流等场景。注意避免滥用导致内存泄漏。
三、闭包代码示例
基本闭包
// 闭包:函数可以记住并访问其词法作用域
function createCounter() {
let count = 0
return {
increment: function() {
count++
return count
},
decrement: function() {
count--
return count
},
getCount: function() {
return count
}
}
}
const counter = createCounter()
console.log(counter.increment()) // 1
console.log(counter.increment()) // 2
console.log(counter.decrement()) // 1
console.log(counter.getCount()) // 1私有变量
function createPerson(name) {
let _name = name
return {
getName: function() {
return _name
},
setName: function(newName) {
if (newName && newName.length > 0) {
_name = newName
}
}
}
}
const person = createPerson('张三')
console.log(person.getName()) // 张三
person.setName('李四')
console.log(person.getName()) // 李四
console.log(person._name) // undefined (私有)循环中的闭包(经典问题)
// 错误示例
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i) // 3, 3, 3
}, 100)
}
// 解决方案1:使用 let
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i) // 0, 1, 2
}, 100)
}
// 解决方案2:使用闭包
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(function() {
console.log(j) // 0, 1, 2
}, 100)
})(i)
}缓存/记忆化
function memoize(fn) {
const cache = {}
return function(...args) {
const key = JSON.stringify(args)
if (cache[key] === undefined) {
cache[key] = fn(...args)
}
return cache[key]
}
}
const expensiveFunction = (n) => {
console.log('计算中...')
return n * 2
}
const memoized = memoize(expensiveFunction)
console.log(memoized(5)) // 计算中... 10
console.log(memoized(5)) // 10 (从缓存获取)IIFE 模块化模式
const module = (function() {
// 私有变量和方法
let privateVar = 0
function privateMethod() {
return privateVar
}
// 公开 API
return {
increment: function() {
privateVar++
return privateVar
},
getValue: function() {
return privateMethod()
}
}
})()
console.log(module.getValue()) // 0
module.increment()
console.log(module.getValue()) // 1四、追问 1:闭包的内存泄漏是怎么产生的?怎么避免?
闭包导致内存泄漏的根本原因是:闭包持有外部函数作用域的引用,如果这个作用域中的变量是大对象,且闭包长期存在(比如作为 DOM 事件回调),这些变量就无法被垃圾回收。
典型场景
- DOM 事件监听:将闭包作为回调绑定到 DOM 元素,但元素被移除前未解绑,闭包持有的外部变量无法释放
- 定时器:
setInterval的回调形成了闭包,但定时器未清除 - 大数组/大对象:闭包持有大对象的引用,即使不再使用也无法回收
避免方法
- 手动解除引用:不需要时将闭包引用置为
null - 及时清理:移除 DOM 前解绑事件、清除定时器
- 尽量使用局部变量:不要在闭包中无意识地保留大对象
- 使用 WeakMap/WeakSet:在需要缓存但又不希望阻止垃圾回收的场景
现代浏览器的垃圾回收器已经比较智能,但开发时仍要注意避免长期持有不必要的引用。
五、追问 2:var 在循环中的问题,闭包是怎么解决的?let 和闭包有什么区别?
经典问题:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
// 输出 3, 3, 3因为 var 是函数作用域,循环结束后 i 被提升到函数顶部,三个定时器回调共享同一个 i,此时 i 已经是 3。
闭包解决方案:用立即执行函数(IIFE)为每次循环创建一个独立作用域
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(() => console.log(j), 0)
})(i)
}每次循环,IIFE 都会捕获当前的 i 值作为参数 j,定时器回调中的 j 指向这个独立的作用域变量。
let 的区别:let 是块级作用域,循环每次迭代都会创建一个新的绑定。所以 for (let i = 0; i < 3; i++) 时,每个定时器回调都对应不同的 i,直接解决问题。let 本质上就是语言层面帮你做了闭包做的事情。
六、追问 3:闭包和普通函数在性能上有什么差异?
闭包相比普通函数会有额外的内存开销和访问速度影响。
内存开销:每个闭包实例都会保存一份对外部作用域的引用。如果大量创建闭包(如循环中每次创建新函数),内存占用会更高。
访问速度:闭包访问外部变量时,需要通过作用域链查找,比访问局部变量或全局变量稍慢。但现代 JS 引擎(如 V8)对此做了高度优化,在大多数场景下差异可以忽略不计。
实际建议
- 不要在性能敏感的循环中大量创建闭包(如动画循环、大数组遍历)
- 优先使用
let/const块级作用域替代不必要的闭包 - 关注代码可读性和正确性优先,性能优化应在必要时进行
七、易错点
- 把闭包等同于"函数返回函数":闭包的本质是"函数 + 作用域",不一定要返回,作为参数传递也是闭包
- 只说概念不举例:闭包是抽象概念,能举例(防抖、节流、数据私有)才加分
- 混淆"闭包"和"作用域":闭包是作用域链的一种应用,不是作用域本身
- 内存泄漏的原因说不清:只知道"闭包会导致内存泄漏",但说不出"因为引用还在,垃圾回收无法回收"
- 循环问题的解释不准确:有人说"因为异步",核心其实是作用域问题
- 不知道 let 本质上也是闭包:
let的块级作用域也是通过类似闭包的机制实现的 - 忽略现代引擎的优化:把闭包性能说得过于严重显得知识陈旧
八、var / let / const 区别详解
var、let 和 const 都是 JavaScript 中用来声明变量的关键字。var 是 ES5 及之前遗留下来的方式,而 let 和 const 是 ES6(ECMAScript 2015)引入的新特性,主要是为了解决 var 带来的一些不合理和容易出错的机制。
1. 作用域(Scope)
var是函数作用域(Function Scope)或全局作用域。如果在函数内声明,它就在函数内有效;如果在块级语句(如if、for)中声明,它会泄露到外部。let和const是块级作用域(Block Scope)。它们只在最近的{}内部有效。能有效防止变量在循环或条件转移之外被意外访问。
if (true) {
var a = 1;
let b = 2;
}
console.log(a); // 输出 1
console.log(b); // 报错:b is not defined2. 变量提升与暂时性死区(Hoisting & TDZ)
var存在变量提升。变量声明会被提升到作用域顶部,并且初始化为undefined。所以在声明前访问不会报错,而是拿到undefined。let和const也有提升,但存在"暂时性死区"(TDZ)。它们被提升到了代码块顶部,但在实际声明语句执行之前,是不允许被访问的,如果提前访问会抛出ReferenceError。
console.log(x); // undefined
var x = 10;
console.log(y); // 报错:Cannot access 'y' before initialization
let y = 20;3. 重复声明(Re-declaration)
var允许在同一作用域内重复声明同一个变量。后面的声明会覆盖前面的。let和const不允许在同一作用域内重复声明。强行声明会报语法错误SyntaxError。
4. 重新赋值与初始化(Reassignment & Initialization)
var和let:声明后可以随意重新赋值。声明时也可以不赋初始值(默认为undefined)。const:用于声明常量。一旦声明,必须立即初始化,且不可重新赋值(即绑定的内存地址不能变)。
注意:如果
const声明的是一个引用类型(如对象或数组),它保证的是指向对象的指针地址不能变,但对象内部的属性和元素是可以被修改的。
const obj = { name: 'Alice' };
obj.name = 'Bob'; // 正常允许操作,内存地址没变5. 全局对象属性绑定
- 在浏览器全局作用域中,
var声明的变量会成为window的属性,而let/const不会。
总结表格
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数 / 全局 | 块级 | 块级 |
| 提升 | 提升(初始化为 undefined) | 提升但存在 TDZ | 提升但存在 TDZ |
| 重复声明 | 允许 | 不允许 | 不允许 |
| 重新赋值 | 允许 | 允许 | 不允许 |
| 必须初始化 | 否 | 否 | 是 |
九、关联文档
- 02-this 绑定与隐式丢失
- 04-函数大全(箭头函数 / class 部分)
- 09-垃圾回收与内存泄漏