精读JS(五) 函数闭包

638 阅读6分钟

前言

本文专门介绍闭包,但事实上,闭包的难点并不在概念,而是在词法环境的嵌套上。只要将词法环境的嵌套关系整理清楚,闭包就瞬间被克服了。(或是去看看Python……)

总之,先不废话了,正文开始。

闭包

如果一个函数在定义的词法环境外运行并记住了定义时的词法环境,这样的现象就可以称作函数闭包(Function Closure) 举个简单例子:

    function f(x=0){
        var count=x;
        
        function getCount(){
            return count++;
        }
        return getCount
    }
    var func = f(10);
    console.log(func());    // 10
    console.log(func());    // 11
    console.log(func());    // 12
    console.log(func());    // 13
    console.log(func());    // 14

首先要明白上面代码究竟发生了什么,梳理一下过程:

  1. 函数f返回了函数getCount的引用, 并将局部变量count设为了10
  2. 外部词法环境中, func指向了getCount

然后在执行func时就很神奇的进行了叠加。 为了直观表示,我就是用图示法表示环境绑定了(也可以用执行上下文伪代码):

在这里插入图片描述

注意: funcgetCount没有在同一个词法环境。

那么执行过程就是:

  1. func引用自getCount所指向的函数。
  2. 调用func会进入getCount的词法环境
  3. 解析标识符count,在当前词法环境中未找到,进入外部词法环境
  4. 外部词法环境找到,返回值后叠加。 --> 保存词法环境状态
  5. 再次调用func时,会再次访问外部词法环境,访问count,此时count为11,然后返回。
  6. ……

可以注意到:形成函数的闭包的关键在于:它会保存外部词法环境的状态。 但是为什么会这样?

闭包实现 I: 执行上下文也会创建外部环境
  1. 函数调用时,会为该函数创建一个执行上下文
  2. 执行上下文中会创建当前词法环境环境记录,记作CurrentEnvRec
  3. 除了会创建CurrentEnvRec,还会创建外部词法环境的环境记录, 记作OuterEnvRec
  4. 如果OuterEnvRec还有外部词法环境,那么继续创建OuterEnvRec的外部词法环境的环境记录 ……
  5. 上面的过程一直延伸到全局词法环境截止,上面的词法环境形成了一个链表,就是众所周知的作用域链(Scope Chain)了。

环境记录中提到过,环境记录用于记录当前词法环境标识符状态的对象;只要环境记录中的变量存在,那么就可以访问

这个作用域链会保存在函数[[Scope]]内部属性中, 可以在调试中可以看到:

在这里插入图片描述

注意: Chrome中,Google浏览器专门对console.log做了优化,因此可以通过func.prototype看到这个属性值。

闭包实现 II: [[Scope]]不会被删除。

Javascript另外一个特别的地方是:一切皆对象。在Javascript中采用的是标记清除算法释放内存的;简单来说,如果对象可以被访问到,那么就会一直带有一个标记,当对象再也无法被访问到时,那就去除标记,在下一个遍历周期中被释放。

  • 因为函数也是一个对象,并且保存了[[Scope]]属性的值。
  • 因为[[Scope]]一直在引用环境记录
  • 所以[[Scope]]中的环境记录会一直被保存。
  • 所以函数总能够访问到外部词法环境的值并且能够一直更新
  • 所以即便是执行上下文被销毁,函数的词法环境也没有消失。再次创建该函数的执行上下文时,也只是重新指向函数的词法环境而已。

闭包陷阱

一般情况下,不会用上面那种麻烦的形式,而是直接会返回一个匿名函数:

   function f(x=0){
       var count=x;
       
       return function(){
           return count++;
       }
   }

或是更加精简:

 let f=  (x=0)=>{
     var count=x;
     return ()=>count++
 }

偶尔,会遇到需要多个函数闭包的情形,即:

  let f = ()=>{
      let func = []
      for(var i = 0; i < 3; i++){
          func.push(()=>{
              return i*i;
          })
      }
      return func 
  }

这样,f1应该输出0f2输出1f3输出4 …… 本应该这样才对。 但是结果却是:


  let [f1,f2,f3]=f();
  console.log(f1());  // 9
  console.log(f2());  // 9
  console.log(f3());  // 9

TMD是又为何?

道理很简单,因为使用了var声明的变量var声明的变量是没有块级作用域的,上面的代码在逻辑上,等价于:

  let f = ()=>{
      let func = []
      var i
      for(i = 0; i < 3; i++){
          func.push(()=>{
              return i*i;
          })
      }
      return func  /// (*)
  }

所以整理一下就是:

  1. 因为 func[0] / func[1] / func[2]三个闭包函数的外部词法环境都是循环体的词法环境函数f的词法环境
  2. 在查询标识符i的过程中, 在循环体的词法环境中未找到标识符i,所以将使用函数f的词法环境中的标识符i
  3. 调用f1 / f2 / f3时,循环体已经结束, 所以 标识符 i最终值为3。所以最终皆返回 9

我们知道,导致最终结果都相同的原因是f1/f2/f3在解析标识符i的过程中使用的都是函数词法环境的标识符i

一图胜千言:

在这里插入图片描述

所以: 可以在函数外部创建一个立即执行函数表达式,我们可以直接使用这个函数作用域中的标识符x

 let f = ()=>{
     let func = []
     var i
     for(i = 0; i < 3; i++){
         func.push((function(){
             var x=i;
             return ()=>x*x;
         }()))
     }
     return func 
 }

词法环境嵌套图:

在这里插入图片描述

或是直接用循环体的作用域:

  let f = ()=>{
      let func = []
      var i
      for(i = 0; i < 3; i++){
          let x = i;
          func.push(()=>{
              return x*x;
          })
      }
      return func 
  }

词法环境嵌套图…… (自己尝试下)

这样每次都会声明一个x并保留标识符i的值,这时正确输出:

  let [f1,f2,f3]=f();
  console.log(f1());  // 0
  console.log(f2());  // 1
  console.log(f3());  // 4

知道了前因后果,可以再对上面的代码加以简化。 let声明的计数变量每次都会重新声明,并以上一次循环结束后的值作为初始化计数变量。(详见规范CreatePerIterationEnvironment条目)即:

  let f = ()=>{
      let func = []
      for(let i = 0; i < 3; i++){
          func.push(()=>{
              return i*i;
          })
      }
      return func 
  }

okay.

这时词法环境嵌套图是这样子的:

在这里插入图片描述

补充: setTimeout

我记得有个经典面试题,是这样的:

    for(var i =0;i<6;i++)
       setTimeout(()=>{console.log(i)})
    // 输出 666666

我觉得这个,理解了上面的示例,这个应该不成问题; 但是要注意,里面有个坑存在,那就是这样魔改一下:

    // 
   for(var i =0;i<6;i++)
        setTimeout(console.log, 0, i)
    // 0-5

它是正常输出的, 因为setTimeout接收了当前i的值作为参数后,会在函数内部将i参数传递给console.log。: 即:

   setTimeout(F, 0, x) 
// 在其setTimeout内部实现上,会有:
   function setTimeout(F, delay, ...x){
       // ....其他代码
        F.apply(thisObj, x)
        // ...
   }    
   window.setTimeout=setTimeout;

备注: setTimeoutdelay,其实是最小延迟时间, 但是HTML标准规定,但凡是小于4ms皆以4ms计算

最后

下一篇,开始异步。 要牢记一句话: 进阶要有深度,学习不要总被套路