2022面试JS基础部分专题——闭包(closure)

360 阅读8分钟

写这篇文章的初衷

开头从何说起呢,就从这卷起来的势头已经挡不住的2022年初开始吧,众所周知互联网行业2021年的情况是起起落落落落落......,所以我们的口号是,早准备面试早学习卷别人,晚准备晚学习被人卷。因为这样的行业大前景,以前我们掌握的知识点深度我个人认为已经无法支持我们在这个环境下舒适的生存了。所以深挖吧少年们。

闭包的定义

谈到闭包绕不开的第一点就是他的精准定义是什么,这里引用MDN对于闭包的定义。

closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function’s scope from an inner function. In JavaScript, closures are created every time a function is created, at function creation time.

我的理解就是简单说闭包是一种组合,一个函数对象和对声明这个函数对象时所在词法环境(lexical environment) 的引用捆绑在一起的一种组合。

词法环境是ES5规范提出的新的对于静态作用域的管理方式,词法环境有两个部分组成,环境记录器(Environment Record)和对于外部环境的引用(out reference)。其中对于外部环境的引用一定要注意,他具体指向那个外部环境是由一个函数定义的时候决定的,规范使用[[scope]]这个属性来记录函数定义时的具体环境,然后当函数执行的时候创建对应的词法环境时,对外部环境的引用便指向[[scope]]。

所以我们可以把闭包拆解成一个公式来看: 闭包 = 函数对象 + 一个对词法环境的引用,关键的是理解这个引用是引用了哪个词法环境。简单的说就是函数创建的时候所在的环境。

看到这里我其实把自己对于闭包的定义的理解都阐述了一下,但是肯定有很多小伙伴会产生一个疑问,虽然自己可能不能很准确的定义闭包,但是你的印象中告诉你闭包不是一个函数的事情,是两个函数的一个嵌套。简单来说是一个函数里面返回另一个函数,这才是你看过很多人说的闭包的概念,还会有些小伙伴记得有人告诉你闭包会产生内存泄漏。这些疑问请看下去我来一一回答,但是请记住以后不要把闭包定义为函数的嵌套返回产生的。 在JavaScript中闭包的产生伴随着每一次函数的创建, 也就是说你就是只定义一个函数也产生了闭包。

对于闭包的不同理解

常见的错误理解:闭包是能够访问另一个函数作用域的变量的函数

在网上常见的一种对于闭包的定义:闭包是能够访问另一个函数作用域的变量的函数。这种定义把闭包解释为一种有特殊功能的函数,我认为是一种不正确的理解。这种解释只是单纯的关注了闭包可以被观察到的具体现象。出现这种解释的原因其实和闭包最显著的特征有关系。我们上面对于闭包的定义公式中强调了一下构成闭包的一个组成因素定义函数的时候可以访问的词法作用域,但是怎么证明或者观察闭包成功的引用了这个词法环境呢。这就需要使用一些方法把一个函数传递或者返回出去。其中最常见的做法如下:

function foo() {
    var a = 'foo内部环境变量';
     
    function bar() {
        console.log( a );
    }
    
    return bar;
}

var baz = foo();

var a = 'global环境中的变量';

baz(); // 'foo内部环境变量' ——这就证明了bar函数定义的时候成功引用了当时可以访问的环境

这是最典型的能够证明闭包存在的例子,因此有很多人就把类似bar这样能够直接观察到闭包现象的函数定义为了闭包。但是我研究了一下闭包这个概念的由来和MDN规范的定义,发现这样理解闭包是不对的。

一种和我个人认识不同的解释

还有一种对于闭包的解释,是大神在 《你不知道的JavaScript》 一书中提出的:

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

请注意下面的例子

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

foo();

大神认为上面的代码不构成闭包,因为bar方法对于a的引用是依赖了词法作用域的查找规则,而这个规则只是闭包的一部分。这样的理解和我自己本人的理解并不完全相同,但是这些都只是纯粹的学术角度。我理解每一个函数的创建都产生了闭包。虽然上面的例子好像应该理所当然的打印出2没有体现出闭包的特殊作用,但是不能否认bar函数和它所处的环境一起同样构成了闭包。

对于闭包会产生内存泄漏的误解

闭包会产生内存泄漏其实是一个错误的认识,这个认识应该是由于早期的IE浏览器垃圾回收机制存在的一些问题导致的。由于IE9之前的版本对JavaScript对象和COM对象使用不同的垃圾回收机制。具体来说,如果闭包的作用域链中保存着一个HTML元素,那么就意味着该元素将无法被销毁,产生内存泄漏。

function assignHandler() {
    var element = document.getElementById('xxxx');
    element.onclick = function handleClick() {
        console.log(element.id);
    }
}

handleClick函数保存了一个对assignHandler词法环境的引用,因此就会导致无法减少element的引用数。只要handleClick函数存在,element的引用数至少也是1,因此它所占用的内存就永远不会被回收。 解决办法就是使用一个临时变量存储id,消除对该变量的循环引用同时将element变量设为null。

function assignHandler() {
    var element = document.getElementById('xxxx');
    var id = element.id;
    element.onclick = function handleClick() {
        console.log(id);
    };
    element = null;
}

根据我们上面对于闭包的理解,闭包本身不会产生内存泄漏,因为其实你每次创建函数都是在创建闭包。但是在某些浏览器环境下,因为垃圾回收机制的问题容易出现一些问题。闭包本身虽然不会产生内存泄漏但是大家注意,闭包能够访问外部函数作用域变量的功能在使用的过程中一定要小心,因为你引用了外部函数的环境就会导致内存的开销加大,使用不好可能会导致你的程序内存占用过大出现内存溢出。

对于内存问题的关注其实不只是使用闭包功能的时候需要注意,在我们写代码的过程中如果不了解浏览器的回收机制和底层机制。各个方面都容易出现问题,所以下一次有机会会和大家一起聊聊浏览器的内存回收机制,有助于提高我们对于代码的掌控能力。

闭包的主要作用

最后和大家一起了解一些对于闭包的常见使用:

封装私有变量

function People(num) { // 构造器
  var lives = num;
  this.getLives = function() {
    return lives;
  };
  this.addLives = function() {
    lives++;
  };
  this.subLives = function() {
    lives--;
  };
}
var xiaoming = new People(5); 
xiaoming.addLives();
console.log(xiaoming.lives);      // undefined
console.log(xiaoming.getLives()); // 6
var xiaohong = new People(20);
console.log(xiaohong.getLives()); // 20

自增函数

function createAGenerate(count, increment) {
    return function(){ 
        count += increment; return count; 
    } 
} 
let generateNextNumber = createAGenerate(0, 1); 
console.log(generateNextNumber()); //1 
console.log(generateNextNumber()); //2 
console.log(generateNextNumber()); //3 
let generateMultipleOfTen = createAGenerate(0, 10);
console.log(generateMultipleOfTen()); //10 
console.log(generateMultipleOfTen()); //20 
console.log(generateMultipleOfTen()); //30

使用闭包代替全局变量

//全局变量,test1是全局变量
var test1=111 
function outer(){
    alert(test1);
}
outer(); //111
alert(test1); //111
 
 
//闭包,test2是局部变量,这是闭包的目的
//我们经常在小范围使用全局变量,
//这个时候就可以使用闭包来代替。
(function(){
    var test2=222;
    function outer(){
        alert(test2);
    }
    function test(){
        alert("测试闭包:"+test2);
    }
    outer(); //222
    test(); //测试闭包:222
    }
)(); 
alert(test2); 
//未定义,这里就访问不到test2

模块化

NodeJS 会给每个文件包上这样一层函数,引入模块使用 require,导出使用 exports,而那些文件中定义的变量也将留在这个闭包中,不会污染到其他地方。

(funciton(exports, require, module, __filename, __dirname) {
    /* 自己写的代码  */
})();

其他

还有很多利用闭包的特性实现的小功能,比如防抖,节流,给DOM元素循环绑定事件处理方法,为函数提前保存一些具体参数等等。包括定时器,事件监听器,Ajax请求,跨窗口通信,Web Works或者其他的异步任务中传递或者使用了回调函数其实就是实际上对于闭包的使用。我们理解了闭包之后看看自己的代码,会发现其实我们一直在使用它,只不过之前我们并没有对他有一个具体而深刻的理解。所以在使用的过程中可能会出现错误和不符合预期的表现,但是当我们重新构建了自己的头脑并理解了闭包的深刻含义,会发现原来没有什么魔法,他们都有着自己的运行逻辑支持这。之前你看不懂只是你不够理解。

参考资料

《你不知道的JavaScript》

MDN闭包

发现 JavaScript 中闭包的强大威力

闭包会造成内存泄漏吗

JavaScript闭包的底层运行机制