难于理解的闭包

林小木
  • 299

闭包的定义是:闭包就是能够读取其他函数内部变量的函数

例如:

function aaa() {
    var a = 0;
    return function () {
        alert(a++);
    };
  }

  aaa()();

aaa() 中的匿名函数就是一个闭包。这个我能理解,但是为什么要使用呢?根本没有任何意义的呀?!

比如1,要访问这个函数中的a,那我直接return出来不就好了

function aaa() {
    var a = 0;
    return a
  }

  aaa();

效果一模一样。

没有弄明白为什么要用闭包,觉得根本没有必要,有没有大神能详细说明一下?

回复
阅读 2.1k
6 个回答
✓ 已被采纳
/**
 * 没使用闭包的
 */
function aaa() {
    let a = 0;
    return a++;
}

/**
 * 使用闭包的情况
 */
function bbb() {
    let a = 0;
    return function () {
        return a++;
    }
}

// 注意 bbb 的用法不是 bbb()(),而是把它当作一个 factory
// 创建一个函数出来,比如 bFn,再使用这个 bFn
const bFn = bbb();
for (let i = 0; i < 3; i++) {
    console.log(`[${i}] a1 = ${aaa()}, a2 = ${bFn()}`);
}

结果

[0] a1 = 0, a2 = 0
[1] a1 = 0, a2 = 1
[2] a1 = 0, a2 = 2

参阅:

其实我们根本不应该学习“闭包”,而应该搞清楚“作用域”。
然后某个风和日丽的上午,你突然想写一个通用的计数器,但是你又嫌面向对象的写法啰嗦,于是天才的你用一层函数作为变量容器,然后从里面返回一层函数来读取和操作,简洁实用的同时,还顺手实现了私有属性。
示例代码我就不贴了,如果你嗑会作用域了,自然就会写了,在不会写之前,不要说自己会作用域,是不是真会,别人不知道,但至少能看出来还不会用。


好吧,这样略带嘲讽的原答案解决不了问题,我再添一些东西,希望能够释疑。

1. 如何定义闭包

首先一点,题目描述中的闭包的定义是错误的,任何使用这个结论的文章都是人云亦云。
关于 JS 中闭包的定义,可以参考MDN 文档:闭包中的解释——函数与其周围状态的捆绑。由此可见,闭包不是什么“能够读取其他函数内部变量的函数”,闭包甚至都不是函数,而是一个组合,正如你不能说情侣是某个男人或某个女人一样——情侣是两个有情人的组合。
函数容易理解,需要解释的就只有“周围状态”了。 MDN 对此的解释是“词法环境”,但是没有给出词法环境的定义,按照我的理解,词法环境由当前位置可访问的全部变量构成,至于哪些变量是“可访问的”,这就是作用域的知识了,这就是初版答案中,我建议先搞懂作用域概念的原因。
所以可以粗略地认为:
$$ 闭包 ≈ 函数 + 此函数能访问的全部外部变量 $$

2. 闭包有何特性

鉴于 JS 代码中至少会有全局作用域存在,所以任何函数都有“周围状态”,也就是说你所见的每一个函数,实际上都属于某个闭包的一部分,而闭包的特性就是 JS 中作用域的特性:函数可以访问其外部环境中的变量。
这简直就是一句废话,到此为止,闭包的特别之处还没有体现出来——因为闭包根本没有特别之处。

3. 闭合、且访问受限的作用域

在 JS 中,函数是可以嵌套的,当函数嵌套的时候,内层函数可以直接使用外层函数执行时所声明的变量,而外层函数不能直接使用内层函数执行时所声明的变量,外层函数的外层、外层函数的同层更不用说了。
所以对外部而言,嵌套的外层函数的作用域是一个闭合的区域,外部对该区域内变量的访问是受限的,只有内层函数可以访问。

4. 将内层函数的引用暴露到外部

几乎所有试图讲解闭包的文章都会从外层函数里 return 内层函数的引用,这样就可以从外面访问内层函数了,类似这样:

function outer(){
  var unreachable = 0;
  return function inner(){
    return ++unreachable 
  }
}
// outer 执行生成了 `unreachable` ,并返回一个 `inner` 
// 来操作 `unreachable`,返回的 `inner` 被 `counter`
// 引用,从而在 `outer` 外部可间接地操纵 `unreachable`
var counter = outer();

实际上,外层函数不必将内层函数返回,我们还可以这样做:

var counter = null;

function outer(){
  var unreachable = 0;
  counter = function inner(){
    return ++unreachable 
  }
}

// outer 执行生成了 `unreachable` ,并声明一个 `inner` 
// 来操作 `unreachable`,然后由 `counter`引用 `inner`,
// 从使得从外部可以间接地操纵 `unreachable`
outer();

5. 有了 ES6+,你甚至不需要外层函数

第三、第四条中,我似乎忘了使用“闭包”这个词语,这是因为讲解这些知识,有作用域的基础就够了,外加一个闭包是多余的。我实际描述的东西是由可访问的函数与仅这些函数可访问的闭合作用域的结合体,和闭包的原定义是有所区别的,要不是我在前端界缺乏影响力,我非得给它造一个新名词不可,就叫他李狗蛋 “闭域开函”,或者简称“闭域”。
不过闭包的定义带给我们一个思考——词法环境的范围必须由函数来圈定吗?为了构造一个闭合、且访问受限的作用域,我们必须使用一个外层函数吗?
在 ES5 及之前的年代,答案是“是”。
然而现在是公元 2021 年,答案可以是 “否”,因为 ES6 新增了块级作用域!且看代码:

var counter = null;
{
  let unreachable = 0;
  const inner = function inner(){
    return ++unreachable 
  }
}

这和大家所熟知的“闭包”的特点是一模一样的!

6. 有无必要使用这种东西

答案当然是有。
比如我在初版答案中提到的计数器,如果对计数器的值的引用暴露在外,那么在任何地方都可以修改这个计数值,从而导致计数不准确。这时候就需要将这个值隐藏起来,只允许使用特定的方法去修改,这样一来对于这个值的修改始终是可以被观测到的。
虽然目前浏览器已经支持类的私有属性,但是面向对象的写法比较啰嗦,写这种小模块用嵌套函数再合适不过了:

function counterGen(init = 0){
  var value = init;
  return {
    add: _ => ++value,
    sub: _ => --value
  }
}

class Counter{
  #value = 0;
  counstructor(init = 0){
    this.#value = init;
  }
  add(){
    return ++ this.#value
  }
  sub(){
    return -- tis.#value
  }
}

代码量差别不大,但我还是愿意使用第一种,如果我想兼容稍微旧一点的平台,那么第二种方案是万万不可的,我猜 Babel 也没有别的什么妙招来实现私有属性。

7. 实际应用中的无心之作

作为 JSer,高阶函数是必学必会的,而最广为 JSer 所知的高阶函数是防抖/节流函数,比如防抖函数:

function throttleGen(func, duration = 100){
  var timer = null;
  
  return function(...args){
    if(timer) return;
    timer = setTimeout(() => {
      timer = null;
      func.apply(this, args);
    }, duration);
  }
}

实际上每执行一次 throttleGen,都会生成一个由可访问的函数与仅这些函数可访问的闭合作用域的结合体,虽然这种场景下使用这个东西并非必要,但几乎没有人会刻意绕开。

这里我刻意避免用“闭包”一词来描述由可访问的函数与仅这些函数可访问的闭合作用域的结合体,但在与别人交流的过程中,你要记住他们说的“闭包”多半是指这个东西。

在这个用例里确实不需要用到闭包,因为直接 return 和通过一个内层函数去取到的值是没有任何区别的。
最经典的闭包用例是这个:

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

在只有 ES5 的年代,防止每次打印的都是 10 的最佳方案是使用闭包的特性:

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

当然这个用例本身也不好理解。

在只有 ES5 的年代,在 JS 中实现私有属性的唯一方法就是利用闭包,且迄今为止这仍是隐藏内部细节最简洁的方式。
你的用例确实不必使用闭包,只是因为这个用例选得不好,不能说明任何问题。

闭包可以用来产生独立的局部私有作用域,比如 mock 区块作用域或 ESM,可以记忆化,作为缓存,延长变量的生命周期和作用域链。

const { toString } = Object.prototype
const isType = (type, v) => Reflect.apply(toString, v, []) === `[object ${type}]`
/* 莫得闭包,每次都要输入多个参数 */
const ret1 = isType('String', '')
const ret2 = isType('String', 233)
console.log(ret1)
console.log(ret2)

/* 使用闭包,缓存输入的类型,以后只需输入一个参数 */
const isTypeMemo = type => v => Reflect.apply(toString, v, []) === `[object ${type}]`
const isNumber = isTypeMemo('Number')
const ret3 = isNumber('')
const ret4 = isNumber(666)
console.log(ret3)
console.log(ret4)

如上述粒子,使用闭包前后效果看似还是一毛一样。
但从代码结构或代码量,封装程度不一样,一般重复的功能可以考虑继续封装抽离,接口的灵活程度不一样,维护成本自然不同,可维护性也有差异。
当缓存函数或数据使用的频率越高,性能差异也高下立判,比如 Vue 源码用来缓存 HTML 原生标签或组件名或工具函数。闭包用的好的关键是要用的巧。
还有很多其他粒子,存在即合理,你永远可以相信闭包,除非它真的没有卵用。

闭包这个概念,在我的理解它只是一种因为变量所处的作用域和被使用的位置,而导致的一种结果。并不是说我写一个方法前,就说我要用闭包来实现这个功能。虽然这样说也没什么问题,但是听上去就很怪异,我并不是通过闭包来实现了这个方法,是因为写了这个方法而触发了闭包这个特性。

所以从严格意义上来说,我们写任何代码都是为了解决问题,而闭包就是可以为我们解决某类问题而存在的重要特性。

题主的问题可能是没理解到为什么会用到闭包,什么时候我们才需要用到这个特性?为什么许多优秀的框架或者库,都是会使用到闭包这个特性呢?

举个例子,react实现了hooks这个超级厉害的特性,它的useState方法是可以实现在某个组件内保持数据持久化的。比如下面这段代码

export default function (){
     const [data, setData] = useState([]);
     
     function setNewData() {
         setData([Math.random() * 100])
     }
     
     return (
         <>
           <span onClick={setNewData}>点击</span>
           <child myData={data}></child>
         </>
     )
}

这段代码我每次点击的时候都会调用setDatadata重新赋值一个随机数,而我的子组件会根据父组件传入的data变化。那么你想想,在这个过程中,useState是不是用到了闭包的思想呢?

datasetData是我从useState这个函数返回的结果拿到的变量,它是通过解构出来的。而这两个变量是一直存在于useState这个方法中的,并且被useState这个hook给维护了起来,实现了持久化数据。我们是通过把useState函数里的变量拿出来给外部使用了,并且通过setData来修改useState函数内部的变量值,这就导致了useState内部的变量是一直被引用且不会被销毁的,这里就产生了闭包的概念。

看到这里,题主应该明白,难道react团队在一开始做这个方案的时候,就想的是我们要用闭包来实现这个功能吗?我想不是的,闭包使用多了本身就会存在性能问题,如果可以不用闭包,当然就不用了。但正是因为react在实现这个方案时,发现无法避免的会触发闭包这个特性,所以反过来说,就是这个方案利用了闭包这个特性。

因为js没有私有变量. 只能通过闭包这种方法来解决.

你知道吗?

宣传栏