嗑嗑瓜子,聊聊作用域、作用域链、变量提升、预编译和闭包(下)

144 阅读7分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

作用域、作用域链、变量提升、预编译和闭包

闭包

什么情况下会形成闭包

函数内部定义的函数,被返回了出去并在外部调用时会产生闭包

function a(){
    function b(){
        var bbb = 234
        console.log(aaa);//123   闭包
    }
    var aaa = 123
    return b//b定义在a里面,但是被保存出去了
}
var demo = a()
demo()

函数b是定义在函数a内部的,函数a执行时将函数b返回了出来,赋值给变量demo并调用。这种情况就产生了闭包

什么是闭包

闭包定义:在js中根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回的一个内部函数后,即使该外部函数已经执行结束,但是内部函数引用外部函数的变量依然保存在内存中,我们把这些变量的集合称为闭包

首先我们要知道,在一个执行上下文里面包括变量环境,词法环境和一个outer,简单的理解,就是var和function的声明会存储在变量环境中,而let,const,try-catch等声明会存储在词法环境当中,词法环境仍然保持一个栈的存储结构,而outer是指向当前执行上下文的上一级(父级)执行上下文

image.png

接着我们用一个实例,让我们对闭包的认识不再那么抽象:

function foo() {
  var myName =  'aaa'
  let test1 = 1
  let test2 = 2
  var innerBar = {
    getName: function () {
      console.log(test1);
      return myName
    },
    setName: function(newName){
      myName = newName
    }
  }
  return innerBar
}
var bar = foo()
bar.setName('bbb')
bar.getName()
console.log(bar.getName());

全局下定义了foo函数和bar变量,将全局上下文压入调用栈,

image.png

接着,是foo函数的执行,所以将foo的执行上下文压入调用栈,并返回其内部的innerBar,将innerBar赋值给bar变量,

image.png

在foo执行结束后,foo的执行上下文应该被销毁,但是由于后面调用了getName函数和setName函数,且这两个函数里有使用到foo中定义的myName和test1,所以,foo的执行上下文(AO对象)并没有被销毁,而是变成了这样

image.png

没错,foo(closure)这个变量的集合就是闭包,它里面只有被用到的myName和test1,这个闭包就像一个小背包一样,函数getName和setName无论在哪里被调用,都会带着这个小背包。

相信,通过上述的例子,大家对闭包的认识更具象化了,那么闭包到底有什么用呢,他肯定是有优缺点的。

闭包的优点(作用)

  1. 实现公有化变量(企业的模块开发)
  2. 模块化开发,防止污染全局变量

假设现在有一个变量count = 0,要实现一个函数,使得每调用一次函数count的值都加一,如下

var count = 0
function test(){
    count++
    console.log(count);
}
test()//1
test()//2
test()//3
test()//4

那么如果是这样写,就会造成全局变量污染,因为count是定义在全局的,但是闭包能够解决这个问题,

function add(){
    var num = 0
    function a(){
        console.log(++num);
    }
    return a
}
var res = add()
res()
res()
res()
res()

使用闭包的方法,这样既能保证模块化开发,又能放止污染全局变量

  1. 做缓存
function fruit(){
    var food = 'apple'
    var obj = {
        eatFood:function(){
            if(food!==''){
                console.log('I am eating ' + food);
                food = ''
            }else{
                console.log('There is nothing');
            }
        },
        pushFood:function(myFood){
            food = myFood
        }
    }
    return obj
}

var person = fruit()
person.eatFood()
person.eatFood()
person.pushFood('banana')
person.eatFood()

这段代码运行的结果如下

image.png

我们会发现,像这样,我们可以使得两个或多个函数,去连续的修改一个变量(此处的food),这就叫做缓存。

  1. 实现属性的私有化

闭包的缺点

  • 闭包会导致原有的作用域链不释放,造成内存泄漏,导致调用栈的空间原来越少,而调用栈其实是有固定大小的,所以会导致栈溢出。闭包虽然有这个缺点,但是它利大于弊,我们要注意的是不要滥用闭包就好。

拓展

变量的查找路径

前面我们知道了,执行上下文中,有变量环境,词法环境,和outer,当我们查找变量的时候,我们是从词法环境开始找,如果没有,则进到变量环境找。如图:

image.png

那么如果还没有,就会去到outer指向的执行上下文里继续找,就这样,直到找到为止,如果找到底了还是没有,就会报错。前文说过outer是指向当前执行上下文的上一级(父级)执行上下文,那么这个上一级的执行上下文到底是哪个呢?我们看下面这个例子:

function bar(){
  console.log(myName);
}
function foo () {
  var myName = 'aaa'
  bar()
}
var myName = 'bbb'
foo()

bar里没有myName,他要去父级执行上下文中去找,这个打印结果一般来说,大家都会觉得会进入foo的执行上下文去找(也就是说认为outer指向的是foo,foo是bar的父级),所以最终打印的应该是aaa,实则不然:

image.png

打印的结果是bbb,因为,bar的父级执行上下文其实是Window全局,所以他找到了全局下的myName,打印bbb,这是因为,父级执行上下文不是看这个函数在哪里被调用的,而是看它在哪里被定义的。 bar是在全局下定义的,所以全局的执行上下文才是它的父级执行上下文,bar执行上下文中的outer指向全局执行上下文(GO对象)。

一道难题(面试题)

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

image.png

可以看到,上述代码的执行结果是打印了六次6,这是因为setTimeout是异步执行的,简而言之就是会放到最后一起执行,我们如何让他照常打印出0,1,2,3,4,5呢?

最根本的思路:找个变量将i存起来。

第一种方法就是用let 将代码中for循环的var i 改成let i

for(let i = 0;i<6;i++){
  setTimeout(()=>{
    console.log(i);
  })
}

image.png 其原理如下

let i; 
for(i=0;i<10;i++){ 
    let j = i 
    setTimeout(()=>{ 
        console.log(j) 
    }) 
}

这其实就是相当于在for循环里面定义了一个j,去保存i的值,而let 声明不会变量提升,var的变量声明会提升,所以将var改成let即可,这是最简单的方法,那么其实还有另外两种。

第二种就是今天聊到的闭包

将代码改写成闭包的样子:

for(var i = 0;i<6;i++){
  (function(j){
    setTimeout(()=>{
      console.log(j);
    })
  })(i)
}

这是一个自执行函数形成闭包,因为let声明不会变量提升,var会,但是在函数中var声明提升到函数体内的最前面,不会提升到函数外面,换句话说,var的变量提升会穿过到{}(花括号)块级作用域外面,但是不会穿过到函数作用域外面,这就相当于,在自执行函数定义了一个形参j,然后将i传进去,用j去保存i的值,而这里定义j变量提升不会提到外面去,所以生效,这就是闭包的好处。打印结果如下:

image.png

第三种方法就是用setTimeout的第三个参数 将代码中setTimeout后面传入第三个参数

for(var i = 0;i<6;i++){
  setTimeout((j)=>{
    console.log(j);
  },1000,i)
}

这第三个参数,就是将i传进去,然后setTimeout中的箭头函数定义一个形参j去保存i,最后的效果如下:

image.png

这三种方法在最基本的思路都是用另一个变量去保存i的值,其中有一种是闭包的方法解决问题,就拉出来讲一下。