作用域与闭包

110 阅读12分钟

前言

在学习JS时,一开始最先接触到的就是闭包,当时对闭包的理解仅仅只存在于一个作用域有权访问另一个作用域的片面认知。趁最近有空,总结一下闭包的知识点。

什么是闭包

官方的解释是:闭包就是有权访问另一个函数作用域中的变量的函数。简单来说就是:把函数执行,形成私有的上下文,并且保存和保护私有变量的机制。这句话听着有点拗口,在了解闭包之前,我们需要先知道什么是作用域作用域链

作用域

简单来讲:作用域指程序中定义变量的区域,它决定了当前执行代码对变量的访问权限。
在JS中一开始只有两种作用域类型:

  1. 全局作用域:全局作用域为程序的最外层作用域,一直存在。
  2. 函数作用域:函数作用域只有函数被定义时才会被创建,包含在父级函数作用域/全局作用域内。

在ES6时,为了避免由于过多的全局变量和函数产生的命名冲突,JS会对其视而不理,从而导致运行出差,而提出了块级作用域
由于作用域的限制,每段独立的执行代码块只能访问自己作用域和外层作用域中的变量,无法访问到内层作用域的变量。

    // 全局作用域开始
    var a = 1
    // test函数作用域开始
    function test() {
        var a = 2
        console.log(a);
    }
    // test函数作用域结束
    test()//2
    console.log(a);//1
    // 全局作用域结束

作用域链

在JS里面,函数,块,模块都可以形成一个作用域(一个存放变量的独立空间),他们之间可以相互嵌套,作用域之间会形成引用关系,这条链叫做作用域链。
在上述代码中,可执行的代码块是可以在自己的作用域中找到变量的,那如果在自己的作用域中找不到目标变量时,程序是否还能正常运行?

    function foo(b) {
        var a = b * 2
        function bar(c) {
            console.log(a,b,c);
        }
        bar(b*3)
    }
    foo(2)//4 2 6

函数bar的内部,我们可以分别获取到a,b,c三个变量的值,但在bar内部作用域中只能获取到变量c的值,ab都是从外部的foo函数作用域中获取到的。

当可执行代码内部访问变量时,会先查找本地作用域,如果找到目标变量即返回,否则会去父级作用域继续查找...一直找到全局作用域。

image.png

从上面图片中,上述代码总共有三层作用域嵌套:

  1. 全局作用域
  2. foo函数作用域
  3. bar函数作用域

函数参数也在函数作用域中

静态作用域

静态作用域是JS中使用的作用域类型,静态作用域又叫做词法作用域,与之相对的还有动态作用域

    var value = 1
    function foo() {
        console.log(value);
    }
    function bar() {
        var value = 2
        foo()
    }
    bar()// 1

在上面这段代码中,存在三个作用域:

  1. 全局作用域
  2. foo函数作用域
  3. bar函数作用域

上述代码中,foo函数里访问了作用域中没有的变量value。根据函数的作用域链,为了获取这个变量,编译引擎就要去foo上层作用域中去查询,那么foo的上层作用域是什么?是它调用时所在的bar作用域?还是它定义时所在的全局作用域?

词法作用域就意味着函数被定义时,它的作用域就已经被确定了,和拿到哪里执行没有关系,因此词法作用域又被叫做静态作用域

动态作用域

与词法作用域不同于在定义时确定,动态作用域在执行时确定,其生存周期到代码片段执行为止。动态变量存在于动态作用域中,任何给定的绑定的值,在确定调用其函数之前都是不可知的。

从某种程度上来讲,这种做法会修改作用域,也就是会欺骗词法作用域。在代码中建议不要使用,在某些方面来说欺骗词法作用域会导致更低下的性能

eval

JS中eval() 函数接收一个字符串作为参数值,并将这个字符串的内容看作是好像它已经被实际编写在程序的那个位置上。

eval() 被执行的后续代码行中,引擎将不会知道或是关心前面的代码是被动态编译的编译的,因此修改了词法作用域环境。引擎将会像它一直做的那样,简单地进行词法作用域查询。

    var b = 2
    function demo(str,a) {
        eval(str)
        console.log(a,b);
    }
    demo('var b = 12',1)//1,12

eval() 调用的位置上,字符串var b = 12被看做一直就存在第二行的代码。因为这个代码恰巧声明了一个新的变量b,它就修改了现存的demo() 的词法作用域。简单来讲,这个代码实际上在demo() 内部创建了变量b,它遮蔽了声明在外部作用域中的b

console.log() 调用发生时,它会在demo() 的作用域中找到ab,而且绝不会找到外部的b。这样打印出来的就是“1,12”

块作用域

简单来讲,花括号内 {...} 的区域就是块作用域区域。
在ES6标准中,提出了使用letconst代替var关键字。

作用域的应用场景

作用域最常见的运用场景之一就是模块化

由于JS并未原生支持模块化,导致了在编写项目的时候出现过很多问题,如全局作用域污染和变量名冲突,代码结构臃肿且复用性不高。在正式的模块化方案出台之前,开发者为了解决这类问题,想到了用函数作用域来创建模块的方案。

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

在上述代码中,通过函数作用域简单的实现了模块化

闭包

JS堆栈内存释放

  1. 堆内存: 存储引用类型值,对象类型就是键值对,函数就是代码字符串。
  2. 堆内存释放:将引用类型的空间地址变量赋值成null,或没有变量占用内存了,浏览器就会释放掉这个地址。
  3. 栈内存:提供代码执行的环境和存储基本类型的值。
  4. 栈内存释放:一般当函数执行完后函数的私有作用域就会被释放掉。

但栈内存的释放也有特殊情况:

  1. 函数执行完,但是函数的私有作用域内有内容被栈外的变量还在使用,栈内存就不能释放,里面的基本值也就不会被释放。
  2. 全局下的栈内存只有页面被关闭时才会被释放。

什么是闭包

了解完了作用域后,我们来说说闭包。简单来讲:能够访问其他函数内部的变量的函数就叫做闭包

    function makeFunc() {
        var name = 'zzz'
        function displayName() {
            alert(name)
        }
        return displayName
    }
    var myFunc = makeFunc()
    myFunc()// zzz

我们来简单看看上述代码:

  1. var myFunc = makeFunc()执行时,makeFunc()创建了相应的函数执行上下文,在该上下文中声明了一个局部变量name并且声明了一个函数displayName,最后返回displayName函数的指针。
  2. var myFunc = makeFunc()执行结束后,相应的函数执行上下文就从栈中弹出,一般情况下,其变量也会随之销毁,但myFunc() 中调用了displayName,执行了displayName() 中的alert(name),这里的name 引用着makeFunc里的变量name,所以其变量并不会随着销毁,相当于封装了一个私有变量。

闭包就是一个函数引用另一个函数内部的变量因为变量被引用着,所以当另一个函数执行结束,其相应的执行上下文弹出栈时,变量并不会被回收,因此可以用来封装一个私有变量。但不正当地使用闭包可能会造成内存泄漏。

更严谨的描述:
闭包是由函数以及声明函数的词法环境组合而成的。该词法环境包含了这个闭包创建时作用域内的任何局部变量。

闭包的使用环境

函数中return一个函数

接下来我们再来看一个例子:

        function makeAdder(x) {
            return function (y) {
                return x + y
            }
        }

        var add5 = makeAdder(5)
        var add10 = makeAdder(10)

        console.log(add5(2));//7
        console.log(add10(2));//12

        add5 = null//释放对闭包的引用
        console.log(add5(1));// Uncaught TypeError: add5 is not a function

在这个例子中,我们定义了makeAdder(x)函数,它接受一个参数x,并返回一个新的函数。返回的函数接受一个参数y,并返回x+y的值。

从本质上讲,makeAdder是一个函数工厂,他创建了将指定的值和它的参数相加求和的函数。在上面示例中,我们使用函数工厂创建了两个新的函数,一个将其参数和5求和另一个和10求和

上面的两个变量add5add10都是闭包。它们共享相同的函数定义,但是保存了不同的的词法环境(变量x)。
add5的环境中,x为5;在add10中,x则为10

立即执行函数

接着我们来看个例子

    var add = (function () {
        var counter = 0
        return function () {
            return counter += 1
        }
    })()

    console.log(add());//1
    console.log(add());//2
    console.log(add());//3

上述代码为立即执行函数,函数最终的结果把counter的值返回给变量add,在执行add() 时,相当于执行立即执行函数,执行立即执行函数时,他会创建一个自己的函数作用域,在执行第二个return时,由于函数中没有变量counter,按照作用域链来看,它会从它的父级作用域中去寻找,找到了counter就把值给返回出去。当立即执行函数执行完毕,相应的函数执行上下文就会从调用栈中弹出,其中的变量counter就会被销毁,但是我们能正常执行add() 输出3,说明counter是没有被销毁的。

定时器打印

最后我们再来看一个比较经典的例子:

    for(var i = 0;i < 5;i++){
        setTimeout(() => {
            console.log(i);
        }, i*1000);
    }

上述代码中,我们预期是每隔一秒,分别输出0,1,2,3,4但实际上依次输出的是5,5,5,5,5
在代码中,由于变量i是通过var关键字声明的,没有块级作用域的概念,在全局范围内都有效,所以全局只有一个变量i。每一轮循环,变量i的值都会覆盖上一轮的值。又因为,setTimeout为异步任务,只有当同步任务结束后,异步任务才会开始执行。也就是说,上面定时器的回调会在循环结束时才调用。这个循环结束的条件是i不再小于5,条件首次成立时i的值是5,因此最后每次输出都为5.

    for(var i = 0;i < 5;i++){
        (function (k) {
            setTimeout(() => {
                console.log(k);
            }, k*1000);
        })(i)
    }

将代码修改成上面这样,就可以按照我们期望的方式执行了。这样修改后,使用IIFE(立即执行函数)会为每一轮循环都生成一个新的函数作用域,使得定时器函数的回调可以将新的作用域封闭在每一轮循环内部,每一轮循环内部都会含有一个具有正确值的变量可以访问。

上述代码中,整个立即执行函数就是一个闭包,变量k就是闭包的一部分,立即执行函数执行结束时,变量j被定时器引用着

函数作为参数

    var a = 'zz'
    function foo() {
        var a = 'foo'
        function fo() {
            console.log(a);
        }
        return fo
    }

    function f(p) {
        var a = 'f'
        p()
    }
    f(foo())//foo

上述代码中f(foo()) 里面的 foo() 执行完毕后,里面的变量a没有被回收,因为被fo() 引用着,所以foo() 与其词法环境就是一个闭包。

使用回调函数

    window.name = 'zz'
    setTimeout(function timeHandler() {
        console.log(window.name);
    },100);

柯里化

柯里化就是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数并且返回结果的新函数的技术。

柯里化是高阶函数的一个用法,js中常见的bind方法就可以通过柯里化来实现。

    Function.prototype.myBind = function (context = window) {
        if (typeof this !== 'function') throw new Error('Error')
        let selfFunc = this
        let args = [...arguments].slice(1)

        return function F() {
            if (this instanceof F) {
                return new selfFunc(...args, arguments)
            }else {
                return selfFunc.apply(context,args.concat(arguments))
            }
        }
    }

柯里化的优势之一就是参数的复用,它可以在传入参数的基础上生成另一个全新的参数。

闭包的内存泄漏

不正当的使用闭包可能会造成内存泄漏

    function fn1() {
        let test = new Array(1000).fill('rocky')
        return function () {
            console.log('Rocky');
        }
    }
    let fn1Child = fn1()
    fn1Child() // Rocky

上面例子是一个闭包,但是它并没有造成内存泄漏,函数中并没有对fn1函数内部的引用,也就是说,函数fn1内部的test变量完全是可以回收的。

    function fn2() {
        let test = new Array(1000).fill('rocky')
        return function () {
            console.log(test);
            return test
        }
    }
    let fn2Child = fn2()
    fn2Child()

这个例子显然也是一个闭包,因为return的函数中存在函数fn2中的test变量引用,所以test并不会被回收,也就造成了内存泄漏。

解决内存泄漏也很简单,在调用函数后,把外部的引用关系置空就好

    function fn2() {
        let test = new Array(1000).fill('rocky')
        return function () {
            console.log(test);
            return test
        }
    }
    let fn2Child = fn2()
    fn2Child()
    fn2Child = null

如此就不会再造成内存泄漏了。

闭包的作用

闭包的作用主要有两点:

  1. 保护函数的私有变量不受外部的干扰。形成不销毁的栈内存。
  2. 保护,把一些函数内的值保存下来。闭包可以实现方法和属性的私有化。