阅读 157

深层次理解闭包

先来看下下面这个例子


function test() {
    var a = 123;
    return function() {
        console.log(a);
    }
}

const b = test();
b(); // 123

复制代码

这是一个最简单的闭包的例子,在test函数中返回出一个匿名函数,然后调用可以取到test函数中的变量,这是由于当一个函数被申明的时候,就会产生闭包,然后将函数以及他的周边环境(作用域链)一起捆绑,让函数可以访问他的父级作用域

作用域链

作用域链指的是每一个函数形成的词法作用域被不断的包含,最后形成一个链式结构,能从子作用域一直查询到全局作用域的形式,可能说的不太清楚,可以看下一下这个图

在作用域链中,每一个单独的作用域都是一个由函数生成的词法作用域,而词法作用域是一个由代码中函数被调用位置决定的,是一个静态作用域

作用域

指的是函数在运行过程中,产生的变量以及变量存放的生命周期的一个可访问范围

在es6之前,是没有块级作用域的区分的,只有全局作用域和函数作用域,即:


function test() {
    console.log(a);
    {
        var a = 123;
    }
    a = 1;
    console.log(a);
}

test();

// undefined
// 1

复制代码

以上这段代码,按照其他有块级作用域的语言来说,在第一个输出的时候应该就会报错,存在找不到a这个变量的问题,即使第一个输出中a没有在下一个a=1的时候也会报错,因为在{}这个包围块中的变量是不会被外面的作用域访问到的,而在js中是不会有这个问题存在的,他说能够访问到a的内容,这个完全是因为在es6之前,用var申明变量的话会存在变量提升的问题

变量提升

变量提升实际上是将申明与赋值分离,将申明的部分提升到函数的最开头去执行,也就只有var定义的内容会有这个方式,可以理解一下


var a = 123;

// 这一句话实际上可以被分为两个部分

var a = undefined;
a = 123;

// 而js在运行的时候,是会将var a = undefined提升到函数开始的地方,所以会存在即使使用比赋值还前面,却会存在能找到对应变量的情况

复制代码

但是js是按照顺序去执行代码的,那么如何跟这个变量提升整合在一起呢?

js的执行过程

首先我们知道js是一个在使用过程中编译的脚本语言,这就会给整个js执行过程中分出了一个编译的阶段,也就是说,一段js在浏览器中执行的时候,实际上会经历两个阶段

  • 编译
  • 执行

在将一段js代码进行编译的时候,实际上会经历很多阶段(这里不进行详讲),最终结果会生成一个执行上下文以及可执行代码

执行上下文指的是当前函数被编译之后生成的一个运行环境,里面含有当前执行的函数的this,变量,对象等信息,当函数被调用的时候会从执行上下文中去查找对应的数据

可执行代码就是一段在后续执行中需要用到的代码块

执行上下文中存在一个变量环境,所以,一整段js在被执行之前就已经在变量环境中先生成了对应的变量

调用栈

说到执行上下文,必须说一下对应的调用栈

调用栈指的是每次函数被调用的时候生成的一个个执行上下文会不断的被压入栈中,也就是说在函数调用函数的时候,会重新生成一个执行上下文,并将其压入调用栈

调用栈是一种栈的数据结构,后进先出的概念

而所谓的爆栈就是指,当函数不断的被调用,产生的执行上下文的数量超过了调用栈的容量,就会发生爆栈的报错

es6的const与let

而在es里面使用了const和let修复了变量提升的这个问题


function test() {
    console.log(a);
    {
        const a = 123;
    }
}

// 报错

复制代码

之前也说过,var 生成的变量会被存放在变量环境中,那么,用const生成的变量也是同样在变量中吗?变量环境中的内容是会被覆盖的,也就是说里面只会存在一个命名的变量


function test() {
    var a = 1;
    {
        var a = 2;
    }
    console.log(a); // 2
}

function test() {
    let a = 1;
    {
        let a = 2;
        console.log(a); // 2
    }
    console.log(a); // 1
}

复制代码

上面描述了let 与 var的区别,因为变量环境的规则而言,如果let的申明也是放在变量环境中,那么a应该会被覆盖,但是事实证明,a并没有被覆盖,也就是说,let或者const在执行上下文中的存放方式并不是存放在变量环境中的,实际上,针对这个更新,js是将其存在一个词法环境中,而词法环境默认是一个小型的栈结构,当使用{}生成一个作用域的时候,let会在内部新生成一个变量环境存放在词法环境中,在使用过程中,他的查找过程是基于从栈顶到栈底的查找方式,所以只会找到当然最近的作用域的一个变量值

闭包中的变量

看完上面描述的作用域链,我相信大家都会作用域都一定的了解了,那么从函数中读取到函数作用域以外的变量应该都已经很清晰了,但是闭包还有一个特性,那就是变量的持久性,即使这个函数已经使用结束,并且函数所生成的作用域也已经出栈了,但是我们知道在闭包里面他的变量并没有在这个时候就在释放,在下一次调用的时候依旧能够取到这个变量当前的值,看个例子


function test() {
    let a = 1;
    return function() {
        console.log(a++);
    }
}

const t = test();
t(); // 1
t(); // 2

复制代码

这和我们之前说的感觉有点不太相符啊,当函数结束的时候,他生成的执行上下文就被出栈结束掉,那么他里面的变量环境就要进入一个即将被垃圾回收的状态,但是根据上面的代码可以看出,即使我下一次在调用,依旧能拿到当前的值,而且还是经过操作之后的值,其实这个涉及到了js的存储方式,分为栈空间跟堆空间

js变量存储方式

栈空间就是在调用栈中的变量存储空间,也就是执行上下文生成的时候,对应的变量存储的空间,而堆空间是一个调用栈以外的变量存储空间,两者拥有本质区别

  • 栈空间的空间比较小,因为会涉及到调用栈的切换,如果空间大的话会有一定的性能差异
  • 栈空间多用于存储一个基础类型的变量,堆空间用于存储复合类型的变量,原因同上,但是栈空间会保存堆空间中的地址,保证复合类型的引用

在谈闭包中的变量

前面讲过,正常的一些基础类型引用的存储在栈空间中的,但是当js编译的时候如果发现闭包的时候那么他的存储方式会发生一定的变化,就是在编译的过程中如果发现存在闭包,并且闭包函数的有使用本身作用域以外的变量,那么会在堆空间中间创建一个closure()的对象,用来存储使用到的变量,所以在js中,调用栈里面已经将当前的执行上下文出栈了,但是他的内部使用的变量还存留在堆中,并没有随着栈的消失而同时销毁

闭包的作用

讲完闭包以及闭包涉及到的一些内容之后,我们来思考下,闭包他的用途以及说闭包能够带来什么好处

模块化

能够使用类似私有化的方式来创建模块化,这个大概用于es6之前的,因为在es6之前js是没有一个模块化的概念,虽然es6也没有,但是有语法糖去实现模块化了


(function() {
    var _a = 123;
    return {
        getA: function() {
            console.log(_a);
        }
    }
})()

复制代码

这只是简单的一种实现,能够说明内部的变量被保存下来,并且能够不污染到其他的模块

柯里化

这个我就不详细讲了,直接上代码吧


const a = (b) => (c) => b = b + c;

const d = a(2);
d(10); // 12
d(10); // 22

复制代码

参考文献

李冰.浏览器工作原理与实践

总结

这些内容在我之前就已经看过一次了,但是却没有去总结过以及说整体的梳理过,这次我将我所学的内容从一个闭包开始逐步往外扩展,虽然会没有这份学习资料里面讲的详细,但是这些都是我自己个人的理解以及我的总结吧