【面试题集 —— No.12】JavaScript中的函数闭包问题


一. 变量作用域

理解闭包,首先必须理解变量作用域。在ECMAScript5的标准中有两种作用域:全局作用域和函数作用域。两者的调用关系是:

  • 函数内部可以直接读取全局变量;
  • 正常情况下,函数外部无法读取函数内部声明的变量;
let num = 1;

function func() {
  let n = 2;
  console.log(num);
}

func();  // 1
console.log(n);
// ReferenceError: n is not defined

实际开发中会出于各种原因,我们必须得拿到函数内部的局部变量。

JavaScript 语言规定:父对象的所有变量,对子对象都是可见的,反之则不成立。即"链式作用域"结构(chain scope) 。基于这一点,我们就可以在目标函数内再定义一个函数,这个子函数就可以正常访问其父函数的内部变量。

function parent() {
  let n = 1;
  function child() {
  console.log(n); // 1
  }
}

既然子函数可以拿到父函数的局部变量,那么父函数直接返回这个子函数,不就达到了在全局作用域下访问函数内部变量的目的吗!?

function parent() {
  let n = 1;
  function child() {
  console.log(n);
  };
  return child;
}

let f1 = parent();
f1();  // 1

二. 闭包的概念及特性

上述的例子就是一个最简单的闭包的写法:函数child就是闭包。所以闭包就是一个“定义在函数内部的函数”。 在本质上,闭包就是一座连接函数内外的桥梁。

闭包本身还具有以下几点重要的特性:

  • 函数内嵌套函数;
  • 闭包内可以访问其外层函数的内部参数,变量或方法;
  • 闭包内用到的参数和变量会始终保存在内存中,不会在函数调用结束后,被垃圾回收机制回收;
  • 同一个闭包机制可以创建出多个闭包函数实例,它们彼此独立,互不影响;

三. 闭包的经典写法

3.1 函数作为返回值

上述的例子还可以进一步精简为匿名函数的写法:

通过匿名函数访问其外层函数的内部变量num,然后外层函数返回该匿名函数,该匿名函数继续返回num变量。

function closure1(){
    let num = 1;
    return function(){
        return num
    }
}

let fn1 = closure1();
console.log(fn1());

这样就可以在全局作用域下声明一个变量fn1 来承接num变量,这样就达到了在全局作用域访问函数内局部变量的目的。

3.1.1 保存变量

闭包在可以读取函数内局部变量的同时,它还可以让这些变量始终保存在内存中,不会在函数调用结束后,被垃圾回收机制回收。比如这个例子:

function closure2(){
    let num = 2;
    return function(){
        let n = 0;
        console.log(n++);
        console.log(num++);
    }
}

let fn2 = closure2();
fn2();  // 0 2
fn2();  // 0 3

执行两次函数实例fn2(),可以看到结果是略有差异的:

  • n++两次输出一致:
    变量n是匿名函数的内部变量,在匿名函数调用结束后,它这块内存空间就会被正常释放,即被垃圾回收机制回收。
  • num++两次输出不一致:
    匿名函数内引用了其外层函数的局部变量num,即使匿名函数的调用结束了,但是这种依赖关系依然存在,所以变量num就无法被销毁。一直保存在内存中,匿名函数下次调用时,就会继续沿用上次的调用结果。

利用闭包的这一特性,确实可以做简单的数据缓存。但是也不能滥用闭包,这样很容易使内存消耗增大,进而导致内存泄漏或者网页的性能问题。

3.1.2 多个闭包函数彼此独立

同一个闭包机制可以创建出多个闭包函数实例,它们彼此独立,互不影响。比如下面这个简单的例子:

function fn(num){
    return function(){
        return num++
    }
}

我们分别声明三个闭包函数实例,分别传入不同的参数。然后分别执行1,2,3次:

let f1 = fn(10);
let f2 = fn(20);
let f3 = fn(30);

f1()  // 10
f2()  // 10
f2()  // 11
f3()  // 10
f3()  // 11
f3()  // 12

可以看到:f1()f2()f3()的第一次执行都输出了10,多执行的也是在自身上次执行的结果上累加的。互相之间没有影响。

3.2 立即执行函数(IIFE)

上一种写法中函数只是作为返回值返回,而具体的函数调用是写在其他地方。那么我们能不能让外层函数直接返回闭包的调用结果呢?

答案当然是可以的:采用立即执行函数(IIFE)的写法。 接下来就先了解一下具体什么是立即执行函数(IIFE):

我们都知道,在 JavaScript中调用函数最常用的方法就是函数名之后跟圆括号()。有时,我们需要在定义函数之后,立即调用该函数。但是你不能直接在函数定义之后加上圆括号,这样会产生语法错误。

function funcName(){}();
// 提示语法错误

产生错误的原因是,function关键字即可以当作语句,也可以当作表达式。

// 语句
function f() {}

// 表达式
var f = function f() {}

当作表达式时,函数可以定义后直接加圆括号调用。

var f = function f(){ return 1}();
f // 1

为了避免解析的歧义,JavaScript 规定,如果function关键字出现在行首,一律解释成语句。那么如果我们还想用function关键字声明函数后能立即调用,就需要让function不直接出现在行首,让引擎将其理解成一个表达式。最简单的处理,就是将其放在一个圆括号里面。

(function(){ /* code */ }());
// 或者
(function(){ /* code */ })();

这就叫做“立即调用的函数表达式”(Immediately-Invoked Function Expression),即立即执行函数,简称 IIFE 。

3.2.1 定时器setTimeout的经典循环输出问题

了解过立即执行函数后,赶紧来看一个实例:使用for循环依次输出1~5。那么如果是下面的代码,它的运行结果是什么?

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

结果肯定是输出5个6。原因是for循环属于同步任务,setTimeout定时器属于异步任务的宏任务范畴。JavaScript引擎会优先执行同步的主线程代码,再去执行宏任务。

所以在执行setTimeout定时器之前,for循环就已经结束了,此时循环变量i = 6。然后setTimeout定时器被循环创建了5次,全部执行完毕也就输出了5个6。

但是我们的目的是希望输出1~5,这样显然没达到要求。在正式介绍立即执行函数(IIFE)的写法之前,我先说另外一种方法:循环变量 i 使用let关键字声明。

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

为什么换成let声明之后就可以呢?是因为要想实现1~5循环输出的本质要求是记住每次循环时循环变量的值。而let的声明方式恰好就可以满足。

这样再来看立即执行函数(IIFE)的写法:

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

setTimeout定时器函数用一个外层匿名函数包裹构成闭包的形式,然后再采用立即执行函数(IIFE)的写法:继续用圆括号包裹外层匿名函数,然后跟上圆括号调用,并把每次的循环变量作为参数传入。

这样每次循环的结果就是闭包的调用结果:输出 i 的值;再根据闭包本身的特性之一:可以保存变量或参数,就满足了所有条件从而正确输出了1~5。


再多说一点,目前的输出形式是一秒后同时输出1~5;那我想这五个数字每隔一秒再输出一个呢?

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

可以控制每个setTimeout定时器的第二个参数:间隔时长,依次乘上循环变量 i 即可。效果如下:

在这里插入图片描述


3.2.2 函数作为API的形参传入

闭包结合立即执行函数(IIFE) 的这种机制还有一类很重要的用处是:需要函数作为形参的各种API。就以数组的sort()方法为例:

Array.prototype.sort()方法中支持传入一个比较器函数,来让我们自定义排序的规则。该比较器函数必须要有返回值,推荐返回Number类型。

比如以下的数组场景,我们希望你能编写一个mySort()方法:可以按照指定的任意属性值降序排列数组元素。

var arr = [{
    name:"code",age:19,grade:98
},{
    name:"zevin",age:12,grade:94
},{
    name:"j",age:15,grade:91
}];

mySort()方法肯定需要两个形参:需要排序的数组arr和指定的属性值property。另外用到的API肯定还是sort()方法,这里我们就不能直接传入一个比较器函数,而是采用闭包的IIFE写法:

属性值property作为参数传入外层匿名函数,然后匿名函数内部返回最终sort()方法需要的比较器函数。

function mySort(arr,property){
    arr.sort((function(prop){
        return function(a,b){
            return a[prop] > b[prop] ? -1 : a[prop] < b[prop] ? 1 : 0;
        }
    })(property));
};

来测试一下:比如我们想让数组按照成绩降序排列。

mySort(arr,"grade");
console.log(arr);


——————OUTPUT——————
[
  { name: 'code', age: 19, grade: 98 },
  { name: 'zevin', age: 12, grade: 94 },
  { name: 'j', age: 15, grade: 91 }
]

3.3 封装对象的私有属性和私有方法

闭包同时也可以用于对象的封装。尤其是封装对象的私有属性和私有方法:

function Person(name) {
  var _age;
  function setAge(n) {
    _age = n;
  }
  function getAge() {
    return _age;
  }

  return {
    name: name,
    getAge: getAge,
    setAge: setAge
  };
}

比如上述代码中,我们封装了一个对象Person,他拥有一个公共属性name,一个私有属性_age和两个私有方法。我们不能直接访问和修改私有属性_age,必须通过调用其内部的闭包getAgesetAge

var p1 = Person('zevin');
p1.setAge(22);
p1.getAge() // 22

四. 使用闭包的优缺点

4.1 优点1:实现封装,保护函数内的变量安全

采用闭包的写法可以把变量保存在内存中,不会被系统的垃圾回收机制销毁,从而起到了保护变量的作用。

function closure2(){
    let num = 1;
    return function(){
        console.log(num++)
    }
}

let fn2 = closure2();
fn2();  // 1
fn2();  // 2

4.2 优点2:避免全局变量的污染

开发中应该尽量避免使用全局变量,防止不必要的命名冲突和调用错乱。

var num = 1;
function func(num){
    console.log(num)
}
func();
let num = Array.of(4);
console.log(num);

这时就可以选择把变量声明在函数内部,并采用闭包的机制。这样既能保证变量的正常调用,又可以避免全局变量的污染。

function func(){
    let num = 1;
    return function(){
        return num
    }
}

let f = func();
console.log(f());  
// 1

4.3 缺点1:内存消耗和内存泄漏

外层函数每次运行,都会生成一个新的闭包,而这个闭包又会保留外层函数的内部变量,所以内存消耗很大。解决的方法只有不滥用闭包。

同时闭包中引用的内部变量会被保存,得不到释放,从而也造成了内存泄漏的问题。解决方法是:

window.onload = function(){
	var userInfor = document.getElementById('user');
	var id = userInfor.id;
	oDiv.onclick = function(){
		alert(id);
	}
	userInfor = null;
}

在内部闭包使用变量userInfor 之前,先用一个其他的变量id 来承接一下,并且使用完变量userInfor 后手动为它赋值为null

  • 5
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值