Skip to content

闭包与作用域

作者:青见春山
发表于:2026-07-29
字数统计:5500 字
预计阅读19分钟

涵盖闭包原理、数据私有化、循环 var 问题、let 与闭包区别、内存泄漏场景与避免。原文件中"闭包"标题两处内容(朴素讲解 + 面试格式)合并到本文。

一、闭包本质

闭包,本质上是上级作用域内变量的生命周期,因为被下级作用域引用,没有得到释放,需要等到下级作用域执行完后才得到释放。

闭包的变量存在堆中,即解释了函数调用之后为什么闭包还能引用到函数内的变量。

二、标准面试回答(1 分钟)

闭包是指一个函数能够"记住"并访问它定义时所在的作用域,即使这个函数是在其作用域之外被调用。简单来说,就是"函数 + 词法作用域"的组合。

核心原理是 JavaScript 的词法作用域机制:当函数被定义时,它会保存一个对当前作用域链的引用。内部函数可以访问外部函数的变量,而外部函数执行完后,这些变量不会立即被垃圾回收,因为内部函数的引用依然存在。

闭包最常见的两种形式

  • 函数作为返回值:外部函数返回内部函数,内部函数保留了对外部函数变量的访问
  • 函数作为参数传递:将函数作为参数传入另一个函数,它依然能访问定义时的作用域

实际应用场景

  1. 数据私有化:通过闭包模拟私有变量,外部无法直接访问,只能通过暴露的方法操作
  2. 函数工厂:根据参数生成不同的函数,如 createMultiplier(n) 返回乘以 n 的函数
  3. 防抖/节流:利用闭包保存定时器 ID,实现延迟执行
  4. 循环中的异步问题:用闭包解决 var 声明带来的变量共享问题(或者直接用 let

需要注意的问题:闭包会导致变量无法被垃圾回收,如果滥用可能造成内存泄漏。比如在循环中大量创建闭包,或者 DOM 元素的事件回调中持有大对象的引用,都会导致内存占用过高。

20 秒极简版

闭包是函数记住定义时作用域的能力,让内部函数可以访问外部函数的变量,即使外部函数已执行完。常用于数据私有化、函数工厂、防抖节流等场景。注意避免滥用导致内存泄漏。

三、闭包代码示例

基本闭包

JavaScript
// 闭包:函数可以记住并访问其词法作用域
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

私有变量

JavaScript
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 (私有)

循环中的闭包(经典问题)

JavaScript
// 错误示例
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)
}

缓存/记忆化

JavaScript
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 模块化模式

JavaScript
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 的回调形成了闭包,但定时器未清除
  • 大数组/大对象:闭包持有大对象的引用,即使不再使用也无法回收

避免方法

  1. 手动解除引用:不需要时将闭包引用置为 null
  2. 及时清理:移除 DOM 前解绑事件、清除定时器
  3. 尽量使用局部变量:不要在闭包中无意识地保留大对象
  4. 使用 WeakMap/WeakSet:在需要缓存但又不希望阻止垃圾回收的场景

现代浏览器的垃圾回收器已经比较智能,但开发时仍要注意避免长期持有不必要的引用。

五、追问 2:var 在循环中的问题,闭包是怎么解决的?let 和闭包有什么区别?

经典问题:

JavaScript
for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 0)
}
// 输出 3, 3, 3

因为 var 是函数作用域,循环结束后 i 被提升到函数顶部,三个定时器回调共享同一个 i,此时 i 已经是 3。

闭包解决方案:用立即执行函数(IIFE)为每次循环创建一个独立作用域

JavaScript
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 区别详解

varletconst 都是 JavaScript 中用来声明变量的关键字。var 是 ES5 及之前遗留下来的方式,而 letconst 是 ES6(ECMAScript 2015)引入的新特性,主要是为了解决 var 带来的一些不合理和容易出错的机制。

1. 作用域(Scope)

  • var 是函数作用域(Function Scope)或全局作用域。如果在函数内声明,它就在函数内有效;如果在块级语句(如 iffor)中声明,它会泄露到外部。
  • letconst 是块级作用域(Block Scope)。它们只在最近的 {} 内部有效。能有效防止变量在循环或条件转移之外被意外访问。
JavaScript
if (true) {
    var a = 1;
    let b = 2;
}
console.log(a); // 输出 1
console.log(b); // 报错:b is not defined

2. 变量提升与暂时性死区(Hoisting & TDZ)

  • var 存在变量提升。变量声明会被提升到作用域顶部,并且初始化为 undefined。所以在声明前访问不会报错,而是拿到 undefined
  • letconst 也有提升,但存在"暂时性死区"(TDZ)。它们被提升到了代码块顶部,但在实际声明语句执行之前,是不允许被访问的,如果提前访问会抛出 ReferenceError
JavaScript
console.log(x); // undefined
var x = 10;

console.log(y); // 报错:Cannot access 'y' before initialization
let y = 20;

3. 重复声明(Re-declaration)

  • var 允许在同一作用域内重复声明同一个变量。后面的声明会覆盖前面的。
  • letconst 不允许在同一作用域内重复声明。强行声明会报语法错误 SyntaxError

4. 重新赋值与初始化(Reassignment & Initialization)

  • varlet:声明后可以随意重新赋值。声明时也可以不赋初始值(默认为 undefined)。
  • const:用于声明常量。一旦声明,必须立即初始化,且不可重新赋值(即绑定的内存地址不能变)。

注意:如果 const 声明的是一个引用类型(如对象或数组),它保证的是指向对象的指针地址不能变,但对象内部的属性和元素是可以被修改的

JavaScript
const obj = { name: 'Alice' };
obj.name = 'Bob'; // 正常允许操作,内存地址没变

5. 全局对象属性绑定

  • 在浏览器全局作用域中,var 声明的变量会成为 window 的属性,而 let/const 不会。

总结表格

特性varletconst
作用域函数 / 全局块级块级
提升提升(初始化为 undefined)提升但存在 TDZ提升但存在 TDZ
重复声明允许不允许不允许
重新赋值允许允许不允许
必须初始化

九、关联文档