我们可能没有发现自己正在使用闭包

2,355 阅读6分钟

“开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 4 天,点击查看活动详情

一、为什么重复写闭包

之前也写过关于闭包的文章。使用到了上下文,使用到了[[立即执行函数]],使用到了AO、GO,甚至还使用到了[[堆内存]]这一大堆名词来进行解释。 一个月过去之后,自己看都愣半天。啥是堆内存了?AO和GO又是什么玩意儿?

image.png

用了一些更高深莫测的词汇来解释一个概念,而这些词汇的知识点甚至比当前要解释的概念更复杂。在当前AI 大行其道的时代,或许写这种基础知识的文章更多的是为了悦己,为的是加深自己对于某个知识点的,而不是装逼的。

通俗易懂才是最重要的。

之前写得还是太复杂了,随着自己的理解加深,重现更新,加深自己的认知。

二、闭包是什么

闭包由closure翻译而来。

它的含义更多的是闭,而不是包。为什么要提及这一点呢?因为这词语更加准确的翻译应该是闭合性,我们在讨论 JS 的闭包的时候,其实是在讨论 JS 中如何实现函数的闭合性的?

换一句话来说,就是 JS 如何让变量在函数外边如何被调用的。这个本质依旧是在说[[函数作用域]]。只需要让目标变量的作用域不被销毁,那么你就可以在任何地方访问这个变量。

如下图,fn1想要访问fn2内的a变量:

image.png

这里提到的这个变量,一般来说指的不是变量的指,而是这个变量的内存位置。

那么,闭合性在 JS 中是如何表现的呢?实践是唯一真理,直接看下面的闭包的例子,从中总结规律。

1. 第一段代码,常见的闭包,直接返回一个函数:

function foo() {
  let local = 1
  return function() {
     local++
     console.log(local)
  }
}

const fn = foo()
fn() // 2
fn() // 3

2. 第二段代码,循环赋值 + IIFE:

for(var i = 0; i < 10; i++) {
  (function() {
    setTimeout(function() {
      console.log(i) // 10, 10, 10, 10, 10
    }, 1000)
  })()
}

3. 第三段代码,全局应用函数:

function foo() {
  let local = 1
  window.addLocal = function() {
    local++
    console.log(local)
  }
}
addLocal() // 2
addLocal() // 3

4. 第四段代码,函数返回对象函数:


function foo() {
  let local = 1
  function too() {
    local++
    console.log(local)
  }
  function eoo() {
    local++
    console.log(local)
  }
  return [too, eoo]
}

const a = foo()
a[0]() // 2
a[0]() // 3
a[1]() // 4

上面段代码都体现了 JS 中函数对于闭合性的实现。局部变量在函数外部依旧可以访问到。通过这四个代码片段,我们找到它们的共性,得出JS实现闭合性的最小单位如下:

let local = 1
function foo() {
  console.log(local)
}

但是这样看不出它是局部变量,那么我们给它添加上 IIFE:

;(function(){
	let local = 1
	function foo() {
	  console.log(local)
	}
})()

由此我们还可以给出一些关于 JS 实现函数闭合性的问题:

  1. 不是一定需要 return 一个函数,代码片段 3 和 4 中都没有返回一个函数。
  2. 不是一定需要函数嵌套函数,代码片段 2 中。

三、闭包实现的原理

闭包是 JS 函数作用域的副产品,所以《高程》上面说了解闭包就要先理解什么是函数作用域。它是 由函数这个函数可以访问到的变量这两者构成的。

上面的说到的作用域和垃圾回收机制在其中又有什么作用的呢?

  • ​作用域(词法环境)​​:
    JavaScript 闭包的核心机制是 ​​词法作用域​​(Lexical Scope)。当一个函数内部定义了另一个函数,且内部函数引用了外部函数的变量时,内部函数会“记住”它被定义时的词法环境(即外部函数的作用域),即使外部函数已经执行完毕。这是闭包实现的基础。

  • ​垃圾回收机制​​:
    JavaScript 的垃圾回收机制(Garbage Collection)会回收不再被引用的内存。闭包中,如果内部函数仍然持有对外部函数变量的引用,这些变量就不会被回收(即使外部函数已执行完),从而形成闭包。因此,垃圾回收机制间接支持了闭包的存在。

两者缺一不可。

结论:JS 对于函数闭合性的实现是基于作用域和垃圾回收机制相互作用的一个现象。

四、在平时工作的时候我们如何应用到闭包

为了能够访问一个变量,我们可以直接让这个变量位于的全局作用域中。我们平时工作中还需要直接使用闭包吗?

答案是需要的。闭包在我们业务开发中最大的作用就是为了隐藏变量!,尤其在 vue3 hook写法的时候,闭包出现的场景更加多,而且也足够安全。

场景一:结合ref实现vue hook

​1. 实现一个带闭包的 Hook​

// useCounter.ts
import { ref } from 'vue';

export function useCounter(initialValue: number, options: { min?: number; max?: number }) {
  const { min = -Infinity, max = Infinity } = options;

  // 闭包内部的私有状态(通过 ref 转为响应式)
  const count = ref(initialValue);

  // 闭包内部方法(直接操作闭包中的 count)
  const increment = () => {
    if (count.value < max) count.value++;
  };

  const decrement = () => {
    if (count.value > min) count.value--;
  };

  const reset = () => {
    count.value = initialValue;
  };

  // 暴露响应式变量和方法
  return { count, increment, decrement, reset };
}

2. 在组件中使用 Hook

<template>
  <div>
    <button @click="decrement">-</button>
    <span>{{ count }}</span>
    <button @click="increment">+</button>
    <button @click="reset">Reset</button>
  </div>
</template>

<script setup>
import { useCounter } from './useCounter';

// 每次调用 useCounter 会创建一个新的闭包,独立维护 count 状态
const { count, increment, decrement, reset } = useCounter(0, { min: 0, max: 10 });
</script>

场景二:非 ref本地变量的使用

前端对于接口响应的数据进行缓存:

export function useCache<T>() {
  // 闭包中缓存计算结果
  let cache: T | null = null

  const calculate = (data: T) => {
    if (cache !== null) {
      console.log('返回缓存结果')
      return cache
    }

    console.log('首次请求');
    cache = result as T
    return result
  };

  const clearCache = () => {
    cache = null
  };

  return { calculate, clearCache }
}

总结​

在 Vue3 的 Hook 写法中,闭包的核心价值:

  1. ​隔离作用域​​:每个 Hook 实例独立维护状态。
  2. ​封装私有逻辑​​:隐藏内部实现细节,只暴露必要接口。
  3. ​性能优化​​:通过缓存、防抖/节流等模式减少重复计算或操作。

五、为什么都说需要谨慎的使用闭包

百度一下,也不知道谁抄的谁,太多一模一样的文章。而我也是长期这么认为的。

截屏2023-02-07 00.17.30.png

根据我之前写的闭包的文章,闭包长期存在于GO当中,自然可能会占用内存,如果数量过多了,不就导致内存泄漏了么?

看起来挺有道理的,这是因为我不懂啥啥叫[[内存泄漏]]而差产生的误解。所以我上网搜索到了如下的回答:

也就是说,对于闭包而言,造成内存泄漏的不是闭包本身,而是IE浏览器。

也就是说,只要我在使用闭包的时候,只有主要及时的销毁作用域就可以了。拿 vue3来举例的话,我们平时都是在tsx或者在.vue组件中引入闭包,那么它就会随着这个组件的销毁而销毁,一般来说是不会产生闭包长期存在于GO的现象。

所以平时该用就用,不需要顾虑那么多。