Skip to content

一文搞懂闭包和闭包问题

1.闭包和闭包的应用场景

介绍闭包

闭包(Closure)是一种在编程中常见的概念,它指的是一个函数可以访问并操作其词法作用域(函数定义时所在的作用域)之外的变量的能力。换句话说,闭包允许函数“捕获”其外部环境中的变量,即使在函数执行后,这些变量也能保持在内存中

形成闭包的条件:

形成闭包的条件是在一个函数内部定义了另一个函数,并且内部函数引用了外部函数作用域中的变量。具体来说,以下两个条件需要同时满足才能形成闭包:

  1. 函数内部定义了另一个函数:闭包是由函数内部的函数(嵌套函数)形成的。这个内部函数可以是在另一个函数的代码块中定义的,也可以是作为函数表达式或返回值返回的。
  2. 内部函数引用了外部函数作用域中的变量:闭包的关键是内部函数捕获了外部函数作用域中的变量。这意味着在内部函数中可以访问并使用外部函数的局部变量、参数或者函数声明。

闭包的特点在于:即使外部函数执行结束,内部函数仍然可以访问外部函数作用域中的变量,因为内部函数的作用域链仍然保留了对外部函数作用域的引用。这样就使得外部函数的局部变量在内部函数中继续可用,即使外部函数已经执行完毕。

应用场景

以下是一些常见的情况,你可能会在这些情况下使用闭包:

  1. 保护变量作用域: 闭包可以创建一个私有作用域,防止变量被外部访问或修改。这在模块化和封装中非常有用。

    js
    function counter() {
      let count = 0;
      return function () {
        return ++count;
      };
    }
    
    const increment = counter();
    console.log(increment()); // 输出:1
    console.log(increment()); // 输出:2
  2. 回调函数: 闭包可以用于创建回调函数,使函数能够访问其定义时的上下文,而不是调用时的上下文。

    js
    function fetchData(url, callback) {
      fetch(url)
        .then((response) => response.json())
        .then((data) => callback(data))
        .catch((error) => console.error(error));
    }
    
    fetchData("https://api.example.com/data", function (data) {
      console.log(data);
    });
  3. 循环中的问题:(事件循环机制导致的闭包问题) 在循环中使用闭包可以解决变量在异步操作中的问题。——> 创建一个新的闭包(存储外面的循环变量到内存中)

    js
    for (var i = 0; i < 5; i++) {
      setTimeout(function () {
        console.log(i); // 输出:5 5 5 5 5
      }, 1000);
    }
    
    // 使用闭包解决:注意这里可以用let解决,但是原理不是闭包,而是块级作用域
    for (var i = 0; i < 5; i++) {
      //其实实际上这里是两层闭包了!
      (function (j) {
        setTimeout(function () {
          console.log(j); // 输出:0 1 2 3 4
        }, 1000);
      })(i);
    }
  4. 缓存数据: 闭包可以用于缓存函数的结果,避免重复计算。

    js
    function memoize(func) {
      const cache = {}; //一个hash表
      return function (arg) {
        if (cache[arg] === undefined) {
          cache[arg] = func(arg); //根据传进来的参数,判断是否计算过这个参数相关的结果了,如果缓存没有值,就执行函数
        }
        return cache[arg]; //如果缓存有值就用缓存值
      };
    }
    
    const expensiveCalculation = memoize(function (n) {
      console.log(`Calculating for ${n}`);
      return n * 2;
    });
    
    console.log(expensiveCalculation(5)); // 输出:Calculating for 5,然后返回 10
    console.log(expensiveCalculation(5)); // 直接返回 10,无需重新计算

这些只是使用闭包的一些例子,实际上闭包在 JavaScript 编程中有广泛的应用,可以用于实现许多不同的模式和技术。

总结:说白了主要是在定义高阶函数的时候,返回一个函数,这个函数去使用外层函数的参数或者外层定义的变量,这样就相当于应用了闭包

1.构造回调函数高阶函数:比如 react 中构造一个 input 的 onChange 事件回调函数:根据属性名(这个就是参数)的不同,生成用于绑定不同属性的回调函数(返回值),将表单数据保存到状态中

2.比如构造一个可以缓存函数的执行结果的闭包函数,结果定义为变量放在外层函数,执行逻辑放在内层(根据缓存是否有值判断应该返回缓存值,还是再执行一次函数)

3.解决循环中的异步任务获取的作用域(应该给循环的每一个异步任务单独开启一个作用域)

4.构造计数器,计数变量应该被保护作用域,用闭包来保护作用域

2.闭包问题以及两种解决方案

image-20230731011820142

在这段代码中,循环将会执行 10 次,每次循环都会设置一个定时器 setTimeout,每个定时器都会在 1000 毫秒(1 秒)后执行一个函数。——> 实际上本身已经存在一层闭包了,每一个 setTimeout 都是一个闭包函数(for 定义的变量是外层函数的变量)

然而,由于 JavaScript 的事件循环机制,循环会在很短的时间内完成所有的迭代,而定时器的回调函数在循环执行完毕后才会被调用(因为定时器是宏任务)(但是没有被调用的时候变量无法持久化在内存里面,这是导致闭包问题的根本原因)。这意味着定时器回调函数实际上会在循环结束后,统一在一个时间点被调用,而不是在每次循环中分别被调用。

由于这个机制,当定时器回调函数执行时,循环已经执行完毕,此时 i 的值已经变成了 10。因此,无论定时器执行多少次,输出的结果都将是 10,而不是从 0 到 9。

重点:首先要明确闭包把变量持久化到内存的时机是在函数被调用的时候

在闭包的情况下,当一个函数被定义时,它会创建一个闭包,其中包含了函数内部引用的外部变量。这些外部变量在闭包中被“捕获”,并且在函数执行时存储在内存中。然后,当这个闭包被返回并保存在其他变量或数据结构中时,闭包内的变量将继续存在,即使函数调用已经完成。

考点:事件循环机制导致的闭包问题

事件循环机制导致的闭包问题通常与异步编程有关。在 JavaScript 中,事件循环是一种处理异步代码的机制,它允许在主线程上执行代码,并在非阻塞的情况下处理异步任务(先把异步任务放到异步线程)。这使得 JavaScript 可以处理网络请求、定时器、事件监听等异步操作。

闭包问题(也就是作用域导致获取变量值不准确的问题)通常在使用循环中创建闭包时会出现。

闭包是指函数可以访问并操作其定义时所在的词法作用域中的变量

闭包问题的概念:在循环中使用闭包时,由于闭包延迟执行或在异步场景中使用,闭包中可能会捕获到循环变量的引用,而不是其当前值。这可能导致在回调函数执行时,循环已经结束,循环变量具有最终值,而不是在闭包中创建时的期望值。

上面的题干就形成了闭包问题!

闭包问题形成的三个原因:

1.事件循环机制,定时器在最后才延迟运行

2.闭包时闭包函数(内层函数)没有被调用的时候变量无法持久化在内存里面

3.let 声明的变量是函数作用域,会在每一次 for 循环中共享,而不是重新创建

闭包问题的解决

为了解决闭包问题,我们需要在每次循环迭代中创建一个新的作用域,以便捕获正确的 i 值。通常可以使用立即调用函数表达式(IIFE)或 let 关键字来解决这个问题:

使用 IIFE:其实实际上这里是创建一个新的闭包,一共变成了两层闭包!

js
for (var i = 0; i < 3; i++) {
  (function (index) {
    setTimeout(function () {
      console.log(index);
    }, 1000);
  })(i); //这是一个立即调用函数表达式。它的目的是在每次循环迭代时创建一个新的函数作用域,以保留当前循环迭代的 i 的值,从而避免 JavaScript 事件循环机制导致的闭包问题。
}

使用 let

js
for (let i = 0; i < 3; i++) {
  //let 变量具有局部作用域(块级作用域),在使用 let 关键字定义的变量,在每次循环迭代时都会创建一个新的绑定。这意味着在循环中使用 let 声明的变量在每次迭代时都拥有一个全新的独立的作用域,而不像使用 var 声明的变量那样只有一个共享的作用域。
  setTimeout(function () {
    console.log(i);
  }, 1000);
}

通过这样的方式,每个定时器回调函数都会捕获到正确的 i 值,输出结果将是 0、1 和 2。

3.let 块级作用域的理解,以及为什么可以解决闭包问题

函数作用域和块级作用域的对比

1.函数作用域 function{}:函数里面任意层次(任意块级)定义的变量在函数任何地方都可以访问(块的外层也可以访问)——> 相当于在函数里层定义的变量也会被提升到函数的第一层来,这样里面任意一层都可以访问!

例子:

js
function example() {
  var x = 10;
  if (true) {
    var x = 20; // 在同一个函数作用域内,因为这行代码在后面,所以x 被覆盖了
  }
  console.log(x); // 输出 20,访问的是if里层定义的x
}

2.块级作用域{}:函数里面的块里面定义的变量只能在本块或者更里层可以访问,块的外层不可以访问

例子:

js
function example() {
  let x = 10; //if里面是新的块级作用域,新定义的x不会影响外面x的访问!
  if (true) {
    let x = 20; // 在不同的块级作用域内,不会被外面访问
  }
  console.log(x); // 输出 10,访问的还是外层块的x
}

var 定义的变量的函数作用域产生的问题: 用 let 定义的变量的块级作用域来解决

var 定义的变量在 JavaScript 中具有函数作用域(function scope)而不是块级作用域(block scope)。这意味着使用 var 定义的变量在整个函数体内都是可见的,而不仅仅是在定义它的代码块内部。

这种行为可能会导致一些意外和不符合预期的结果。下面是一些体现 var 没有块级作用域的示例:

  1. 变量泄漏到外部的问题:

    js
    function example() {
      if (true) {
        var x = 10;
      }
      console.log(x); // 输出 10,x 在 if 块外部仍然可见
    }
  2. 循环中的问题:(闭包问题)

    js
    for (var i = 0; i < 5; i++) {
      setTimeout(function () {
        console.log(i); // 输出 5 五次,因为 i 是共享的变量
      }, 1000);
    }

    由于 var 声明的变量是函数作用域,会在每一次 for 循环中共享,而不是重新创建,所以最后定时器获取的都是那一个变成 5 的变量!

    使用 let 声明变量来解决

    要解决这个问题,你可以使用 let 关键字代替 var 来声明变量,let 具有块级作用域,因为在每次循环迭代时都会新建一个块级作用域,所以每次循环都会创建一个新的变量实例,从而避免上述问题。

    js
    for (let i = 0; i < 5; i++) {
      setTimeout(function () {
        console.log(i);
      }, 1000);
    }

    在使用 let 声明的情况下,每个闭包都持有一个不同的 i 值,因此会按预期输出从 0 到 4。

  3. 函数内部变量被覆盖的问题:——> 其实也是变量泄漏到外部的问题

    js
    function example() {
      var x = 10;
      if (true) {
        var x = 20; // 在同一个函数作用域内,因为这行代码在后面,x 被覆盖了
      }
      console.log(x); // 输出 20,访问的是if里层定义的x
    }

    使用 let 定义变量来解决

    如果在同一个作用域内使用 let 声明变量,它会遵循块级作用域规则,不会像 var 那样被覆盖。因此,使用 let 声明变量的情况下,内部作用域的变量不会影响外部作用域的同名变量。

    让我们通过示例来理解:

    js
    function example() {
      let x = 10; //if里面是新的块级作用域,新定义的x不会影响外面x的访问!
      if (true) {
        let x = 20; // 在不同的块级作用域内,不会被外面访问
      }
      console.log(x); // 输出 10,访问的还是外层块的x
    }

上面我们了解到因为let具有块级作用域(每次 for 的时候都新建 let 变量)从而解决了闭包问题!

那么 js 都在什么情况下会新建块级作用域{}?

在 JavaScript 中,块级作用域(block scope)是指由一对花括号 {} 创建的范围,其中的变量在这个范围内是可见的,并且在范围外不可访问。在以下情况下会创建新的块级作用域:——> 每一个{}就是 let 的作用域

注意:var 的作用域是函数作用域 function{},变量的访问范围:函数作用域>块级作用域

  1. 函数体内部: 函数是 JavaScript 中的一个主要的块级作用域单元。在函数内部定义的变量在函数范围内可见,而在函数外部不可访问。(类似函数作用域,区别在于只能在这个函数的第一层使用)

  2. 条件语句中的代码块: 使用 ifelseswitch 等条件语句时,它们的代码块中也会创建新的块级作用域。在这些代码块中定义的变量仅在其所在代码块内部可见。——> 所以使用 let 可以解决上面的闭包问题

  3. 循环语句中的代码块: 类似于条件语句,循环语句如 forwhiledo-while 也会创建新的块级作用域。在循环体内部定义的变量只在循环体范围内有效。——> 重点

    因为let具有块级作用域,所以在for循环新建块级作用域的时候就会新建for里面定义的let变量!

总之,在任何花括号 {} 内部定义的 let 块级作用域变量都会限制在只能在其所在的块级作用域中可见,这种作用域限制可以提高代码的可维护性和避免变量冲突问题。