继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

两段js代码理解闭包的概念和实际应用

种子_fe
关注TA
已关注
手记 28
粉丝 83
获赞 479

去年刚开始学javaScript的时候就看过闭包的概念,当时看的是高程三,看的不是很懂。要从初级前端工程师往上进阶,像闭包这样的javaScript基础知识是绕不过去的,闭包这一个概念就涉及到作用域,执行环境,变量对象等一系列概念。最近我又找了一些资料看了一下,加上又看了慕课网上一节讲闭包的课,现在感觉对闭包有一个初步的认识了,这里赶紧记录下来,以免以后又忘记了。
如果你也是看了一些对闭包概念的阐述但还是不理解闭包的具体作用,不妨看下这篇手记。


闭包的基本概念和作用

闭包是指有权访问另一个函数作用域中的变量的函数。创建闭包的常见方式,就是在一个函数内部创建另一个函数。

在理解闭包之前有一些关于函数的基本概念需要先了解:
作用域是程序中定义的一个变量的有效区域作用域链规定了函数中调用一个变量时查找变量的顺序,作用域链的用途是保证对执行环境有权访问的所有变量和函数的有序访问。。作用域链的前端,始终都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其活动对象(activation object)作为变量对象。活动对象在最开始时只包含一个变量,即 arguments 对象(这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自包含(外部)环境,而再下一个变量对象则来自下一个包含环境。这样,一直延 续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。
执行环境定义了变量或函数有权访问的其他数据,每个执行环境都有一个与之关联的变量对象,里面保存了执行环境中定义的所有变量和函数。每个函数都有自己的执行环境,当执行流进入一个函数时,函数的环境就被推入一个环境栈中。而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境。
仅仅看概念几乎无法理解闭包,所以还是通过代码来实际看看闭包起到的作用。

通过一段代码理解闭包的作用

先看下面这段代码:

var i = 0;
function a() {
    i++;
    console.log(i);
}
a();

这里定义了一个全局变量i和一个函数a,每执行一次函数a就会使i加一,然后在控制台中输出i。
这段代码在功能上没有问题,但是i作为一个全局变量,属于全局执行环境的变量对象,在全局环境中的所有函数和变量都可以访问和修改它,如果我们不希望这样呢?也就是说,我希望把这个变量隐藏起来,在外部不能直接访问。
这时就可以在函数a里定义这个变量i,也就是把i变成一个局部变量:

function a() {
    var i = 0;
    i++;
    console.log(i);
}
a();
a();

上面的代码里,我两次调用函数a,结果发现在控制台里两次输出的结果都是1,这又是为什么呢?我一开始希望达到的目的是每次调用a输出的值都加了一,要解决这个问题就要用到闭包了。
局部变量i属于函数a执行环境的变量对象,函数a执行时,执行流进入函数a,函数a的环境被推入一个环境栈中,其变量对象被激活成为活动对象,而在函数a执行完毕后,栈将其环境弹出,函数a的局部活动对象就会被销毁,局部变量i也就被销毁了,因此再次调用a时i还是等于0。在第一段代码中,再次调用a会输出2,是因为i是全局变量,保存在全局执行环境的变量对象中,只要浏览器页面不关闭,这个全局变量对象会始终保存在内存中。
那么,我既想通过函数a的局部环境隐藏i这个变量,又想在外部间接访问这个变量,该怎么办呢?这里就该闭包来发挥作用了,看第三段代码:

/*通过闭包的方式保留函数a的活动对象(这样局部变量i就不会被销毁),因为内部函数b的作用域链仍然在引用函数a的活动对象*/
function a() {
	/* body... */
	var i = 0;
	return function b() {
		/* body... */
		i++;
		console.log(i);
	}
}
// 调用函数a得到对内部函数b的引用
var c = a();
// 调用内部函数b
c(); // 1
c(); // 2
console.log(c); //function b()
i++; //ReferenceError: i is not defined, 在全局执行环境中不能直接访问i,只能通过调用内部函数b来访问
c = null; //解除对函数b的引用(以便释放内存),此时函数a的活动对象也被销毁了

在函数a里,我又定义了一个函数b,这就构成了一个闭包,在函数b里进行i++和输出i的操作,由于在函数a里return了函数b,所以在外部调用函数a会得到函数b的引用(赋值给了c,所以c就指向函数b),接着调用c就相当于调用b,在函数a里定义的函数b会将函数a的活动对象添加到函数b的作用域链中,在函数b被返回后,它的作用域链被初始化为包含函数a的活动对象和全局变量对象,这样函数b就可以访问变量i,更重要的是,函数a执行完毕后,其活动对象不会被销毁,因为函数b的作用域链仍然在引用这个活动对象,因此变量i仍然留在内存中,再次调用c就会输出2,直到c被销毁,函数a的活动对象才会被销毁。

仔细理解上面三段代码的区别,应该就能对闭包等一系列概念有个初步的理解。

闭包在实际开发中的应用举例

上面的例子还是在讲闭包的理论,那闭包在前端实际开发工作中有什么应用呢?我们先来看看tab切换这个网页中常用的功能。
通过循环给选项卡中的选项绑定点击(或者鼠标移入)事件,选中某个选项卡时先让所有选项卡变为未选中的样式,所有的内容变为不显示;然后让当前点击(移入)的选项卡变为选中的样式,当前选项卡对应的内容变为显示,选项卡的样式和内容的显示切换通过添加和删除className来控制,如果是javaScript初学者,很可能会和我一样写出下面的代码:

for (var i = 0; i < tabs.length; i++) {
	tabs[i].onclick = function() { // tabs是选项卡数组
		// 清除所有tab上的class
		for (var j = 0; j < tabs.length; j++) {  // 通过循环让所有选项卡样式变为未选中
			tabs[j].className = '';
			// 通过className来操作内容的显示切换,避免内联样式代码
			contents[j].className = 'mod'; //contents是内容数组
		}
		// 设置当前为高亮显示
		this.className = 'selected'; // 给当前选项卡添加selected类名,让其高亮
		contents[i].className = 'mod mod-current'; // 想让当前索引选项卡对应的内容显示,能实现吗?
	}
}

看起来好像思路清晰,也没什么问题,但是打开浏览器运行,点击页面里的选项卡却发现内容都没有显示出来,再打开控制台发现报错:TypeError: contents[i] is undefined,这是怎么回事呢?
假定选项卡和内容数组的长度都是5,contents内容数组应该没有问题,用开发者工具自带的调试工具监控一下变量i会发现,不管点击哪个选项卡,i的值都是5,而contents数组最大的索引值是4,所以才会报undefined。那为什么i始终是5呢?
这其实是因为JavaScript没有块级作用域,在C语言里,花括号封闭的代码块都有自己的作用域,也就是自己的执行环境,比如for循环和if语句的花括号中定义的变量会在for语句和if语句执行完后被销毁,而JavaScript中则不会,因此在上面的代码中,给选项卡绑定click事件的循环结束后,i没有被销毁,而是变成了5,此时无论点击哪个选项卡,i的值都是5。
要解决这个问题,就要在JavaScript中实现块级作用域,可以通过定义一个立即执行的匿名函数来实现块级作用域,怎么做呢?还是以之前tab切换的代码为例:

for (var i = 0; i < tabs.length; i++) {
	(function (e) {
		tabs[e].onclick = function() {
		    // 清除所有li上的class
		    for (var j = 0; j < tabs.length; j++) {
			tabs[j].className = '';
			// 通过className来操作内容的显示切换,避免内联样式代码
			contents[j].className = 'mod';
		    }
		    // 设置当前为高亮显示
		    this.className = 'selected';
	    	contents[e].className = 'mod mod-current';
	    }
	})(i);
}

和之前的代码相比,这里用一个立即执行的匿名函数将事件绑定函数包裹在里面,这个立即执行的匿名函数有一个参数e,代表选项卡索引,执行的时候将当前索引值i传递进去;匿名函数内还有一个绑定到click事件上的函数,这个函数可以访问到参数e,这就形成了闭包,因为外层匿名函数是立即执行的,就可以把当前索引值传递进去。
这种技术除了这种应用外,还经常在全局作用域中被用在函数外部,从而限制向全局作用域中添加过多的变量和函数,从而避免多人协作开发时过多的全局变量和函数导致的命名冲突,一个常见的应用场景是封装jQuery插件,为避免$变量名与其他库发生冲突和防止污染全局命名空间,要把封装插件的代码放在立即调用函数(IIFE)里,并且将jQuery对象作为参数传入,然后用$接收:

;(function($, window, document, undefined) {
    ......
})(jQuery, window, document);

上面的代码还将window/document等系统变量作为参数传递到插件内部,这样在需要访问这些系统变量的时候就可以访问这些参数,也就是访问局部变量,比访问全局变量速度更快,有少许性能提升。
怎么样,有了实际的例子,是不是觉得闭包更好理解也更有用了呢~

总结

  1. 闭包的常见形式就是函数里面套一个函数,并且外层函数要return内层函数;
  2. 函数套函数是为了隐藏变量,把变量放在外层函数里定义,就是一个局部变量,如果在全局环境中定义就是全局变量,无法隐藏;
  3. 外层函数return内层函数是为了内层函数能在外部调用
  4. 闭包的作用是隐藏一个变量(在全局执行环境中不能直接访问)并且能通过闭包里的内层函数在外部间接访问这个变量。
  5. 通过定义立即执行的匿名函数可以模仿块级作用域,存储循环索引值和在全局环境中创建私有作用域,避免污染全局环境。

参考资料

  1. javaScript高级程序设计第三版-第四章和第七章
  2. JS 中的闭包是什么?
  3. 闭包-MDN

本作品采用知识共享 署名-非商业性使用-相同方式共享 4.0 国际 许可协议进行许可。要查看该许可协议,可访问 http://creativecommons.org/licenses/by-nc-sa/4.0/ 或者写信到 Creative Commons, PO Box 1866, Mountain View, CA 94042, USA。

打开App,阅读手记
10人推荐
发表评论
随时随地看视频慕课网APP