什么是闭包?

2024/09/02 JavaScript 共 3662 字,约 11 分钟

什么是闭包 (Closures)

在讨论闭包之前,首先看看官方介绍

闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。

从定义中可以看到,闭包和词法环境( lexical environment )有很大的关系。 那么什么是词法环境呢? 这里先卖个关子后面介绍, 我们先从另一个角度——内存模型来切入。

内存模型与闭包

在程序运行时,变量的声明、使用和销毁都有一个生命周期。一般来说,当一个变量所在的作用域执行完毕后,内存就会被回收,这个变量也不再可访问。但是,如果存在某种机制可以让变量的生命周期延长,即使超出了它所在作用域的执行,也仍然能存活下来。闭包正是这样的一种机制。

JavaScript 中,全局变量、闭包和异步操作都能延长变量的生命周期。你可能已经注意到,这些操作类似某种形式的内部函数对外部函数的访问。

词法环境(Lexical Environment)

先查看 ECMA规范 了解 词法环境 是什么

词法环境是一种规范类型,用于定义标识符s 到基于词法嵌套的特定变量和函数 ECMAScript 代码的结构。词法环境由环境记录和对外部词法环境的可能 null 引用组成。通常,词法环境与 ECMAScript 代码的一些特定语法结构,例如函数声明一个Block语句或抓住子句的TryStatement 语句并且每次评估此类代码时都会创建一个新的词法环境。

简单来说:

  • 词法环境 = 环境记录 + 外部环境引用
  • 环境记录 可以暂时理解为当前作用域环境下的变量和方法(详细内容将在执行上下文中展开)
  • 外部环境引用 指向上级词法环境(如果存在)(详细将在作用域链文中展开)
  • 在代码执行期间,每当 JavaScript 引擎遇到一个新的代码块、函数或 catch 子句时,就会创建一个新的词法环境。

举个栗子:

let data = null;
function test() {
  let name = null;
  console.log(data);
}

在上面的代码中,存在两个作用域:全局作用域和函数 test 的作用域。

  • 全局作用域:由于没有上层作用域,所以它的外部词法环境为 null。
  • test 函数的词法环境:包含当前作用域下的变量(如 name),以及对上层全局环境的引用

因此,test 函数的词法环境由它自己的变量(name)和外部的全局环境(data 等)组成。

闭包定义解释

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

闭包绑定了它定义时的词法环境,这就是为什么子函数在离开父函数的作用域后仍然可以访问父函数的变量。

举个栗子说明:

function t1() {
  let data = null;
  function t2() {
    console.log(data);
  }
  return t2;
}

let t_fn1 = t1();
t_fn1();

我们可以通过模拟函数调用栈观察这一行为:

  1. 首先全局环境入栈

     [ 全局词法环境 (环境记录: { t1, t_fn1 } + 外部词法环境: null) ]
    
  2. 调用 t1(), t1 环境入栈

     [
       t1 词法环境 (环境记录: { data, fn } + 外部词法环境: 全局词法环境),
       全局词法环境 (环境记录: { t1, t_fn1 } + 外部词法环境: null)
     ]
    
  3. 调用 t_fn1(), t2 环境入栈

     [
       t2 词法环境 (环境记录: {} + 外部词法环境: t1 词法环境),
       t1 词法环境 (环境记录: { data, fn } + 外部词法环境: 全局词法环境),
       全局词法环境 (环境记录: { t1, t_fn1 } + 外部词法环境: null)
     ]
    

可以打开浏览器验证这一行为,如下图

从这个示例中,我们可以得出一些结论:

  • 变量是由环境记录中获取。
  • 如果某个词法环境被引用,它不会被销毁。
    • 那么这一行为就延长了 t1 词法环境 的生命周期。

疑问

好现在我再抛出几个疑问

  1. t1 函数会形成闭包吗?

     function t1() {
       let data = null;
       function t2() {}
       return t2;
     }
    
  2. 如果通过全局变量引用 t1 的活动对象,t1 执行完毕后,该对象仍然存活,这会形成闭包吗?

     let data = null;
     function t1() {
       let record = {};
       data = record;
     }
    
     function t2() {
       console.log(data);
     }
    
     t1();
     t2();
    
  3. 上述两种方式延长变量生命周期的方式存在区别吗?

闭包的优缺点

根据上面的分析,你可能会惊讶的发现: 所有的函数都会包含周边词法环境。 某种程度上来讲全局作用域下所有函数都是闭包。

为了方便后续理解这里做一个区分 (非官方定义):

  • 隐式闭包 函数在其内部不使用外部环境中的变量

    function test() {
      console.log("???")
    }
    
  • 幽灵隐式闭包 函数在其内部不使用外部环境中的变量

    function t1() {
      let data = null;
      function t2() {}
      return t2;
    }
    
  • 显示闭包 函数在其内部使用了外部环境中的变量

    function t1() {
      let data = null;
      function t2() {console.log(data)}
      return t2;
    }
    

需注意虽然 隐式闭包幽灵隐式闭包 看上去没有使用外部词法环境人畜无害; 但他们的情况完全不一样。

  • 对于 test 函数执行完即可被销毁。
  • 对于 幽灵隐式闭包 t1 函数执行完并不会销毁,因为 t2 存在对其 外部词法环境 引用。
    • t1 只会在 t2 所有引用它的闭包被销毁之后才会被回收。

(回收这块详细见垃圾回收文)

闭包的优缺点总结

  • 缺点 内存不能及时释放,可能导致内存泄漏
  • 缺点 性能开销高,多层嵌套会保留每层词法环境
  • 优点 提供数据封装私有化
  • …more

如何使用

根据上面对闭包原理及其优缺点了解之后, 日常编码中该如果使用闭包,以规避内存泄漏和性能问题

  1. 消除不必要的 幽灵隐式闭包

    在这种情况下,闭包的存在并没有任何意义,反而会导致不必要的内存占用和性能开销。

    官方就已经提供了很好的示例

     function MyObject(name, message) {
       this.name = name.toString();
       this.message = message.toString();
       this.getName = function () {
         return this.name;
       };
    
       this.getMessage = function () {
         return this.message;
       };
     }
    

    优化调整:

     function MyObject(name, message) {
       this.name = name.toString();
       this.message = message.toString();
     }
     MyObject.prototype = Object.create({
       getName() {
         return this.name;
       },
       getMessage() {
         return this.message;
       },
     });
    
  2. 谨慎使用闭包多层嵌套。

    深层次的嵌套闭包会导致每层的词法环境被保留在内存中,尤其在复杂的应用中,容易造成性能开销。避免使用多层嵌套闭包可以减少内存占用并提高性能。

    如果需要嵌套闭包,确保每一层闭包的存在都是必要的。

     // 非必要的多层嵌套闭包
     function outer() {
       let outerVar = "outer";
       function middle() {
         let middleVar = "middle";
         function inner() {
           console.log(outerVar, middleVar);
         }
         return inner;
       }
       return middle;
     }
    

    优化调整:

     function outer() {
       let outerVar = "outer";
       let middleVar = "middle";
          
       return [outerVar, middleVar];
     }
    
     function inner(outerVar, middleVar) {
       console.log(outerVar, middleVar);
     }
    
     inner.apply(null, outer())  
    
  3. 及时释放不再需要的闭包

  4. 小心 this指向, 避免内存泄漏。

总结

通过本文,我们了解了:

  1. 闭包的定义和工作原理: 闭包( closure )是一个函数以及其捆绑的周边环境状态( lexical environment ,词法环境)的引用的组合; 词法环境是由当前作用域中的变量和对外部环境的引用组成。

  2. 词法环境的结构和生命周期: 词法环境由环境记录和对外部词法环境的引用组成。当 JavaScript 代码块、函数或其他作用域被执行时,会创建新的词法环境。 如果词法环境被引用,他的生命周期会被延长。

  3. 闭包的缺点: 由于闭包保留了其词法环境的引用,会导致内存不能及时释放,可能造成内存泄漏和性能问题,特别是在深层嵌套闭包的情况下。

  4. 如何合理使用闭包:

    • 避免不必要的闭包。
    • 谨慎使用多层嵌套的闭包。
    • 及时释放不再需要的闭包。
    • 小心 this 的指向,避免内存泄漏。

参考文章:

文档信息

Search

    Table of Contents