V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
4ark
V2EX  ›  前端开发

理解 JavaScript 中的闭包

  •  
  •   4ark ·
    gd4ark · 2019-01-20 18:27:29 +08:00 · 2068 次点击
    这是一个创建于 2135 天前的主题,其中的信息可能已经有所发展或是发生改变。

    前言

    继上一篇《理解 JavaScript 中的作用域》后,我又立刻写下了这篇文章,因为这两者是存在关联的,在理解闭包前,你需要知道作用域。

    而对于那些有一点 JavaScript 使用经验的人来说,理解闭包可以看做是某种意义上的重生,但这并不简单,你需要付出非常多的努力和牺牲才能理解这个概念。

    如果你理解了闭包,你会发现即便是没理解闭包之前,你也用到了闭包,但我们要做的就是根据自己的意愿正确地识别、使用闭包。

    什么是闭包

    闭包的定义,你需要掌握它才能理解和识别闭包:

    当函数可以记住并访问所在的词法作用域时,就产生了闭包,即便函数是在当前词法作用域之外执行。

    下面用一些代码来解释这个定义:

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

    很明显这是一个嵌套作用域,而bar的作用域也确实能够访问外部作用域,但这就是闭包吗?

    不,不完全是,但它是闭包中很重要的一部分:根据词法作用域的查找规则,它能够访问外部作用域。

    下面再来看这段代码,它清晰地使用了闭包:

    function foo(){
        var a = 2;
        function bar(){
            console.log(a);
        }
        return bar;
    }
    var baz = foo();
    baz(); // 2 —— 这就是闭包
    

    由于bar的词法作用域能够访问foo的内部作用域,然后我们把bar这个函数本身当作返回值,然后在调用foo时把bar引用的函数赋值给baz(其实是两个标识符引用同一个函数),所以baz能够访问foo的内部作用域。

    而这里正是印证前面的定义:函数是在当前词法作用域之外执行。

    其实按正常情况下,引擎有垃圾回收器用来释放不再使用的内存空间,当foo执行完毕时,自然会将其回收,但闭包的神奇之处正是可以阻止这件事情的发生,因为内部作用域依然存在,bar在使用它。

    由于bar声明位置的原因,它涵盖了foo内部作用域的闭包,使得该作用域能够一直存活,以供bar在之后任何时间进行引用。

    bar依然有对该作用域的引用,而这个引用就叫做闭包。

    因此,当baz在调用时,它自然能够访问到foo的内部作用域。

    当然,无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包的存在:

    function foo(){
        var a = 2;
        function baz(){
            console.log(a);
        }
        bar(baz);
    }
    function bar(fn){
        fn(); // 2 —— 这也是闭包
    }
    

    把内部函数baz作为fn参数传递给bar,当调用fn时,它能够访问到foo的内部作用域。

    传递函数也可以是间接的:

    var fn;
    function foo(){
        var a = 2;
        function baz(){
            console.log(a);
        }
        fn = baz;
    }
    foo();
    fn(); // 2 —— 这也是闭包
    

    所以:

    无论通过何种方式将内部函数传递到所在的词法作用于之外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

    闭包的使用

    既然前面说闭包无处不在,那不妨看看几个平时经常看到的片段,看看闭包的妙用。

    function wait(message){
        setTimeout(function timer(){
            console.log(message);
        },1000);
    }
    wait("Hello, closure!");
    

    将一个内部函数(这里叫做timer)作为参数传递给setTimeout,而timer能够访问wait的内部作用域。

    如果你使用过jQuery,不难发现下面代码中也使用了闭包:

    function setupBot(name,selector){
        $(selector).click(function activator(){
            console.log("Activating:" + name);
        })
    }
    setupBot("Closure Bot 1","#btn_1");
    setupBot("Closure Bot 2","#btn_2");
    

    本质上无论何时何地,如果将函数( 访问它们各自的词法作用域)当作第一级的值类型并到处传递, 你就会看到闭包在这些函数中的应用。 在定时器、 事件监听器、Ajax 请求、 跨窗口通信、Web Workers 或者任何其他的异步( 或者同步)任务中, 只要使用了回调函数,实际上就是在使用闭包!

    再来看一个很经典的闭包面试题:

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

    正常情况下,我们对这段代码行为的预期是每秒一次输出 1~5。

    但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。

    为什么?

    首先解释 6 是从哪里来的,这个循环的终止条件是i不再<=5,所以当条件成立时,i等于 6。因此,输出显示的是循环结束时i的最终值。

    也就是我们陷入了一个这样的误区:以为循环中每个迭代在运行时都会复制一个i的副本,但根据作用域的工作原理,它们都共享同一个全局作用域,因此实际上只有一个i

    要使这段代码的运行与我们预期一致,解决方法如下:

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

    在这段代码中我们使用了IIFE,将i作为参数j传递进去,在每个迭代IIFE会生成一个自己的作用域,它们接受参数j不一样,所以这段代码能够符合我们预期地运行。

    还有别的解决方案吗?

    是的,使用 ES6 新出的let可以解决这个问题:

    for (let i=1; i<=5; i++){
    	setTimeout(function(){
    		console.log(i);
        },i*1000);
    }
    

    我们仅仅把var替换为let就轻松地解决了该问题,原因如下:

    • for中有自己的块作用域(()是父级作用域,{}是子级作用域)。
    • 使用let能够创建块作用域的变量。

    好了,到现在你应该能够很容易地识别闭包,那么接下来,我们继续介绍闭包更高级的用法。

    假设我们有这样一个对象:

    var box = {
        age : 18,
    }
    console.log(box.age); // 18
    

    然而这里有一个问题,那就是属性age可以随意改变,如果我们使用闭包,就可以实现私有化,将age属性保护起来,只做允许的修改。

    var box = (function (){
        var age = 18;
        return {
            birthday : function(){
                age++;
            },
            sayAge : function(){
                console.log(age);
            }
        }
    })();
    box.birthday();
    box.sayAge(); // 19
    

    这样我们就保证age属性只能增加,而不能减少,毕竟没有人能够越活越年轻。

    注意:

    1. 其实对象也有方法可以控制属性的修改,但这里主要讲述闭包,就不过多赘述。
    2. 使用闭包能够轻松实现原本在 JavaScript 较复杂的设计。

    后记

    其实当你理解了闭包之后,你就会发现一切都是那么的理所当然,就仿佛它本该如此。

    最后,如果你已经理解了闭包并且想练习一下,那么我可以出一道题目给你:

    实现一个add函数,功能:add(1)(2)(3); // 6

    难一点的:

    实现一个add函数,功能:add(3)(‘*’)(3); // 9

    有几点:

    1. add函数可以被无限调用。
    2. 调用完毕后将结果输出到控制台。

    感谢观看!

    注:此文为原创文章,如需转载,请注明出处。

    目前尚无回复
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2499 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 23ms · UTC 15:45 · PVG 23:45 · LAX 07:45 · JFK 10:45
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.