深入理解闭包之初步认识闭包

516 阅读3分钟

阳光.jpg

在 JavaScript 中闭包一直都是一个难以理解但又非常重要的概念,同时也是面试官考察面试者 JavaScript 能力强弱的常用手段。不过不用担心,我会结合代码,先介绍理解闭包的前置知识,再介绍闭包的基本概念,最后我会通过一道经典面试题巩固你对闭包的理解。我会描述的简单易懂,让初学者也能读懂。

一, 词法作用域

先看一段代码

   fuction foo(a) {
        const b = a * 2
        
        function bar(c) {
            console.log(a, b, c)
        }
        
        bar(b * 3)
        
   }
   
   foo(2) // 2 4 12
   

考虑函数 bar 的声明, 它里面的变量 a, b定义位置上处于 bar 的外层函数 foo 中,那么在调用 bar 时根据函数作用域链查找规则,从 bar 的声明内部一直向外查找,最终在 foo 中找到得 a, b 变量。像这样,在函数调用时,函数中的变量根据声明时的位置一级一级往上找,而不是根据调用时的位置往上查找就称为 词法作用域。关键点是函数声明时的位置,而不是调用时的位置

词法作用域根据名字也很好理解,词法是指写代码声明变量的位置来确定该变量在何处可用,注意是写代码时声明变量的位置

词法作用域是指函数中的变量在何处可用是由代码中函数声明的位置来决定的

为了检验你是否真正理解词法作用域,请思考如下代码

   function bar() {
       console.log(a);
   }
   
  function foo() {
      const a = 666;
      bar();
    }
    
    const a = 222;
    foo();

要正确理解上面的代码,请仔细思考词法作用域的概念。

二、闭包

再看一段代码

   function foo() {
      const a = 666

      return function () {
          console.log(a)
      }
   }

   const bar = foo()

   bar()  // 666

常量 bar 是 foo 函数调用的返回值, 而 foo 返回一个函数,所以 bar 是个函数,而且是 foo 内部的那个函数。调用 bar 后打印了 666,即变量 a 的值,a 只在 foo 中存在,意味着 bar 能访问 foo 中的变量。

像这样,函数互相嵌套,内部的函数引用外部函数中的变量,通过某种方式被传递出去,在外部调用就形成了闭包

这段话有两个关键点需要重点强调,一定要函数互相嵌套;一定要把内部函数传递出去;

一定要函数互相嵌套,是因为依据词法作用域,只有内部函数才能访问函数内部变量。一定要把内部函数传递出去,是因为只有传递出去了才能在外部访问函数内部变量并且把内部变量保持内存中。

私有变量和垃圾回收待续......

下面剖析一段经典代码,让你深入理解闭包。

三、一段代码

 for (var i = 1; i < 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

这段代码可谓非常经典,相信很多人都被面试过这道题。

今天我将深入骨髓的剖析这道题。

首先,这段代码输出 5, 5, 5, 5, 应该没有意见,我们来分析执行这段代码发生了什么。

  1, timer 中使用外部变量 i,在 setTimeout 中调用, 显然形成闭包
  
  2, 由于同步任务执行早于异步任务,for 循环中的 i 是全局变量导致 timer 调用时,i 已经变成了 5
  
  3, 所以打印 45

其次,改进代码打印 1, 2, 3, 4

根据上面的分析,问题出在因为异步执行, timer 使用的 i 不是循环调用 setTimeout 时的 i 了。哪么解题思路也就很明显。

思路: 只要 timer 执行时, 词法作用域的 i 是循环调用 setTimeout 时的 i, 就能解决问题

我们都知道 立即执行函数 中的匿名函数拥有独立的词法作用域。那么自然就可以利用立即执行函数修正词法作用域。

1,把 setTimeout 回调函数改造成立即执行函数

for (var i = 1; i < 5; i++) {
  setTimeout(
    (function timer(j) { // 利用函数参数,修正词法作用域
      return () => console.log(j);
    })(i), // 把 i 作为参数传入立即执行函数,相当于保存循环 i 的副本
    i * 1000
  );
}

2,用立即执行函数包裹整个 setTimeout

  for (var i = 1; i < 5; i++) {
      ((j) => { // 利用函数参数,修正词法作用域
        setTimeout(function timer() {
          console.log(j);
        }, j * 1000);
      })(i) // 把 i 作为参数传入立即执行函数,相当于保存循环 i 的副本
   }

3, 除了使用立即执行函数外,还可以借助块级作用域,使每次循环的 i 在单独的块级作用域

for (let i = 1; i < 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

至此我们完美打印出了 1, 2, 3, 4。

总结起来,核心是让 timer 的词法作用域能访问每次循环的 i 或 i 的副本。这道题的方方面都介绍完了,下次面试官再问你时,你可以怒怼面试官了。

到这里,你已经明白了闭包的基本知识,如果你想了解闭包的使用,请阅读系列第二篇文章深入理解闭包之广泛使用的闭包