浅析JS中的闭包

由 月琳 发布
  | 2027 次浏览

闭包是什么

且看官方的解释:

闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment词法环境)的引用的组合。

——《MDN》

初次看到这个解释确实晦涩难懂,毕竟它还涉及到了作用域链和执行上下文环境。

其捆绑的周边环境状态 指的就是执行上下文,可以简单理解为代码执行的环境,存放了对应的变量。

执行上下文的特性之一就是:当一个函数被调用结束之后,其执行上下文环境就会被销毁,销毁之后就无法再读取到其中的变量

而闭包却打破了这个特性,维持住了函数的执行上下文不被销毁,内部变量得以维持在内存中。

后文中会进行相关分析,或许能帮助读者理解上述概念。同时也推荐去了解一下作用域链和执行上下文环境,会有所帮助。(其实我的理解也不深,啊这)


什么情况下形成闭包

我们先来看看形成闭包的简单代码:

// 外部函数
function counter() {
    var value = 1;
      // 内部函数
    function getValue() {
      return value;
    }
    return getValue; // 返回内部函数
}
var myCounter = counter();
console.log(myCounter()); // 输出 1

分析这段代码,

当第 10 行执行 var myCounter = counter(); 时,执行了 counter 函数,而 getValue 子函数作为返回值就赋值给了 myCounter 变量,即此时 myCounter = getValue。但是需要注意,此时 myCounter 并没有执行 getValue 函数,仅仅是赋值了引用,毕竟在 JS 中函数即变量

在第 11 行执行 myCounter() 时,其实就相当于执行了 counter 函数内的 getValue() 函数,因此会输出 value 的值 1。

那么到这你就会发现,value 这个变量是在 counter 函数内部定义的,按理来讲,在这个 counter 函数的外部应该不能直接调用获取它里面的变量,因为跨作用域了。而在上述代码中,通过在 counter 函数暴露出子函数 getValue给外部调用,就能间接让函数外部访问内部的 value 值。

这种在函数外部调用其公开的内部函数,以此访问函数的私有属性的方式,就形成了闭包。闭包相当于将函数内外连接起来的桥梁。

其特性就是,函数的私有属性在函数执行完成之后仍然可用,栈上的内存空间(执行上下文)在函数返回之后仍在存在,不被 GC 回收。


分析上下文执行栈

在此之前我们需要知道,作用域不等同于执行上下文。作用域在代码定义时就已经确定,不会再改变,而执行上下文在运行时确定,随时可能改变。说白了执行上下文就是函数执行阶段占用的一块内存空间,自然会发生改变了,例如执行结束时销毁这块空间。

之后我们对上文中的代码进行深入分析,

首先分析作用域,如下图所示

闭包-1

接着开始分析执行上下文,

第一步,代码在执行时先进入全局作用域,自然就创建了全局执行上下文,并对其中的变量进行赋值(图中省略了其他变量),此时全局上下文环境处于活动状态

如下图所示

闭包-2

第二步,执行到第 19 行代码时,先执行 counter(),创建 counter 的执行上下文环境,并压入内存栈中。将 counter上下文 设置为活动状态,而 全局上下文 则闲置等待。

如下图所示

闭包-3

第三步,同样在第 19 行代码,counter()执行完成,一般情况下这时的 counter 的执行上下文会被销毁,但这里却是个例外

由于 getValue 函数变量(JS 中函数即变量)被 counter 函数返回,挂载到了外部的全局上下文环境中。即使 getValue 函数在此时仍未执行,但由于其内部存在对 value 的引用,那么 counter 上下文环境就不能销毁,否则 value 也会被跟着销毁,导致 myCounter 执行时无法找到 value 的值。

于是,counter 的执行上下文依然存在于栈中,如下图所示

闭包-4

第四步,执行第 21 行代码,创建 getValue 的执行上下文环境,输出它的返回值。

如下图所示

闭包-5

最后,代码全部执行完毕,所有执行上下文均销毁。

经过上述分析,再结合前文中的官方对于闭包概念的解释:

闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment词法环境)的引用的组合。

想必读者应该能理解闭包究竟为何物;

例如上述代码中,本应销毁却保持在内存中的 getValue 以及它对应的执行上下文环境,就是闭包。


闭包的优点

  1. 闭包可以读取另一个函数作用域中的变量

    如上文所见。

  2. 实现面向对象语言中的私有属性

    熟悉 Java 等面向对象语言就比较清楚,一个对象可以有公开属性也可以有私有属性,私有属性不能被外部直接访问,只能通过公开的方法去间接访问。JS 通过闭包就能实现类似这种效果。

  3. 私变变量的命名不污染当前的作用域

    函数内部的私有变量无论怎么命名,都不会干扰外部的作用域,因此使用闭包的时候就无需在意它所在函数里的变量命名是否会冲突了。

闭包的缺点

  1. 不注意清除引用就可能导致内存泄漏

    正常的函数执行完之后,函数里面的局部变量(私有属性)就会被 GC(垃圾回收机制) 回收释放,大部分带有GC 特性的编程语言皆是如此。

    而闭包就比较特殊了,由于函数的私有属性被函数外部所引用,而被引用的变量不会被 GC 所回收,一直保持在内存中,直到外部变量也不再引用时,才会被回收。

    因此如果不注意清除外部的引用的话,该变量就会一直占用着内存空间,导致内存泄漏。

  2. 过度使用闭包会导致性能下降

    如果想利用闭包实现面向对象,却并没有利用到闭包的优点,仅仅只是为了面向对象而使用闭包,那意味着每次创建对象,内部的属性和方法都会重新赋值,影响性能。这种情况官方更推荐使用原型链替代。详情可以阅读 MDN 闭包文章的末尾


经典案例

经典的计数器

const increase = (function () {
  let count = 0;
  return function () {
    return ++count;
  }
})(); // 使用了自执行函数
increase(); // 此时返回值为 1
increase(); // 此时返回值为 2

另一种计数器

function Counter() {
    let count = 0;
    return {
        increase: function(){
            return ++count;
        },
        value: () => count, // 箭头函数也行
    }
}

var c1 = new Counter();
var c2 = new Counter();
console.log(c1.value()); // 0
c1.increase();
console.log(c1.value()); // 1
console.log(c2.value()); // 0

这里创建了两个 Counter,分别是 c1c2实例,在第 14 行经过执行 c1increase 函数后,c1 内的 value 发生了变化,但却没有影响 c2 内的 value

这是因为同个函数(Counter)的不同实例(c1、c2),它们的执行上下文是相互独立的。


实际应用——业务封装

闭包结合 ES6 解构赋值,可以非常方便地将函数按照特定的业务封装,

比如定义一个计数器可以这样实现:

// 计数器模块
const useCounter = function () {
  let count = 0; // 计数器值
  // 累加函数,返回累加后的值
    const increase = () => {
        return ++count;
  }
  // 用于获取内部值的函数
    const getValue = () => {
        return count;
    }
  return { getValue, increase };
};
const { getValue, increase } = useCounter(); // 使用 ES6 的解构赋值
console.log(getValue()); // 注意这里是函数调用,输出:0
increase(); // 调用累加函数
console.log(getValue()); // 输出:1

当我们使用该函数的时候,无需在意内部是如何实现的,也无需担心作用域污染,只管用就行了。

上述代码的缺点就是获取函数内部的值需要调用函数(getValue)才能获取,并且获取的值与函数内部的值构不成引用关系

例如在上述代码继续添加以下代码:

...连接上文代码
let count = getValue()
console.log(count); // 输出:1
increase(); // 调用累加函数
console.log(count); // 还是输出 1
console.log(getValue()); // 调用函数去读取内部值,输出 2

看吧,因为内部的值是非引用变量,即使获取了也不再是同一个引用,因此每次读取都需要执行一次 getValue 函数。

既然是非引用对象的问题,那么换成引用对象不就能保持数据的同步吗?

于是同样的计数器可以这样写:

// 计数器模块
function useCounter() {
      // 引用数据类型
    const data = {
        count: 0,
    }
    // 定义一个累加函数
    const increase = () => {
        data.count++;
    }
    return { data, increase }
}
// 取得函数内部的值
const { data, increase } = useCounter(); // 使用 ES6 的解构赋值
console.log(data.count); // 输出:0
increase(); // 调用累加函数
console.log(data.count); // 输出:1

这样我们就能直接读取内部的值而不需要通过函数调用去获取了。

不过引用对象就意味着可以在外部随意更改 data 内部的属性,就像这样:

...承接上文代码
data.count = 9999;
increase();
console.log(data.count); // 输出 10000

这样会导致函数内部变量值的变得不可控,可能难以追踪。

其实看到这你就会发现,这玩意怎么这么像 Vue 3 中的 组合式函数?其实,组合式函数也是利用了闭包的一些特性,只要将上文的 useCounter 函数内部的 data 变量的数据类型换成 Vue 中的 Ref,就几乎跟组合式函数一模一样了。

那么文章就到此为止了,等有时间再分析并实现 Vue 3 中的响应式数据吧。

作者涉(JS)世不深,若有哪里不对,欢迎纠正。


参考资料

MDN-闭包:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Closures

深入理解javascript原型和闭包(15):https://www.cnblogs.com/wangfupeng1988/p/3994065.html


版权属于:月琳cc
本文链接:https://kerrinz.com/archives/483.html
作品采用《知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议》进行许可,转载请务必注明出处!

暂无评论

发表评论