看完这篇,闭包面试再也不怕了

0 阅读5分钟

深入理解 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 指向、循环问题

理解闭包不仅能帮你通过面试,更能让你在实际开发中写出更优雅、更模块化的代码。


参考资源