重学javaScript (八)| 说说闭包的那些事儿

173 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第8天,点击查看活动详情

前言

闭包这个东西,几乎每一次面试都会被拿出来问,什么是闭包,今天就来学习一下它吧,在说闭包前,需要先明确几个概念

js中函数是一等公民

这句话的意思就是说,在js中函数既可以作为参数也可以作为返回值,只有具有这个特性的编程语言才能形成闭包

闭包的定义

我们分为两部分来看,计算机科学和js中的闭包

  • 计算机科学(维基百科):
    • 在[计算机科学]中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),
    • 是在支持[头等函数]的编程语言中实现[词法][绑定]的一种技术。
    • 闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包括[约束变量](该函数内部绑定的符号),也要包括[自由变量](在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。
    • 闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。捕捉时对于值的处理可以是值拷贝,也可以是名称引用,这通常由语言设计者决定,也可能由用户自行指定(如C++)。
  • js中的闭包 (mdn)
    • 一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure)。
    • 也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。
    • 在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

怎么样是不是很枯燥难以理解,没关系,用自己的大白话说就是

  • 一个函数和对其周围状态(词法环境)的引用,捆绑在一起的组合就叫闭包
  • 也就是说,闭包可以让你在内部函数中,访问到外部函数的作用域
  • 在js中,每创建一个函数就会有一个闭包被创建出来

还是很难理解吗,没关系,我们接着往下看

到底什么是闭包!

先来看一个小栗子

// 代码的执行结果是什么呢?
function fn () {
    var name = 'kaka'
    function bar() {
        console.log(name)
    }

    return bar
}

var fun = fn()
fun() 

来看下结果,显而易见的,它的打印结果是kaka

image.png

有没有发现什么重点,在全局作用域里的fun,可以访问到函数fn里的变量,这个由函数bar和fn函数作用域(fn函数)组成的组合就叫做闭包

闭包在内存中的执行过程

可能这个时候,又会有人要疑惑了,搜索脑海里的知识,明明只有在作用域链里边的变量才能被访问到,fun的作用域链是全局作用域,那么name不在全局作用域里,它又是怎么被func访问到的呢? 不要着急,我们接着往下看

image.png

还是这段代码,我这次直接把编辑器里粘贴了过来,有了行号一会儿就方便多了

当我们的这段代码执行的时候,就是在内存中一个怎么样的表现呢,下面来一步一步的进行介绍

  1. 代码的编译阶段,也就是parse(从源码到ast树的阶段),这个阶段也大致分为以下几步

    • 我们的js引擎会首先生成一个一个GO对象和一个空的调用栈

    • GO对象用来存放全局的函数和方法,以及我们代码定义的变量及函数

      image.png

    • 我们编译阶段,遇到基本数据和函数的处理方法是不同的

      • 基本的数据就是给它赋值一个undefied
      • 函数则会在内存中开辟一个空间,里边存放着函数的父级作用域和函数的执行体,我们写定的变量里则储存这个内存的地址
    • 做完上述的操作后,至此编译阶段结束

    image.png

  2. 编译过后,我们的js引擎开始对代码进行执行,在这个阶段,它做了这么几件事

    • 在调用栈中创建一个全局执行上下文,用于执行代码

    • 全局执行上下文中创建一个叫VO的对象,它指向GO

    • 然后开始一行一行的执行代码

    image.png

    • 开始执行第12行代码,执行函数fn并将其结果赋值给变量fun,其中函数fn的执行,它在内存中是这样表现的

      • 先对函数fn进行编译,内存中开辟空间生成函数fn的AO对象,AO对象有fn中定义的变量

        • 基础类型name赋值为undefind
        • 对函数bar进行编译,在内存中生成一块函数bar的空间,里面存放着bar的父级作用域(它的父级作用域就是函数fn的作用域,也就是fn的AO对象)和bar执行函数体,bar变量存放着bar函数的内存空间的地址

        image.png

      • 在调用栈中放入函数fn的执行上下文,当函数执行的时候都需要生成一个执行上下文

      • 在函数fn的执行上下文中生成一个VO对象,它就是fn的AO对象

      image.png

      • 接着开始执行代码,name赋值为kaka,然后将bar函数作为返回值返回给了fun(GO对象里),所以fun存放的是bar的内存地址0xb00

      image.png

      • 函数fn执行完毕,fn的执行上下文出栈,随着执行上下文的出栈,它的vo对象和里面的指向也被回收

         划重点: 此时按理说,fnAO对象应该随着它执行上下文的出栈,被回收,但是因为它指向了函数bar,所以它此时是不会是被销毁的
        

      image.png

    • 至此第12行,代码执行完毕,开始执行第13行代码也就是执行func,也就是执行函数bar

      • 执行函数bar还是重复上述的步骤

      • 内存中开辟空间生成函数bar的AO对象,AO对象有fn中定义的变量

      • 在调用栈中加入一个函数bar的执行上下文,然后生成一个VO,VO指向bar的AO

      image.png

      • 在bar函数体的内部,打印了变量name,根据变量是沿着作用域链找的原则
        • 现在bar的作用域里找,没找到,于是去父级作用域找,此时发现内存中函数bar指向fn的AO对象,发现了name的值为kaka,就打印kaka

    • 至此,函数bar执行完毕,它的执行上下文出栈,然后同时销毁bar的AO对象

      image.png

这就是闭包在内存中的执行过程

划重点:函数bar和它绑定的fn的AO对象的组合就叫做闭包,这个时候bar还指向着fn的Ao对象,所以即使fn被销毁,依旧能访问到它作用域的值,理解了这个过程再去看闭包的定义是不是就容易理解多了

关于闭包的内存回收

这个时候可能有小伙伴就问了,按照之前说的,一个函数执行完毕,随着执行上下文的出栈,它的AO对象也会随着被回收,为什么函数fn的就不会被销毁呢?

闭包造成的内存泄漏

  • 之前学过js的垃圾回收机制主要是标记清除,也就是从gc会定期从根开始往前查找它的引用,对于那些没有引用的对象,和找不到的对象,进行回收。
  • 它的根就是GO对象,沿着GO往后找,发现fn的Ao被bar和函数fn指向,所以,它不会被清除

这个时候,如果bar对象以后你不想使用的话,就造成了内存泄漏,如果还想使用的话,就不叫内存泄漏

怎么优化闭包的内存泄漏呢

  • 很简单,给对应的函数赋值null就可以了,例如给func赋值为null,就是它们指向了一个null的空的地方,它是这样表现的

image.png

  • 此时发现从go开始找,函数fn的ao对象和函数bar,不在这条线路上了,就把它俩回收,至于它俩互相循环引用,就不管,刚好对应了标记清除算法可以解决引用计数算法无法解决循环引用的这个优势

image.png