JS基础三座大山之二一作用域和闭包(2)

在了解学习完作用域,垃圾回收,this指向等知识之后,现在来了解一下闭包

带着一下问题来学习闭包吧!

  • 什么是闭包?
  • 写个闭包的例子
  • 闭包涉及的变量,存储的时候是在栈内存吗?
  • 闭包有什么应用?
  • 防抖和节流的含义?
  • 写个防抖/节流函数吧!
  • 闭包为什么不会被回收?
  • JS如何实现自动垃圾回收?
  • 如何手动进行垃圾回收?
  • 闭包会不会出现内存泄露?
  • 怎么避免内存泄露?
  • 怎么释放闭包?

1. 闭包基础

由一个例子,引发的问题及解释:

function createCounter() {
  let count = 0
  return {
    increase: function() {
      count++
      return count
    },
    decrease: function() {
      count--
      return count
    },
    getCount: function() {
      return count
    }
  }
}

const counter = createCounter()
console.log(counter.increase()) // 输出:1
console.log(counter.increase()) // 输出:2
console.log(counter.count) // undefined
const newCounter = counter
console.log(newCounter.getCount()) // 输出:2
console.log(newCounter.decrease()) // 输出:1

const counterAgain = createCounter()
console.log(counterAgain.getCount()) // 输出:0
console.log(counterAgain.decrease()) // 输出:-1

仅观察输出结果:

  • 在访问 counter 的过程中,其中的 count 会一直记录下来(访问 newCounter 也可以,因为引用类型的赋值为对象地址,所以会指向同一个函数)。
  • 当你再声明 counterAgain,此时相当于新建 function(count 又会从 0 开始)。

分析结果:

或许第一次看会觉得很正常,但这确实已经是不知不觉间利用到了闭包的特性,也就是出现了一个不会被垃圾回收的变量 count(被 increase 等 function 一直引用),所以才能让你每次访问同一个对象 counter,都能返回上一次运行后的结果。

此时只要 createCounter 不返回 count,那么它就形成私有变量(当然也可以创建出私有方法)。这就是闭包的应用之一:创建私有变量和方法。

通过示例,总结一下闭包的概念:

闭包是那些能够访问“自由变量”的函数。所谓的自由变量,是指在函数中使用的变量,既不是函数参数,也不是函数的局部变量的变量(也不是全局变量)。

再用上述示例复述下,即:increase、decrease 和 getCount 这三个函数都能访问到createCounter 函数作用域中的 count 变量,但 count 变量既不是这些函数的参数,也不是这些函数的局部变量(也不是全局变量),所以符合了“自由变量”的概念。此时在 createCounter 函数执行完毕后,这些函数仍然能够访问 count 变量。而这三个函数与它们共同引用的 count 变量的组合,就构成了闭包。

综上,闭包的特性可以总结如下:

  • 访问外部变量:闭包可以访问定义它们的外部函数中的变量(在上文中就是 count)。
  • 封装性:闭包可以帮助封装变量,提供类似私有变量的效果(在上文中还是 count)。
  • 持久性:通常,当函数执行完毕后,其内部局部变量会被销毁。但闭包的存在使得外部函数执行完毕后,闭包仍然可以访问外部函数的变量,这些变量的生命周期被延长了(在上文中依然是 count)。
  • 记忆性:闭包可以用于创建具有记忆功能的函数(getCount 方法总是能返回最新的 count 值)。

2 自由变量存放在堆内存

之前的文章提到过:基本数据类型会存放在栈内存中。但闭包依然是个例外,比如闭包示例中的 count,虽然是基础类型,但是会存储在堆内存中。原因如下:

当 createCounter 函数执行完毕后,正常情况下其局部变量(包括 count)应该被销毁。但由于闭包的存在(JavaScript 引擎检测到对象依然被引用),count 变量需要继续存在。此时,为了使 count 在 createCounter 函数执行完毕后仍然可以被访问,JavaScript 引擎会将其存储在堆内存中。

存放在堆内存中时,引擎会创建一个特殊的对象,这个对象包含了闭包引用的所有外部变量(且作用域链中会包含对这个特殊对象的引用,使得闭包函数可以访问 count 变量)。这个特殊的对象通常被称为“词法环境”或“闭包对象”。

3. 再解释一下什么是闭包

什么是闭包?

概念: 一个函数对周围状态的引用捆绑在一起,内层函数中访问到其外层函数的作用域

说人话:

简单理解就是: 闭包 = 内层函数 + 引用的外层函数变量

function outer() {
    const a = 1  // 外层函数变量
    function f() {  // 内层函数
        console.log(a)
    }

    // function f + const a = 闭包
    f()
}
outer()
// 通常会使用一个函数包裹住闭包结构,以起到对变量保护的作用

闭包的两个注意点:

  • 闭包一定有return 吗 no
  • 闭包一定会有内存泄漏吗 no

闭包什么时候用到 return

外部如果想要使用闭包变量,此时需要 return

function outer() {
    const a = 1  // 外层函数变量
    function f() {  // 内层函数
        console.log(a)
    }
}

变成:

function outer() {
    const a = 1  // 外层函数变量
    return function f() {  // 内层函数
        console.log(a)
    }
}
const fn  = outer()
fn()

案例:

// 普通形式 统计函数调用次数
let i = 0
function fn() {
    i++
    console.log(`函数调用了${i}次`)
}
// 弊端
// 应为i是一个全局变量,所以我可以在任何地方改变i的值,这样会导致函数调用次数计算的不准确

// 改造 使用闭包形式
function count(){
    let i  = 0 // 现在i在闭包的加持下就成为了私有变量,但是在外部却可以使用
    function f() {      
        i++
        console.log(`函数调用了${i}次`)
    }
    return  fn
}
const fun = count()

由上述案例可以看出,闭包有一个特别显示的作用就是将变量私有化,外部只能使用,不能修改

4. 闭包的应用

4.1 防抖(Debounce)

防抖用于限制函数的调用频率,常用于处理频繁触发的事件,只有最后一次调用会触发。

应用场景:搜索框输入建议、窗口大小调整、表单验证,提交按钮等

function debounce(fn, delay) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  }
}

const debouncedSearch = debounce((query) => {
  console.log('Searching for:', query);
}, 300);

// 使用示例
input.addEventListener('input', (e) => debouncedSearch(e.target.value));

4.2 节流(Throttle)

确保函数在一定时间间隔内最多执行一次。

应用场景:滚动事件处理、鼠标移动、高频点击等

function throttle(fn, limit) {
  let inThrottle;
  return function(...args) {
    if (!inThrottle) {
      fn.apply(this, args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  }
}

const throttledResize = throttle(() => {
  console.log('Window resized');
}, 1000);

// 使用示例
window.addEventListener('resize', throttledResize);

4.3 创建私有变量和方法

由开始的案例可以看出,闭包有一个特别显示的作用就是将变量私有化,外部只能使用,不能修改

4.4 函数柯里化

柯里化是一种将接受多个参数的函数转换成一系列使用一个参数的函数的技术。通过闭包,柯里化的函数可以记住每次传递给它们的参数。如下所示:

function multiply(a) {
  return function(b) {
    return a * b;
  }
}

// 使用柯里化函数
const multiplyByTwo = multiply(2);
console.log(multiplyByTwo(5)); // 输出:10

const multiplyByThree = multiply(3);
console.log(multiplyByThree(5)); // 输出:15

在这个例子中:

  • multiply 函数接受一个参数 a,并返回一个新的函数,这个新的函数接受另一个参数 b。
  • 当调用 multiply(a) 时,它返回一个闭包,这个闭包记住了 a 的值(因为 a 既不是它的参数,也不是它的局部变量)。
  • 返回的函数(闭包)接受 b 作为参数,并返回 a 和 b 的乘积。
  • 通过 multiply(2) 和 multiply(3) 创建了两个新的函数 multiplyByTwo 和 multiplyByThree,它们分别固定了乘法的第一个参数为 2 和 3。
  • 当调用这些固定了一个参数的函数时,只需要提供第二个参数。
  1. 闭包-内存泄漏问题

内存泄漏是指程序中已分配的内存由于某些原因未能被正确释放,导致这些内存无法被再次使用,从而造成系统内存占用不断增加的现象。

显然,闭包很符合这个说法:闭包会保持对其外部作用域中变量(自由变量)的引用,如果这个闭包引用了大量数据,又长期存在,那么这些被引用的变量就无法被垃圾回收,占用大量内存。

关于解决的方法:

如果出现内存泄漏,且你不希望形成闭包,那就检查代码,优化相关代码即可;

如果你希望利用闭包 + 避免出现内存泄漏,此时可考虑手动释放。

当然在3中也提到 闭包不一定会有内存泄漏

闭包内存泄露的问题:

function count(){
    let i  = 0 // 现在i在闭包的加持下就成为了私有变量,但是在外部却可以使用
    function f() {      
        i++
        console.log(`函数调用了${i}次`)
    }
    return  fn
}
const fun = count()
fun() // 1
fun() // 2

谁会存在内存泄漏? i
借助于垃圾回收机制的标记清除法可以看出:

  1. fun是一个全局变量,代码执行完毕不会立即销毁
  2. fun使用count函数
  3. count用到f函数
  4. f函数用到 i 变量
  5. 只要count被引用就不会被回收,所以一直存在

此时,闭包引起了内存泄漏

注意:

  • 不是所有的内存泄漏都要手动回收
  • 比如 react里面很多闭包是不能回收的

5.1 如何释放闭包

  1. 清除定时器和回调函数,移除事件监听器:如果闭包被用作事件监听器,确保在不需要时移除事件监听器(Vue中,可以写在 beforeDestroy / beforeUnmount 里,react中,可以写在 useEffect 里)。
function handleClick() {
  // 这里形成闭包…
}

button.addEventListener('click', handleClick);
// 当不再需要监听点击事件时,移除事件监听器
button.removeEventListener('click', handleClick);

  1. 解除外部引用:将声明的变量设置为 null 或 undefined 可以帮助释放闭包。
<template>
  <div class="test-page">
    {{ result && result.getValue().name }}
    <button @click="handleBtn">
      触发
    </button>
    <button @click="releaseBtn">
      释放
    </button>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api'
export default defineComponent({
  name: 'TestPage',
  setup() {
    const registry = new FinalizationRegistry(name => {
      console.log(`${name} 已被回收`)
    })

    let result = ref(null) as any
    const testFunction = function() {
      let testObj = { name: '测试数据' }
      registry.register(testObj, 'testObj')
      return {
        getValue: () => testObj
      }
    }
    function handleBtn() {
      result.value = testFunction()
    }
    function releaseBtn() {
      if (result.value) {
        result.value.getValue = null
      }
      result.value = null
    }

    return {
      result,
      handleBtn,
      releaseBtn,
    }
  },
})
</script>
  1. DOM 引用
// ❌ 错误:保留对已移除 DOM 的引用
let elements = {
  button: document.getElementById('myButton'),
  image: document.getElementById('myImage')
};

// 从DOM中移除元素
document.body.removeChild(document.getElementById('myButton'));

// 但 elements.button 仍然引用该DOM元素

// ✅ 解决方案:移除引用
elements.button = null;

5.2 避免闭包内存泄露最佳实践总结

  1. 使用严格模式:避免意外创建全局变量

  2. 及时清理资源:

  • 清除定时器(clearInterval, clearTimeout)

  • 移除事件监听器(removeEventListener)

  • 关闭连接(WebSocket, EventSource)

  1. 管理DOM引用:
  • 在移除DOM元素后解除引用

  • 使用弱引用(WeakMap, WeakSet)存储DOM引用

  1. 合理使用闭包:
  • 避免在闭包中保留大型对象

  • 在不再需要时解除引用

  1. 使用弱引用
// 使用WeakMap不会阻止键对象被垃圾回收
const weakMap = new WeakMap();
let keyObj = { id: 1 };
weakMap.set(keyObj, 'some data');

// 当keyObj被设置为null后,WeakMap中的条目会被自动清除
keyObj = null;

  1. 避免循环引用:
// 循环引用
let objA = { name: 'A' };
let objB = { name: 'B' };
objA.ref = objB;
objB.ref = objA;

// 解决方案:在不再需要时解除引用
objA.ref = null;
objB.ref = null;
posted @ 2025-08-17 22:43  cyy618  阅读(6)  评论(0)    收藏  举报