老斯基也翻车的JS闭包

前置知识

es6之前,js中变量作用域分为两种:全局作用域、局部作用域
学习闭包之前需要先了解作用域及变量提升的概念。《JS作用域和作用域链》《JS变量提升》

通过了解变量作用域我们知道,js的变量作用域很特殊,采用的是“词法作用域”。
子作用域可以访问父作用域的变量。
但是父作用域无法访问到子作用域的变量。

调用栈:
我们在执行一个函数时,如果这个函数又调用了另外一个函数,而这个“另外一个函数”也调用了“另外一个函数”,便形成了一系列的调用栈

function fn1() {
    fn2()
}
function fn2() {
    fn3()
}
function fn3() {
    fn4()
}
function fn4() {
    console.log('fn4')
}
fn1()

调用栈的原则是先进后出,后进先出。
fn1 先入栈,fn1 调用fn2,fn2 入栈,……,直到 fn4 执行完成,fn4 先出栈,fn3,fn2,fn1 分别出栈。
正常来讲,函数执行完毕出栈时,函数内局部变量会在下一个垃圾回收节点被回收,该函数对应的执行上下文会被销毁。
==重点:这也就是我们在外界无法访问函数内部定义的变量的原因。==
也就是说,只有在函数执行时,相关函数可以访问该变量,该变量在预编译阶段进行创建,在执行阶段进行激活,在函数执行完毕后,相关上下文被销毁。

为何使用闭包

但是出于一些原因,有时候我们需要得到函数内部的局部变量,通过上面的解释知道常规的手段是不行的,
那就使用非常规手段,就是让无数人翻车的闭包

何为闭包

闭包的概念:闭包的概念也可以理解为函数的概念,即
函数对象可以通过作用域链关联起来,函数体内部的变量都可以保存在函数作用域内,这种特性在计算机科学文献中成为“闭包”。
这句话是犀牛书8.6节闭包中的一段定义,可能过于官方,很多人都不太理解,那我们把这句话再翻译一下:
==一个函数内部的函数可以访问到外部函数的变量。==
再换句话说就是:
函数嵌套函数时,内层函数引用了外层函数作用域下的变量,并且内层函数在全局环境下可访问,就形成了闭包。
从技术角度来说,所有的JavaScript函数都是闭包。
注:闭包函数内不一定要有return,如没有 return 那么就要将一个内部函数赋值给一个全局变量,否则没有意义。当然也可以返回一个对象(见最后一个栗子)。

老司机来了,快上车

下面通过几个栗子,让大家快速了解闭包

  • 栗子1
function fn(){
    var a = 5;
}

a是函数fn的局部变量,在外部是无法访问到的,但是由于子作用域可以访问父作用域的变量,我们将代码简单修改代码↓

function fn(){
    var a = 5;
    function fn2(){
        console.log(a);
    }
}

在函数fn内部定义函数fn2,fn2内部可以访问到a变量,那是不是可以将函数fn2作为返回值,这样是不是就可以在函数fn外部获取到变量a了,再改写代码↓

function fn(){
    var a=5;
    return function(){
        console.log(a);
    }
}
var fn3 = fn();
fn3(); //5 

==将fn函数内部函数作为返回值,然后在函数fn外部调用返回的函数==,正确输出a。这样就实现了我们最开始的需求(在函数外部拿到函数的局部变量)。

为什么会这样?

首先再复习一遍闭包定义,“==一个函数的内部函数可以拿到外部函数的变量==”。
再具体一点就是:一个函数的内部函数可以拿到外部函数的变量,然后将这个内部函数作为返回值返回
这样在函数外部调用返回的函数时同样可以拿到函数内部的这个变量,这就是闭包

什么原理?

一个普通的函数在执行完后,上下文即被销毁,内部的变量都会被释放,但是这在个栗子中,js引擎发现返回的函数中使用了变量a,并且这个返回的函数在外部是有可能被执行的,所以变量a没有被释放,而是放到了一个只有这个返回的函数可以访问到的地方,此时==a变量可以且只能被这个函数访问,每次调用fn()都会创建一个新的作用域链和一个新的私有变量==。

到这你还是有点懵,没理解,不用怕,刚接触都会懵,将上面的栗子反复看几遍,总会有所收获的。
如果到这你都能看懂,那么恭喜你,你已经掌握了闭包的基础用法。系好安全带,开始飙车了。

  • 栗子2
function fn(){
    var a = 1;
    return  function(){
        a++;
        console.log(a);
    }
}
var fn2 = fn();
fn2(); //2
fn2(); //3
fn2(); //4

这里可以看到,我们不光可以获取到fn函数内的局部变量a,还可以对其进行修改。因为变量a是一直存放在内存中fn2函数可以访问到的地方
再升级下代码↓

  • 栗子3
function fn() {
    var a = 1;
    return function() {
        a ++;
        console.log(a);
    }
}
var fn1 = fn();
fn1(); //2
var fn2 = fn();
fn2(); //2
fn2(); //3
var fn3 = fn();
fn3(); //2

上面代码将fn的返回函数分别赋给3个对象,fn1、fn2、fn3,
三次赋值相当于初始化3个a变量放到内存中,分别只供fn1、fn2、fn3使用。
fn1、fn2、fn3函数在执行的时候,分别访问的是各自区域内的a变量,==3个区域不共享==。
==原理:每次调用fn()都会创建一个新的作用域链和一个新的私有变量。==

  • 栗子4
//第一题
function q1() {
    var a = {};
    ruturn function() {
        return a;
    }
}
var t1 = q1();
var o1 = t1();
var o2 = t1();
console.log(o1 == o2);//true

//第二题
function q2() {
    var a = {};
    ruturn function() {
        return a;
    }
}
var t1 = q2();
var t2 = q2();
var o1 = t1();
var o2 = t2();
console.log(o1 == o2);//false

分别输出true和false,不需要解释了吧。

  • 栗子5

一些情况下,需要返回多个函数,这时候就用到返回对象

function fn() {
    var a = 10;
    return {
        add:function(addNum) {
            a += addNum;
            console.log(a);
        },
        sub:function(subNum) {
            a -= subNum;
            console.log(a);
        }
    }
}

var obj1 = fn();
obj1.add(5); // 15
obj1.add(20); // 35
obj1.sub(3); // 32

var obj2 = fn();
obj2.add(2); // 12
obj2.add(6); // 18

返回对象和返回函数用法基本一致,变量在不同对象间依然不共享。

  • 栗子6
const foo = () => {
    var arr = []
    var i
    
    for (i = 0; i < 10; i++) {
        arr[i] = function () {
        console.log(i)
        }
    }

    return arr[0]
}

foo()()

输出10。

  • 栗子7
var fn = null
const foo = () => {
    var a = 2
    function innerFoo() {
        console.log(a)
    }
    fn = innerFoo
}

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

foo()
bar()

输出2
在 foo 函数内,将 innerFoo 函数赋值给 fn,fn 是全局变量,这就导致了 foo 的变量对象 a 也被保留了下来。
这个栗子就说明了,闭包函数可以没有显式的 return 。

  • 栗子8
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()

栗子8是栗子7的改版。
执行结果为:报错 ReferenceError: c is not defined。
变量 c 并不在其作用域链上,c 只是 bar 函数的内部变量。

说翻车就翻车——内存管理

内存管理就是:对内存生命周期的管理。包含分配内存空间、读写内存、释放内存空间。

var foo = 'bar' // 在栈内存中给变量分配空间
alert(foo)  // 使用内存
foo = null // 释放内存空间

JavaScript依赖宿主浏览器的垃圾回收机制
如内存管理不当极易造成内存泄漏,指内存空间明明已经不再被使用,但由于某种原因并没有被释放的现象。

  • 栗子9
var element = document.getElementById('element')
element.innerHTML = '<button id="btn1">按钮</button>'

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

element.innerHTML = ''

栗子9中,button元素已经从dom中移除,但是其事件处理句柄还在,所以依然无法被回收,需要手动removeEventListener。需要注意的是,addEventListener()添加的匿名函数无法移除,所以要尽量传入具名函数。

另外==闭包使用不当,极易造成内存泄漏,如果不再使用,需要手动清除。==
之前说到闭包中的变量在函数执行完后不会被释放,还是存放在内存中,势必会造成内存浪了。严重可导致内存泄漏。
没办法直接释放这个变量,如需释放变量就释放访问变量的函数

  • 栗子10
function foo() {
    let a = 123
    
    function bar() { alert(a) }
    
    return bar
}

let bar = foo()

此时 a 变量会被保存在内存中,如果需要释放则执行

bar = null

释放掉对闭包函数的引用后,垃圾回收机制就会回收变量a。

总结

很多人学完闭包都会有一个这样的问题,“我知道什么是闭包,可是闭包是做什么的呢?”

  • 闭包的应用场景

    • 模块化
    • 防止变量被破坏
    • Redux中间件实现机制

设计模式中的单例模式就可以依托闭包来实现

function Person() {}

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

const p1 = new getSingleInstance()
const p2 = new getSingleInstance()
console.log(p1 === p2) // true

singleInstance 为闭包变量 ,这正是单例模式的体现。

一个闭包引申出了内存、执行上下文、作用域、作用域链等概念。虽说都是基础,但是每个概念都能衍生出很多知识点。难怪老司机也爱翻车。

阅读 251

推荐阅读