闭包是什么
函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域内,这种特性在计算机科学文献中称为“闭包”。——《JavaScript权威指南》
匿名函数经常被人误认为是闭包(closure)。闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。 ——《JavaScript高级程序设计第4版》
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。 ——《MDN Web Docs》
总结:
闭包是指有权访问另一个函数作用域中变量的函数。
闭包与作用域
JS 堆栈内存释放
- 堆内存:存储引用类型值,对象类型就是键值对,函数就是代码字符串。
- 堆内存释放:将引用类型的空间地址变量赋值成 null,或没有变量占用堆内存了浏览器就会释放掉这个地址
- 栈内存:提供代码执行的环境和存储基本类型值。
- 栈内存释放:一般当函数执行完后函数的私有作用域就会被释放掉。
但栈内存的释放也有特殊情况:
① 函数执行完,但是函数的私有作用域内有内容被栈外的变量还在使用的,栈内存就不能释放里面的基本值也就不会被释放。
② 全局下的栈内存只有页面被关闭的时候才会被释放。
作用域链、变量(活动)对象、上下文
作用域链就是一个包含变量对象的指针的列表。而变量对象与上下文相关联。
变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。
上下文是一个抽象的概念,可以理解为一个代码集(包含定义、声明的变量和函数),具体是以变量对象的形式所利用,每一个上下文都有一个关联的变量对象,而这个上下文中定义的所有变量和函数(其实只是指针)都存在于这个对象上。这个对象无法被用户访问。
全局上下文是最外层的上下文,在浏览器中是window对象。
上下文在其所有代码都执行完毕之后会被销毁(内存释放),包括定义在它上面的所有变量和函数(全局上下文在应用程序退出之前才会被销毁,如关闭网页或退出浏览器)
函数与作用域链
函数在定义时,会保存一个作用域链,链上最后一个必然是全局变量对象。
函数调用时,则会创建一个活动对象,该对象以arguments和命名参数作初始化。当执行流进入时,再将函数中其他所有的变量和函数加入该活动对象。然后将这个活动对象加入作用域链的最前端,当函数执行完之后,该活动对象随上下文销毁。
变量对象和活动对象其实是一样的,只不过变量对象用来描述在全局作用域下的变量组成的对象,即全局对象,浏览器中是window,node中是global
由于函数会保存他所定义时的一条作用域链,也就是说内部函数,即闭包,会保存一条作用域链,这条作用域链在定义时生成,链中从前往后依次是外部函数的活动对象,外外部函数的活动对象,直到全局变量对象,而内部函数因为尚未执行所以没有产生活动对象,更不会出现在作用域最前端。
在《JavaScript高级程序设计(第4版)》10.14小节中提到,这条作用域链会保存在定义的函数的内部[[Scope]]属性中。
作用域链的生成过程:
- 全局环境下的函数定义时就保存一条作用域链,当函数执行时,这个函数的上下文生效,从而产生该函数的活动对象,并把该活动对象加到作用域链前端,对于函数作用域内的一切变量和函数,都会与这条新的作用域链产生联系,最常见的就是按照作用域链去追溯去访问变量和函数
- 如果该函数还有一个内部函数(闭包),那么
[外部函数,window]这条作用域链会在内部函数定义时添加到内部函数的[[Scope]]属性中 - 闭包执行,闭包自己的活动对象被添加到作用域链前端,在闭包内进行访问可根据这条作用域链进行。闭包活动对象的构建分两个阶段:
- 调用时,对象仅以arguments和命名参数作初始化
- 执行流进入时,将函数中其他所有的变量和函数(包括对其他函数作用域的引用)加入该活动对象,这个阶段就有对变量和函数溯源过程,可能会顺着作用域链进行访问。
通过下面的例子来展示作用域链:
function createComparisonFunction(propertyName) {return function(object1, object2) {let value1 = object1[propertyName];let value2 = object2[propertyName];if (value1 < value2) {return -1;} else if (value1 > value2) {return 1;} else {return 0;}};}let compare = createComparisonFunction('name');let result = compare({ name: 'Nicholas' }, { name: 'Matt' });

createComparisonFunction()保存一条作用域链,由于它定义在全局作用域下,于是它的作用域链是这样的——createComparisonFunction作用域--全局作用域,作用域链上的都是指针,指向各自的变量(活动)对象。- 而匿名函数,由于是在
createComparisonFunction()中定义的,所以会继承createComparisonFunction()的作用域链,并在执行时在作用域链前面添加自己的活动对象,因为该匿名函数可以访问其他函数作用域的变量(propertyName是属于createComparisonFunction活动对象的变量),所以该匿名函数是一个闭包。
闭包的作用、用途
模拟私有变量和私有方法
在上面我们已经归纳出闭包的一个重要特点——可以访问其他函数作用域的变量,那么我们可以保存一些私有变量,只通过闭包函数进行访问。
function data(){let _students=[{name:"Jack",age:23,id:2021111523,major:'Information Security'},{name:"Mark",age:22,id:2021111322,major:'Information Security'}]let getInfoAll = function(){console.log(JSON.stringify(_students,null,'--'));}let searchStuById = function(id){for(let info of _students){if(info.id == id){console.log('get '+id+JSON.stringify(info,null,'--'));return;}}console.log('not found');}return {getInfoAll,searchStuById};}let stuDataFunc = data();stuDataFunc.getInfoAll();stuDataFunc.searchStuById(2021111523);stuDataFunc.searchStuById(2021111522);/*[--{----"name": "Jack",----"age": 23,----"id": 2021111523,----"major": "Information Security"--},--{----"name": "Mark",----"age": 22,----"id": 2021111322,----"major": "Information Security"--}]get 2021111523{--"name": "Jack",--"age": 23,--"id": 2021111523,--"major": "Information Security"}not found*/
在上面的例子中,_students就成了一个私有的变量了。这是不是跟面向对象编程很像了。
同样的,我们可以设置一个私有方法,只让返回的函数去调用。
函数工厂
用一个抽象的函数来描述某一类行为,然后通过传参了具象化。比如增长行为属于一类,更具体的增长行为表现在增长速度上,是每次加5还是加10,通过传参就可以实现更具体的行为:
function makeAdder(x) {return function(y) {return x + y;};}var add5 = makeAdder(5);var add10 = makeAdder(10);console.log(add5(2)); // 7console.log(add10(2)); // 12
上面出现的createComparisonFunction()也是这样的,将比较这一行为抽象出来,然后生成由传参决定具体对那种属性进行比较这一更具体的行为。
又比如通过传参设置字体大小:
function makeSizer(size) {return function() {document.body.style.fontSize = size + 'px';};}var size12 = makeSizer(12);var size14 = makeSizer(14);var size16 = makeSizer(16);document.getElementById('size-12').onclick = size12;document.getElementById('size-14').onclick = size14;document.getElementById('size-16').onclick = size16;
将原材料处理生成可用的产品,就像工厂生产一样。
闭包的不足
不要在循环时创建闭包
function showHelp(help) {document.getElementById('help').innerHTML = help;}function setupHelp() {var helpText = [{'id': 'email', 'help': 'Your e-mail address'},{'id': 'name', 'help': 'Your full name'},{'id': 'age', 'help': 'Your age (you must be over 16)'}];for (var i = 0; i < helpText.length; i++) {var item = helpText[i];document.getElementById(item.id).onfocus = function() {showHelp(item.help);}}}setupHelp();
上面的代码中,在showHelp函数接收到的始终是'Your age (you must be over 16)'。原因是赋值给 onfocus 的是闭包(有权访问另一个函数作用域中变量的函数)。这些闭包是由他们的函数定义和在 setupHelp 作用域中捕获的环境所组成的。这三个闭包在循环中被创建,但他们共享了同一个作用域——setupHelp的函数作用域,在这个作用域中存在一个变量item。这是因为变量item使用var进行声明,由于变量提升,所以可以在setupHelp函数作用域内可用。当onfocus的回调执行时,item.help的值被决定。由于循环在事件触发之前早已执行完毕,变量对象item(被三个闭包所共享)已经指向了helpText的最后一项。
如果想继续用闭包的方式解决这个问题,则应该利用函数工厂创建一个新的函数作用域作为隔离,写法如下:
function showHelp(help) {document.getElementById('help').innerHTML = help;}function makeHelpCallback(help) {return function() {showHelp(help);};}function setupHelp() {var helpText = [{'id': 'email', 'help': 'Your e-mail address'},{'id': 'name', 'help': 'Your full name'},{'id': 'age', 'help': 'Your age (you must be over 16)'}];for (var i = 0; i < helpText.length; i++) {var item = helpText[i];document.getElementById(item.id).onfocus = makeHelpCallback(item.help);}}}setupHelp();
在这里makeHelpCallback会分别把每一个传入的参数item.help存入三个不同的活动对象之中,于是每一个回调都对应一个新的上下文和活动对象,在这些环境中,help 指向 helpText 数组中对应的字符串。
另一种方法就是使用匿名函数:
function showHelp(help) {document.getElementById('help').innerHTML = help;}function setupHelp() {var helpText = [{'id': 'email', 'help': 'Your e-mail address'},{'id': 'name', 'help': 'Your full name'},{'id': 'age', 'help': 'Your age (you must be over 16)'}];for (var i = 0; i < helpText.length; i++) {(function() {var item = helpText[i];document.getElementById(item.id).onfocus = function() {showHelp(item.help);}})(); // 马上把当前循环项的item与事件回调相关联起来}}setupHelp();
这里同样是通过创建新的作用域来隔离,每一个onfocus的回调函数都会对应了一个独立的活动对象,且该活动对象都分别加入了不同的item(因为这个匿名函数立即执行了)。
更好的解决方法就是使用新语法里的let了:
function showHelp(help) {document.getElementById('help').innerHTML = help;}function setupHelp() {var helpText = [{'id': 'email', 'help': 'Your e-mail address'},{'id': 'name', 'help': 'Your full name'},{'id': 'age', 'help': 'Your age (you must be over 16)'}];for (var i = 0; i < helpText.length; i++) {let item = helpText[i]; //用let关键字创建块级作用域document.getElementById(item.id).onfocus = function() {showHelp(item.help);}}}setupHelp();
性能问题
如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响。
函数执行完,但是函数的私有作用域内有内容被栈外的变量还在使用的,栈内存就不能释放里面的基本值也就不会被释放。因此被闭包引用的函数作用域对应的栈内存无法被释放。
例如,在创建新的对象或者类时,方法通常应该关联于对象的原型,而不是定义到对象的构造函数中。原因是这将导致每次构造器被调用时,方法都会被重新赋值一次(也就是说,对于每个对象的创建,方法都会被重新赋值)。
function MyObject(name, message) {this.name = name.toString();this.message = message.toString();this.getName = function() {return this.name;};this.getMessage = function() {return this.message;};}
在上面的代码中,我们并没有利用到闭包的好处,因此可以避免使用闭包。修改成如下:
function MyObject(name, message) {this.name = name.toString();this.message = message.toString();}MyObject.prototype.getName = function() {return this.name;};MyObject.prototype.getMessage = function() {return this.message;};
这样的修改后,继承的原型可以为所有对象共享,不必在每一次创建对象时定义方法。
参考文献
闭包——《MDN Web Docs》
面试 | JS 闭包经典使用场景和含闭包必刷题
《JavaScript高级程序设计》第4版
