细读闭包

1,710 阅读6分钟

对于闭包,可以说是老生常谈的话题,但是真正理解闭包的,又有哪些核心点呢?

静态作用域

先理解一下静态作用域的定义:函数作用域在定义时就已确定。

可以看出闭包就是一个静态作用域。内部函数在定义的地方向上查找所需变量。

例1:

let a = 1
function fn() {
    let a = 0
 function fn2() {
    console.log(a);
 }
  fn2()
}
fn() // 0

例2:

let a = 1
function fn() {
   let a = 0
   fn2()
 }
function fn2() {
   console.log(a);
}
fn() // 1

根据上面两个案例,可以很好的展示静态作用域的特点。
例1:
在代码定义时,确定其作用域范围。在运行时,根据作用域创建相应的执行环境。
在fn2函数中需要输出a时,会先查看当前fn2作用域是否存在,有就会使用;没有则会通过作用域链向上一级fn函数作用域中查找,找到第一次与之匹配的变量停止。
所以,a输出的时fn中的值0。

image.png 例2中,依旧同理,fn2会找它上一级的作用域,即全局作用域中a变量的值1。

闭包定义

闭包:首先闭包是一个对象,是内部嵌套函数引用了外部函数变量(函数)的对象,是内部函数与周围状态的引用捆绑在一起的一个组合。

查看闭包

chrome浏览器控制台进行debug,执行外部函数,可以发现内部函数需要的变量存在于内部函数的[[scopes]]内。

image.png

如上图,闭包就是存在于内部嵌套函数中,包含被引用的a变量。

常见的闭包

常见的闭包有两种:将子函数作为父函数的返回值、将父函数中实参传递给子函数。

子函数作为父函数的返回值

let a = 1
function fn() {
  let a = 0
  function fn2() {
    a++
    console.log(a);
  }
  return fn2
}
let f = fn()
f() // 1
f() // 2

执行let f = fn();f()调用的是fn函数内部的fn2函数。
执行fn2:fn2通过作用域链找到要fn中引用的a变量,执行a++,输出1。
再次执行f()语句,再次调用fn2。fn函数内部的变量依旧存在,不过经过上一次自加,a变量值为1。此时fn2再次a++,输出值为2。

注意: 闭包调用次数,取决于调用几次外部函数。

父函数中实参传递给子函数

function fn(a) {
 function fn2() {
    a++
    console.log(a);
 }
 fn2()
 fn2()
}
fn(0) // 1  2

fn2()内部函数引用fn外部函数参数。第一次执行fn2(),a自加后输出1;第二次执行fn2(),此时a值已经为1,a自加后输出2。

闭包作用

  • 使用父函数内部的变量在函数执行完成后,仍然存活在内存中(延长局部变量的生命周期
  • 外部可以调用子函数,操作到父函数内部数据(变量/函数,起到私有作用域)
function fn() {
  let a = 0
  function fn2() {
    a++
    console.log(a);
  }
  return fn2
}
let f = fn()
f()

1、fn()执行完成后,变量a仍然存活在内存中。 image.png 解释:在执行let f = fn()时,变量f指向了fn2指向的函数对象。该行语句执行完成后,fn2函数变量会释放。但是f指向的函数对象(闭包)仍然存在,闭包内部a变量仍然存活在内存中。再执行f(),就相当于调用的上图的函数对象。

如果未定义变量f指向闭包,即只执行fn()。那么,内部函数在fn执行完成后释放回收。外部也无法调用fn内部函数。

所以,父函数执行完成后,父函数内部声明的局部变量一般不存在,在闭包中的变量才可能存在

2、在外部可以通过操作变量f,来对fn函数内部变量a进行自加。

闭包的生命周期

产生:在子函数定义时就产生了(不是调用)
死亡:在子函数的内部对象成为垃圾对象时

function fn() {
  let a = 0
  function fn2() {
    a++
    console.log(a);
  }
  return fn2
}
let f = fn()
f()
f()

image.png 在fn内部,执行到39行时,fn2定义时就存在了闭包。 image.png 代码执行完成,闭包仍然存在!

此时,我们可以将f变量置为空,将fn2指向的函数对象变为垃圾对象。添加f = nullimage.png

闭包应用

闭包最常见的应用应该就是在定义js模块了。定义一个js模块,对外暴露多个方法或变量。如果对外暴露的方法中,使用到模块内部的变量就属于闭包的应用。
可以这样定义简单的js模块:

;(function (window) {
  let a = 0
  function f1() {
    console.log('f1', a)
  }
  function f2() {
    console.log('f2', a)
  }
  window.myModule = { f1, f2 }
})(window)

直接在所需使用的地方引入文件,并调用myModule的可用方法。如:myModule.f1()就可以执行模块中f1的方法。

js模块中使用匿名函数,对外暴露的是myModule全局对象。在使用时不需要执行myModule,直接调用myModule的方法即可。传入window参数,通常是为了在代码压缩时,进行简写window参数。

此外,vue中的data函数本质也是闭包,组件复用不会因为共享同一数据,造成数据污染。

闭包缺点及解决方法

缺点:容易引起逻辑问题;函数执行完,函数内部变量未被释放占用内存时间变长,容易造成内存泄漏。

循环与闭包

闭包容易引起逻辑问题。先来看个典型的案例:

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

for语句在js中不属于块作用域,定义的i属于的全局变量。6次循环都被封闭在共享的全局作用域中,因此i只有一个。

再者,setTimeout属于异步回调。6次循环结束后,才执行setTimeout。

上段代码等价于将setTimeout函数回调重复定义6次,完全不使用循环。

如果想要输出0、1、2、3、4、5怎么办呢?

解决:
核心:使用封闭循环的各自作用域。

  • 使用IIFE(立即执行函数表达式)在每次迭代中创建的作用域封闭起来。
  • 使用let声明,可以用来劫持块作用域,并且在块作用域中声明这个变量。

内存泄漏

内存泄漏:一个被分配的内存既不能使用,也不能回收到浏览器,直到进程结束。 关于内存泄漏,可以参考[闭包的生命周期]中,闭包手动释放回收。

解决:

  • 能不用闭包就不用闭包
  • 让内部函数称为垃圾对象,回收闭包(及时释放)。

补充:内存溢出与内存泄漏

内存溢出与内存泄漏关系:内存泄漏积累到一定程度,会造成内存溢出。

内存泄漏

内存泄漏的情况:

  • 闭包
  • 意外的全局变量
function fn() {
   a = 0
   console.log(a);
}
fn()

image.png 变量a未正常定义,变成全局变量。在代码执行完成之后,全局变量中依旧存在a变量。

  • setInterval
setInterval(() => {
  console.log('--');
}, 1000);

启动循环定时器时,未及时清理。需要使用clearInterval手动清理。

let time = setInterval(() => {
  console.log('--');
}, 1000);
clearInterval(time)

内存溢出

当程序运行所需内存超过剩余内存时,抛出内存溢出的错误。

image.png

执行上述代码,浏览器一直卡在运行状态,内存不能满足程序所需内存。