本文旨在总结在js学习过程中,对闭包的思考,理解,以及一些反思,如有不足,还请大家指教

闭包---closure

闭包是js中比较特殊的一个概念,其特殊之处在于,本身有些晦涩难懂,是js中的一种高级用法,但其实闭包在整个js中无处不在,甚至很多情况下都是在不知情的情况下用到了闭包。

下面就从原理上分析一下闭包。

要谈闭包,就必须先说一下词法作用域,因为闭包是基于词法作用域书写代码时所产生的自然结果

词法作用域

由于本文章不是详细介绍词法作用域的。所以就只把闭包中利用到的部分做一个简要的说明。

闭包利用的,其实就是作用域嵌套情况下,内部作用域可以访问外部作用域这一特性。

function foo(a) {
    let b = a * 2;
    function bar(c) {
        console.log(a, b, c);
    }
    bar(b * 3);
}

对于foo所创建的作用域,其中有三个标识符:a, bar和b
bar所创建的作用域,其中有一个标识符: c

但是由于bar完全处于foo的作用域内部,所以在bar中查询变量a时,由于bar中不包含a,就会根据词法作用域的规则,到其父作用域中寻找变量a
所以bar的作用域中实际可以使用的变量是a,b,c,bar。

这一原理正式闭包的基础

闭包

首先说一下笔者比较认同的闭包的定义:
如果函数可以访问自身作用域以外的变量,并在词法作用域以外的地方执行,那么就可以认为创建了一个闭包

对于闭包的定义其实笔者遇到过一种说法

眼镜鉴别法-----()()
这是在学校中学习时听到的一套理论,表示的是立即执行函数IIFE(immediately invoked function expression)通常都会是一个闭包

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

但是这种说法并不准确,因为IIFE函数虽然访问了自身作用域外的变量,但是是在定义他的作用域内运行的(此处为window),所以这并不能算是一个典型的闭包

下面写一个真正符合定义的闭包

let fn;
function foo () {
    let a = 2;
    function baz() {
        console.log(a);
    }
    fn = baz;
}
        
function bar() {
    fn();  //此处为闭包
}
        
foo();
bar();  // 2

之所以fn可以称为闭包,是因为保留了baz的引用,而根据词法作用域的规则,baz是可以访问外部变量a的,于是fn就可以访问foo内的变量a了,并且可以在任意一个地方进行调用。

写到这个时候,闭包的概念就介绍完了,下面浅谈一下垃圾回收机制GC(garbage collection)。

垃圾回收机制(garbage collection)

一般能见到的垃圾回收机制有两种

标记清除

当一个变量进入环境时,会被标记为‘进入环境’,并被分配内存。
当变量被标记为‘进入环境’时,其占用的内存是永远不会被清空的。
而当变量离开环境时,则将其标记为‘离开环境’,并在下一次垃圾回收的轮询中,将其清除,

引用计数

这个策略不太常见。。甚至已经绝种。。。
其原理可以理解为,对于一个变量,有一个统计其引用次数的统计器,当引用次数变为0时,就会被清除。
但因为当存在循环引用时,这种GC策略可能会导致一些bug,所以渐渐用这种策略的就不多了。

之所以要将闭包和垃圾回收策略联系在一起,是因为这涉及到闭包的一些重要特性,如变量暂时存储和内存泄漏。
在一般的函数执行过程中

function foo (a) {
    console.log(a);
}
foo(1); //1

foo函数被调用完成后,变量a就会离开环境(或者说其引用数为0),那a所占用的内存就会被清除
但对于闭包

function foo() {
    let a = 2;
    return function() {
        console.log(a);
    }
}

let bar  = foo();
bar(); // 2

由于bar中保留了对a的引用,使a无法被打上‘离开环境’的标签,所以变量a会一直占用内存,由此引申出一些高级功能,如函数调用次数统计,函数柯里化等,随之而来的,因为闭包会持续占用内存,当系统中存在很多闭包时,必然会影响性能,甚至导致内存泄漏。
所以当使用完闭包后,应该尝试手动清除内存

bar = null;

闭包的用途

下面介绍几种笔者遇到的闭包的实例,可能经验优先,欢迎大家补充

异步for循环问题

for (var i = 0; i < 10; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000);
}

这例子,不能再眼熟,不能再经典的一个坑
这个坑,归根结底,就是因为闭包。
但是因为涉及到一些js异步策略的问题,所以就简单分析下

js代码会分为同步和异步两种方式运行,在一个任务队列中,会先执行同步操作,如遇到异步操作(回调),会将异步操作先挂起,待本队列中的所有同步操作都跑完后,再去跑异步操作。
所以for循环中的代码逻辑,大概可以解释为
for - start
var i = 0;
setTimeout----异步回调挂起
i = 1;
setTimeout----异步回调挂起
...
...
...
i = 9;
setTimeout----异步回调挂起
i = 10;
for - end
// 开始执行异步的回调

function() {
    console.log(i); // 10
}

到此大家应该就可以理解为什么会打出10个10了。
因为匿名函数回调的闭包实际引用的是变量i,而非变量i的值。
所以对于这个坑,解决方法就是将变量引用变为值引用

for (var i = 0;i < 10; i++) {
    (function (j) {
        setTimeout(function() {
            console.log(j);
        });
    })(i);
}

由于函数参数是采用值传递的方式,所以解决了这个问题
ES6中提供了一种更简单的做法

for(let i = 0; i++; i < 10) {
    setTimeout(() => {
        console.log(i);
    });
}

由于let声明在for循环中有不同的表现----既每次循环并非只是previous value进行了++操作,而是加++后的值赋值给了一个新的变量,所以解决了闭包只会引用变量最新值的这个问题

函数调用统计

如果想统计一个函数的调用次数,那就应该存在一个计数器变量用以统计,但如果将这个变量放在全局环境中,就会存在变量污染问题。
但如果将其放在闭包中,就可以保证在不污染全局的前提下,进行变量保存

function foo() {
    let i = 0;
    return function target() {
        ......
        i++;
    }
}
let bar = foo();
bar() // i = 1
bar() // i = 2

函数柯里化

柯里化的理论知识可以参考张鑫旭大佬的个人网站
柯里化的定义和代码实例

这里简单说下柯里化,柯里化有很多很多的用途,但下文主要介绍的是利用柯里化设置函数默认参数的问题
首先我们先看下es5环境下的张鑫旭大佬的柯里化代码

var currying = function(fn) {
    // fn 指官员消化老婆的手段
    var args = [].slice.call(arguments, 1);
    // args 指的是那个合法老婆
    return function() {
        // 已经有的老婆和新搞定的老婆们合成一体,方便控制
        var newArgs = args.concat([].slice.call(arguments));
        // 这些老婆们用 fn 这个手段消化利用,完成韦小宝前辈的壮举并返回
        return fn.apply(null, newArgs);
    };
};

// 下为官员如何搞定7个老婆的测试
// 获得合法老婆
var getWife = currying(function() {
    var allWife = [].slice.call(arguments);
    // allwife 就是所有的老婆的,包括暗渡陈仓进来的老婆
    console.log(allWife.join(";"));
}, "合法老婆");

getWife("大老婆","小老婆","俏老婆");
// 合法老婆;大老婆;小老婆;俏老婆


getWife("超越韦小宝的老婆");
// 合法老婆;超越韦小宝的老婆

其原理其实就是通过闭包的方法将默认参数存储在外层函数的作用域中,其实现方法与函数调用计数器是相似的。
下面贴出个人在es6环境下对这段代码的优化

function currying(fn, ...default_args) {
    return function (...args) {
        return fn(...default_args, ...args);
    };
}


const getWife = currying(function () {
    console.log([...arguments].join(';'));
}, 'a');

getWife('b', 'c', 'd'); // a;b;c;d

主要的优化点是利用拓展运算符对arguments的展开作用,取代繁琐的Array.slice.call()和Array.concat.call()

模块化

因为es6中已经有了十分完备的模块机制,所以我们只是利用闭包来做一个模块化思想的实现,该实现包括了模块存储,依赖关系处理

let MyModules = (function Manager() {
    let modules = {};
    
    function define(name, deps, impl) {
        deps = deps.map(val => {
            return modules[val];
        }
        modules[name] = impl.apply(impl, deps);
    }
    
    function get(name) {
        return modules[name];
    }
    
    return {
        define: define,
        get: get
    }
})();

对闭包的总结差不多就到这了,感觉一番总结之下,还是有所收获的,也希望能给看到这篇文章的各位,提供一些帮助。
不足之处,欢迎各位一起来交流

以上内容参考

javascript高级程序设计(第三版)(Nicolas C.Zakas)
你不知道的javascript 上卷(Kyle Simpson)
张鑫旭大佬的个人主页


Heptagon
221 声望6 粉丝