JavaScript 基础强化:闭包

基础成就高度

Posted by Mopecat on August 5, 2019

闭包的概念可以说是js里老生常谈的了,它可以说是javascript最基本最重要的概念之一,为什么呢?因为它涉及到很多js的基本概念。比如:作用域,作用域链,执行上下文,调用栈,内存管理等概念 为了更好的理解闭包,所以我们从基础走起,简单了解这些概念:

作用域

任何语言都有作用域这个概念,作用域是什么呢?说白了也就是寻找变量的规则,在javascript中现在为止有三种作用域:函数作用域全局作用域块级作用域

函数作用域和全局作用域

通过几个🌰总结一下

1
2
3
4
5
function a(){
    var aa = '函数作用域';
    console.log(aa)
}
a()

变量aa在函数 a的作用域内,所以执行a()可以正常输出 函数作用域

1
2
3
4
5
var bb = '全局作用域'
function b(){
    console.log(bb)
}
b()

执行函数b,在函数b自身作用域中没有变量bb,所以向外扩大作用域寻找,在外层的全局作用域中找到了变量bb因此成功输出全局作用域

1
2
3
4
5
6
7
function a(){
    var a = '函数一'
}
function b(){
    console.log(a)
}
b()

执行上述代码会报错,因为每一个函数都是一个单独的作用域,在函数b中没有找到变量a同样在外层的全局作用域中也没有找到,所以执行b函数报错

1
2
3
4
5
6
7
8
9
10
var b = '全局'
function func1() {
    var b = '一层'
    function func2() {
        console.log(b)
    }
    func2()
}

func1()

执行上述代码,对比第二个例子可以知道函数作用域的范围是逐级向外递增的 通过以上几条条例子可以简单总结几条规则:

  • 每个函数作用域都是独立的
  • 变量作用域的查找是一个扩散过程,一级一级向外就近寻找。
  • 最外层就是全局作用域

由此我们可以发现,作用域的工作方式就像环环相扣的联调,逐级递进这也就是作用域链说法的由来

块级作用域

块级作用域是ES6后才出现的由letconst 声明的变量,不涉及到变量提升,作用在块级作用域中,也就是代码块中,再通俗一点就是两个大括号中(当然这种书法并不全面) 由于块级作用域的变量不涉及变量提升所以出现了一个新鲜的概念,比如 暂存死区letconst声明的变量不存在变量提升,比如下面这种情况

1
2
3
4
function a(){
    console.log(aa)
    let aa = 111
}

会报错,以上在这个块级作用域内let aa之前的区域被称为 暂存死区 还要一种情况就是函数参数也会有暂存死区的问题,不过这种情况比较少见

1
2
3
4
function a(arg1 = arg2, arg2){
    console.log(arg1,arg2)
}
a(undefined,'arg2')

会报错,同样的暂存死区问题,各位老铁以后要注意一下哈

执行上下文

Javascript执行主要分为两个阶段:代码预编译阶段代码执行阶段 预编译阶段是前置阶段,这个时候由编译器将 JavaScript 代码编译成可执行的代码,执行阶段主要任务是执行代码,执行上下文在这个阶段全部创建完成。 在通过语法分析,确认语法无误之后,JavaScript 代码在预编译阶段对变量的内存空间进行分配。预编译阶段我们需要注意的三点:

  • 预编译阶段进行变量声明;
  • 预编译阶段变量声明进行提升,但是值为 undefined;
  • 预编译阶段所有非表达式的函数声明进行提升。

总结:作用域在预编译阶段确定,但是作用域链是在执行上下文的创建阶段完全生成的。因为函数在调用时,才会开始创建对应的执行上下文。执行上下文包括了:变量对象、作用域链以及 this 的指向

调用栈

总的来说就像是进一个只有一个门的大巴车(调用栈),先上车的坐最后面,依次来坐,那下车的时候(调用结束后)最先上车的一个就要最后一个下车。 看码好了:

1
2
3
4
5
6
7
8
9
10
11
12
13
function f1() {
  f2()
}
function f2() {
  f3()
}
function f3() {
  f4()
}
function f4() {
  console.log('f4')
}
f1()

这样形成的调用栈,最先入栈的是就是最先调用的f1然后f2,f3,f4相当于一个依次上车的人,那调用结束后也就是下车了,排队下车,最后上的排在最前面所以最先下车。所以f4最先出栈,然后是f3,f2,f1按顺序一个一个出栈

注意: 正常来讲,在函数执行完毕并出栈时,函数内局部变量在下一个垃圾回收节点会被回收,该函数对应的执行上下文将会被销毁,这也正是我们在外界无法访问函数内定义的变量的原因。也就是说,只有在函数执行时,相关函数可以访问该变量,该变量在预编译阶段进行创建,在执行阶段进行激活,在函数执行完毕后,相关上下文被销毁。

接下来就要进入正题了:闭包

内存管理

内存管理是计算机科学中的概念。不论是什么程序语言,内存管理都是指对内存生命周期的管理。 内存的生命周期包括以下几点:

  • 分配内存空间
  • 读写内存
  • 释放内存空间

🌰:

1
2
3
let a = '分配存储空间'
console.log(a) // 使用内存空间
a = null // 释放内存空间

内存空间分为 堆内存栈内存

  • 栈内存:由操作系统自动分配释放,存放函数的参数值,局部变量的值等。
  • 堆内存: 一般由开发者分配释放,这部分空间就要考虑垃圾回收的问题。

划重点 javascript中基本数据类型存储在栈内存中,引用类型存储在堆内存中。引用类型一般是存储在栈内存的一个内存地址指向堆内存中的位置 主题是说闭包为什么这里提到了内存空间呢,前面了解了一下内存管理相关的基本知识,接下来说后面闭包会涉及到的内存泄漏 内存泄漏是指内存空间明明已经不再被使用,但由于某种原因并没有被释放的现象。

这是一个非常“玄学”的概念,因为内存空间是否还在使用,某种程度上是不可判定问题,或者判定成本很高。内存泄漏危害却非常直观:它会直接导致程序运行缓慢,甚至崩溃。这个时候就得开始举个🌰了:

1
2
3
4
5
6
let el = document.getElementById("el")
el.class = 'el-class'
// 移除 element 节点
function remove() {
    element.parentNode.removeChild(element)
}

上面的代码,我们只是把 idel 的节点移除,但是变量 el 依然存在,该节点占有的内存无法被释放。 我们需要在 remove 方法中添加:el = null,这样更为稳妥

🌰2:

1
2
3
4
5
6
7
8
9
var el = document.getElementById('el')
el.innerHTML = '<button id="button">点我呀</button>'

var button = document.getElementById('button')
button.addEventListener('click', function() {
    // ...
})

el.innerHTML = ''

这段代码执行后,因为 element.innerHTML = ''button 元素已经从 DOM 中移除了,但是由于其事件处理句柄还在,所以依然无法被垃圾回收。我们还需要增加 removeEventListener,防止内存泄漏。

🌰3:

1
2
3
4
5
6
7
8
function func1() {
  var name  = 'Mopecat'
  window.setInterval(function() {
    console.log(name)
  }, 1000)
}

func1()

这段代码由于 window.setInterval 的存在,导致 name 内存空间始终无法被释放,如果不是业务要求的话,一定要记得在合适的时机使用 clearInterval 进行清理。

闭包

看过各种各样的说法,晦涩且多样的定义仍然给初学者带来困惑,所以直接一点通俗的定义应该是:

函数嵌套函数时,内层函数引用了外层函数作用域下的变量,并且内层函数在全局环境下可访问,就形成了闭包。

简单的例子:

1
2
3
4
5
6
7
8
9
10
function numGenerator() {
    let num = 1
    num++
    return () => {
        console.log(num)
    } 
}

var getNum = numGenerator()
getNum()

这个简单的闭包例子中,numGenerator 创建了一个变量 num,返回打印 num 值的匿名函数,这个函数引用了变量 num,使得外部可以通过调用 getNum 方法访问到变量 num,因此在 numGenerator 执行完毕后,即相关调用栈出栈后,变量 num 不会消失,仍然有机会被外界访问。 对比前述内容,我们知道正常情况下外界是无法访问函数内部变量的,函数执行完之后,上下文即被销毁。但是在(外层)函数中,如果我们返回了另一个函数,且这个返回的函数使用了(外层)函数内的变量,外界因而便能够通过这个返回的函数获取原(外层)函数内部的变量值。这就是闭包的基本原理。

因此,直观上来看,闭包这个概念为 JavaScript 中访问函数内变量提供了途径和便利。所以闭包是前端进阶必备基础。

接下来就看一些面试题来加深理解闭包:(都是求输出啥)

1
2
3
4
5
6
7
8
9
10
11
12
const foo = (function() {
    var v = 0
    return () => {
        return v++
    }
}())

for (let i = 0; i < 10; i++) {
    foo()
}

console.log(foo())

在循环执行时,执行 foo(),这样引用自由变量 10 次,v 自增 10 次,最后执行 foo 时,得到 10。(自由变量是指没有在相关函数作用域中声明,但是使用了的变量。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const foo = () => {
    var arr = []
    var i

    for (i = 0; i < 10; i++) {
        arr[i] = function () {
            console.log(i)
        }
    }

    return arr[0]
}

foo()()

答案:10,这时自由变量为 i,分析类似例题 1:foo() 执行返回的是 arr[0],arr[0]是一个打印ifunction而此时i的值是10

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var fn = null
const foo = () => {
    var a = 2
    function innerFoo() { 
        console.log(a)
    }
    fn = innerFoo    
}

const bar = () => {
    fn()
}

foo()
bar()

答案:2 正常来讲,根据调用栈的知识,foo 函数执行完毕之后,其执行环境生命周期会结束,所占内存被垃圾收集器释放,上下文消失。但是通过 innerFoo 函数赋值给 fnfn 是全局变量,这就导致了 foo 的变量对象 a 也被保留了下来。所以函数 fn 在函数 bar 内部执行时,依然可以访问这个被保留下来的变量对象,输出结果为 2。

将上面的题稍作修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var fn = null
const foo = () => {
    var a = 2
    function innerFoo() { 
        console.log(c)            
        console.log(a)
    }
    fn = innerFoo
}

const bar = () => {
    var c = 100
    fn()    
}

foo()
bar()

执行结果:报错。 在 bar 中执行 fn() 时,fn() 已经被复制为 innerFoo,变量 c 并不在其作用域链上,c 只是 bar 函数的内部变量。因此报错 ReferenceError: c is not defined。

思考题:如何利用闭包实现单例模式? 单例模式,是一种常用的软件设计模式。GoF 在《设计模式:可复用面向对象软件的基础》一书中给出了如下定义:

Ensure a class only has one instance, and provide a global point of access to it.

保证一个类只有一个实例,并提供一个访问它的全局访问点。 使用闭包我们可以保持对实例的引用,不被垃圾回收机制回收,因此:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Person() {
    this.name = 'lucas'
}

const getSingleInstance = (function(){
     var singleInstance
    return function() {
         if (singleInstance) {
            return singleInstance
         } 
        return singleInstance = new Person()
    }
})()

const instance1 = new getSingleInstance()
const instance2 = new getSingleInstance()

事实上,instance1 === instance2。因为借助闭包变量 singleInstanceinstance1instance2 是同一引用的(singleInstance),这正是单例模式的体现。

结尾

闭包就分享到这里了,在js中闭包的应用非常之广泛,我们应该在在扎实的基础上更好的分析和利用它。 本文中部分内容摘自GitChat上的课程,如有侵权联系删除。