【JS启示记】—闭包之争可休矣

630 阅读7分钟

前言

“闭包”,可以排进JavaScript最难理解概念的前三,连那些正在从事“前端”职业的人,可能都没懂。

这么说不是吓唬人,它并不难,但它的名字本身有点不友好,“闭”什么?“包”什么?“闭什么包”?(此处自行脑补马某梅~)

技术圈儿里从不缺少这类一部分人很懂,一部分人很不懂的晦涩概念。

所以,在此先把名字的意思重塑一下,闭包 == 封闭 + 包裹

作用域

“闭包”就是一种突破数据访问能力的方法

那么本身的访问机制是怎样的呢?

我们知道,JavaScript的作用域,分全局和局部,局部其实也可称为“块级”。

“块”即一对大括号包含的代码块。

    {
      // 代码块
    }

不论是循环,还是条件判断语句,都在此范畴,但是在ES6之前,这样的包裹没有起到限制作用域的效果,所以,JavaSctipt的局部作用域通常由“函数”构成。

变量定义在全局,就全局可访问,甚至修改,但定义在局部,只在局部可用,外部不可访问。

函数就是闭包?

相信很多人曾为搞懂“闭包”看过不少资料,看着看着,就会看到这句话“函数就是闭包”。

这句话简直是雪上加霜,为理解“闭包”增添了新一层迷雾。

别怕,我们来段代码:

    function outer() { 
      var a = 2;
      function inner() { 
        console.log( a );
      }
      return inner; 
    }
    var global = outer();
    global(); // 2 

得嘞,这就是闭包达成的效果~

我特意使用了方便理解的命名方式,这段代码的特点是:

  • 全局作用域定义了一个函数 outer
  • outer 内部嵌套定义函数 inner
  • inner 访问了 outer 的变量a
  • outer 执行结果返回 inner
  • 返回的结果赋给了另一个全局变量 global

于是,效果就是,global 成功访问了 outer 的所管辖区域的变量

这一路看下来是不是觉得挺正常的?可是,它本来无法访问的呀~

先来看一句话:

JavaScript的函数可以嵌套在其他函数中定义,这样它们就可以访问它们被定义时所处的作用域中的任何变量。这意味着JavaScript函数构成了一个闭包(closure),它给JavaScript带来了非常强劲的编程能力。

这句话来自《JavaScript权威指南》函数章节的引言,有两个重点:

一、函数构成闭包 二、可访问它被定义时所处的作用域

所以,函数并不是闭包,而是闭包形成的土壤,上例中的global只是作为一种引用标识符,调用的还是内部的inner。

可访问的,不是函数被调用时的作用域,而是定义时,这么一说,似乎一切都合理了。

或许你有一种常识——垃圾回收机制,不再使用的内存空间会被释放掉。当函数在被调用、代码执行之后,其内部作用域就被销毁了,闭包的神奇之处就是消除这种机制所造成的影响。

到这儿,我们回头再看看“封闭+包裹”是什么意思,“闭”就是局部作用域,而“包”是作用域的嵌套。

还有么?

它只有一种形式吗?答案是否定的,不然它就没那么多用处了。

只要是传递了一个函数类型的值,不论形式,当函数在别处调用,都可以看到闭包的形成。

比如这样:

function outer() { 
  var a = 2;
  function inner() { 
    console.log( a ); // 2
  }
  another( inner ); 
}
function another(fn) {
  fn(); // 妈妈快看,这是闭包!
}

这段代码中,anothier 是 outer之外的一个函数,但因为它传递了一个outer内部的函数 innner,继而能够访问到 outer 中定义的a。

再看:

var fn;
function outer() {
    var a = 2;
    function inner() { 
      console.log( a );
    }
    fn = inner; // 将 inner 分配给全局变量 
}
function another() {
    fn(); // 妈妈快看,这也是闭包!
}
outer();
another(); // 2

这种像是前面两者的结合体,先定义了全局变量fn,在函数内部将inner赋给了fn,然后在another里调用fn()。

以上几段代码,形式不同,但原理一致。

它在哪儿?

到这儿大家可能已经懂了什么是闭包,但上面的讲述只是为了方便理解,实际项目中,它不会那么乖巧、坦诚的暴露给你,它可能隐藏在成百上千行的代码中,所以到底哪些地方用了闭包?或者说它有什么用?

示例一

function wait(msg){
  setTimeout(function timer(){
    console.log(msg);
  },1000)
}

wait("你好, 闭包!");

我们写了个wait函数,其中设置了定时器,定时器内有个timer函数,然后调用wait函数,并传参。

wait函数是马上执行的,但timer在1000毫秒之后才执行,这时候wait的作用域应该是已经被销毁了,但是依然可以正常输出"你好, 闭包!"。

示例二

function clickBtn(name, selector) {
    $( selector ).click(function listen() {
        console.log( name );
    });
}
clickBtn( "name", "selector" );

这段代码可看出,我们给某按钮设置了负责点击行为的函数,在函数内部绑定了事件监听器,这样以来,也出现了作用域的嵌套,形成了闭包。

以上两个示例均使用了“回调”,这是一种很常见的闭包形成方式,定时器、事件监听器、Ajax请求等都存在这种情况。

“坑”

这个坑大概所有人都掉过,就是循环。

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

这段代码目的很明显,从1开始,每隔一秒输出一个数字,每次递增1,正确的结果是1,2,3,4,5。

试着运行它就会发现“诡异”的现象,它居然输出了5次6?5次好理解,6是什么?!

这就要追究到js的任务运行机制,在循环体中,遇到定时器、事件监听器或其他类似执行体的时候,会先执行循环,将i从头到尾存储到一个栈当中,而里面的函数并不会在i值变化的时候马上执行,会依次进入任务队列,for循环结束后,队列中的函数才进入主线程执行,所以,这里i执行完最后一次迭代就是6,且被5个定时器输出了5次。

怎么解决?较为常用的一种办法是:

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

我们加了个立即执行的匿名函数,这就为每次迭代创建了一个闭包环境,即使里面的函数体依然会等待执行,但正确的值已经被每个独立的作用域保存起来,执行的时候就能输出预期的结果。

慢着,这么做不就是为了得到一个封闭的作用域吗?既然如此,干脆这样咯:

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

ES6的出现给了我们更干净的处理方式,终于不用动歪脑筋了~

更大的用处

闭包的一项本领是什么?——局部作用域中的变量,跟外界相互独立,但可通过调用内部定义的函数访问

而这正是模块化所需要具备的:

  • 隐藏私有数据
  • 暴露共有方法

所以,我们可以这么写。

function Module() {
  var something = "模块私有数据";
  function doSomething() {
    console.log(something);
  }
  return {
    doSomething: doSomething,
  };
}
var foo = Module();
foo.doSomething(); // 模块私有数据

是不是很熟悉?也感受到了闭包的无处不在和强大?

当然,模块化有多种形式,本文点到为止,后面单独介绍。

小结

对于“闭包”,我们需要了解:

  • 它是什么
  • 表现形式
  • 应用场景

懂不懂“闭包”似乎成为工程师们的一道坎,相信读罢此文,你已经豁然开朗,如有问题,欢迎交流~

下篇见!

博客链接:【JS启示记】—闭包