面试时高频问到的“闭包”

2021-11-16T10:01:00

面试时高频问到的“闭包”

千呼万唤始出来,犹抱琵琶半遮面,这么形容JavaScript的闭包(closure),一点也不过分。闭包是JavaScript最强大的特性,没有之一。很多强大JavaScript库比如jQuery、Vue.js都使用了闭包的特性来实现的。闭包几乎是一线互联网企业面试必问的题。

看个小例子:循环中的闭包

在上一篇文章讲述bind的时候有个小例子,是一个经典的循环中的闭包 的问题。代码如下:

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

本意是想每隔一秒依次输出“1 2 3 4 5”,结果变成输出“6 6 6 6 6 ”。为什么会这样呢,根据作用域链上变量查找机制,setTimeout第一个参数的函数体内的i引用了去全局作用域里面的i,当for循环完毕后,i的值为6,所以输出了“6 6 6 6 6 ”。

上一篇文章说过,用bind可以达到正确输出的目的,并提到过本质上使用bind其实也是用到了闭包。但是什么是闭包呢,用代码来感受一下。下面这段代码,寥寥几行,用闭包就轻松达成了目的。

for (var i = 1; i <= 5; i++) {
  (function(j) {//包了一层IIFE形式的函数,这个函数是闭包
    setTimeout(function test() {//函数体内的j引用了外层匿名函数的参数j
      console.log(j); //>> 1 2 3 4 5
    }, j * 1000);
  })(i);
}

“噢,在循环体内的最外层包了一层IIFE形式的函数,这个函数就是闭包了吗?”

你猜对了一半!

首先,每个闭包肯定是一个函数。

其次,每个内层的函数,需要引用它上层作用域里的参数/变量。比如上面的代码,IIFE函数体内的变量j,引用了上层作用域的参数j,也即for循环里面的i

什么是闭包

1. 闭包的定义

关于JavaScript闭包的定义有很多种,每本书、每个作者都有不完全相同的描述,虽然笔者认为函数就是闭包 这个定义才是最简单最直白的,但其实笔者看到过不下十种定义,到现在一种都记不住。鉴于此,我们干脆不要记住这些五花八门的定义了,只要记住了产生闭包的时机会更实际一些,面试时,把闭包产生的时机告诉面试官就可以了:

内层的作用域访问它外层函数作用域里的参数/变量/函数时,闭包就产生了。

让我们用代码来说事儿吧:

function func(){//func1引用了它外层的变量a,因此func成为了闭包
    let a="coffe";
    function func1(){
        console.log(a);//访问了外层函数func体内的变量a
        debugger;//断点
    }

    func1();
}

func();

我们在chrome浏览器的“开发者工具”里面的控制台,运行上面的代码,可以很方便看到闭包。

看上面这个图,Closure出现在Scope一栏里面,所以可以认为闭包也是一种作用域 。既然闭包也是一种作用域,闭包能够解决经典的“循环中的闭包”的问题,那是不是利用作用域就能解决问题?让笔者想到了关键字let,试试看吧,把本文开头的代码改造一下:

for (var i = 1; i <= 5; i++) {
   {
      let j = i;
      setTimeout(function test() {
           console.log(j) //>> 1 2 3 4 5
       }, j * 1000);
    }
}

果然,用let关键字包上一个作用域,也能和闭包一样解决问题达成目的。因此可以说,闭包是一种作用域,它拷贝了一套外层函数作用域中被访问的参数、变量/函数 ,这个拷贝都是浅拷贝(关于浅拷贝详见《壹.3.1 深拷贝与浅拷贝》)。

2. 写成闭包形式有什么好处呢?

当然有好处!还是以之前的代码为例,变量a类似于高级语言的私有属性,无法被func外部作用域访问和修改,只有func内部的作用域(含嵌套作用域)可以访问。这样可以实现软件设计上的封装 ,设计出很强大的类库、框架,比如我们常用的jQuery、AngularJS、Vue.js。

看一个ES6出现之前最常见的模块化封装的例子:

//定义一个模块
function module(n) {
  //私有属性
  let name = n;
  //私有方法
  function getModuleName() {
    return name;
  }
  //私有方法
  function someMethod() {
    console.log("coffe1891");
  }
  //以一个对象的形式返回
  return {
    getModuleName: getModuleName,
    getXXX: someMethod
  };
}

let myapp = module("myModule");//定义一个模块
console.log(myapp.getModuleName()); //>> myModule
console.log(myapp.getXXX()); //>> coffe1891

3. 闭包有什么缺点吗?

javascript中的垃圾回收(GC)规则是这样的:如果对象不再被引用,或者对象互相引用形成数据孤岛后且没有被孤岛之外的其他对象引用,那么这些对象将会被JS引擎的垃圾回收器回收;反之,这些对象一直会保存在内存中。

由于闭包会引用包含它的外层函数作用域里的变量/函数,因此会比其他非闭包形式的函数占用更多内存。当外层函数执行完毕退出函数调用栈(call stack)的时候,外层函数作用域里变量因为被引用着,可能并不会被JS引擎的垃圾回收器回收,因而会引起内存泄漏。过度使用闭包,会导致内存占用过多,甚至内存泄漏。

function A(){
    var count = 0;
    function B(){
       count ++;
       console.log(count);
    }
    return B;//函数B保持了对count的引用
}
var b = A();
b();//>> 1
b();//>> 2
b();//>> 3

count是函数A中的一个变量,它的值在函数B中被改变,B每执行一次,count的值就在原来的基础上累加1。因此,函数A中的count一直保存在内存中,并没有因为函数A执行完毕退出函数调用栈后被JS引擎的垃圾回收器回收掉。

避免闭包导致内存泄漏的解决方法是,在函数A执行完毕退出函数调用栈之前,将不再使用的局部变量全部删除或者赋值为null。

其他使用场景介绍

除了上面介绍过的循环中的闭包、模块化封装之外,闭包还有一些其他写法。

1. 返回一个新函数

function sayHello2(name) {
    var text = "Hello " + name; // 局部变量

    var sayAlert = function() {
        console.log(text);
    };

    return sayAlert;
}

var say2 = sayHello2("coffe1891");
say2(); //>> Hello coffe1891

调用sayHello2()函数返回了sayAlert,赋值给say2。注意say2是一个引用变量,指向一个函数本身,而不是指向一个变量。

2. 扩展全局对象的方法

下面这种利用闭包扩展全局对象,可以有效地保护私有变量,形成一定的封装、持久性。

function setupSomeGlobals() {
    //私有变量
    var num = 666;

    gAlertNumber = function() {//没有用var和let关键字声明,会成为全局对象的方法
        console.log(num);
    };

    gIncreaseNumber = function() {
        num++;
    };

    gSetNumber = function(x) {
        num = x;
    };
}

setupSomeGlobals();
gAlertNumber(); //>> 666

gIncreaseNumber();
gAlertNumber(); //>> 667

gSetNumber(1891);
gAlertNumber(); //>> 1891

三个全局函数gAlertNumbergIncreaseNumbergSetNumber指向了同一个闭包,因为它们是在同一次setupSomeGlobals()调用中声明的。它们所指向的闭包是与setupSomeGlobals()函数关联一个作用域,该作用域包括了num变量的拷贝。也就是说,这三个函数操作的是同一个num变量。

3. 延长局部变量的生命

日常开发时,Image对象经常被用于数据统计的上报,示例代码如下:

var report = function(src) {
    var img = new Image();
    img.src = src;
}
report('http://www.xxx.com/getClientInfo');//把客户端信息上报数据

这段代码在运行时,发现在一些低版本浏览器上存在bug,会丢失部分数据上报。原因是Image对象是report函数中的局部变量,当report函数调用结束后,Image对象随即被JS引擎垃圾回收器回收,而此时可能还没来得及发出http请求,所以可能导致此次上报数据的请求失败。

怎么办呢?我们可以使用闭包把Image对象封闭起来,就可以解决数据丢失的问题,代码如下:

var report = (function() {
    var imgs = [];//在内存里持久化
    return function(src) {
        var img = new Image();
        imgs.push(img);//引用局部变量imgs
        img.src = src;
    }
}());
report('http://www.xxx.com/getClientInfo');//把客户端信息上报数据
当前页面是本站的「Baidu MIP」版。发表评论请点击:完整版 »