# 闭包

闭包是函数和声明该函数的词法环境的组合。

在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!

  • 闭包
    • 形成三要素
      1. 函数嵌套
      2. 内层函数使用外层变量
      3. (被变量调用整个函数嵌套体)
    • 闭包作用
      1. 保护需要保护的数据不被定义在外面被轻易修改
    • 避免无效闭包
      1. 循环引用
      2. for循环中异步操作
      3. 代码优化:写在原型链上的方法优于写在构造函数上

可打断点在浏览器source中看到闭包的信息

闭包,指的是词法表示包括不被计算的变量的函数,也就是说, 函数可以使用函数之外定义的变量

  • 闭包是一个函数
  • 闭包可以使用在它外面定义的变量
  • 闭包存在定义该变量的作用域中

在Js中,只有函数内部的子函数才能读取局部变量,因此可把闭包简单理解成"定义在一个函数内部的函数"。在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。

# 闭包用途

// 闭包隐藏数据,只提供 API
function createCache() {
    const data = {} // 闭包中的数据,被隐藏,不被外界访问
    return {
        set: function (key, val) {
            data[key] = val
        },
        get (key) {
            return data[key]
        }
    }
}

const c = createCache()
c.set('a', 100)
c.set('a', 160)
console.log( c.get('a') )
/**
 * 设置鼠标样式
 * @param updateTableCol  选中列
 * @param updateTableCell 在单元格内部左侧
 * @param updateLeftStaple 在表格左侧 正文左侧
 * @returns
 */

const updateCursor = (function () {
  // 如果这个换成全局变量,其实也可以,找的时候都可能有些麻烦。
  let selectTableCol = false
  return function (updateLeftStaple: boolean) {
   // xxx处理
   // selectTableCol= xxxx
  }
})()

# 变量生存周期

全局变量,生命周期是永久的。局部变量,当定义该变量的函数调用结束时,该变量就会被垃圾回收机制回收销毁 再次调用该函数时又会重新定义了一个新变量。

var func = function(){
    var a = 'linxin';
    var func1 = function(){
        a += ' a';
        console.log(a);
    }
    return func1;
}
var func2 = func();
func2();         // linxin a
func2();         // linxin a a
func2();         // linxin a a a

可以看出,在第一次调用完 func2 之后func中的变量a变成 'linxin a',而没有被销毁。因为此时func1形成了一个闭包,导致了a的生命周期延续了。

提示

  • 闭包使用其他函数定义的变量, 使其不被销毁。比如上面 func1 调用了变量 a
  • 闭包存在定义该变量的作用域中,变量 a 存在 func 的作用域中,那么 func1 也必然存在这个作用域中。
  • 在另一个函数内部定义的函数,初始化时会将包含它的函数(外部函数)的活动对象添加到他们作用域中。外部函数执行完毕后,活动对象不会被销毁,因为匿名函数或者说闭包的作用域仍然在引用这个活动对象。所以对于外部函数,仅仅是销毁了它的作用域链 。直到闭包被销毁,外部函数的活动对象才会被销毁。

# 在循环中创建闭包:一个常见错误

<p id="help">Helpful notes will appear here</p>
<p>E-mail: <input type="text" id="email" name="email"></p>
<p>Name: <input type="text" id="name" name="name"></p>
<p>Age: <input type="text" id="age" name="age"></p>
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();

运行这段代码后,无论焦点在哪个input上,显示的都是关于年龄的信息。

原因是赋值给 onfocus 的是闭包。这些闭包是由他们的函数定义和在 setupHelp 作用域中捕获的环境所组成的。这三个闭包在循环中被创建,但他们共享了同一个词法作用域,在这个作用域中存在一个变量item。当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 函数为每一个回调创建一个新的词法环境。在这些环境中,help 指向 helpText 数组中对应的字符串。

另一种方法使用了匿名闭包:

  for (var i = 0; i < helpText.length; i++) {
    (function() {
       var item = helpText[i];
       document.getElementById(item.id).onfocus = function() {
         showHelp(item.help);
       }
    })(); // 马上把当前循环项的item与事件回调相关联起来
  }
}

还可以用let关键词代替var声明变量,每个闭包都绑定了块作用域的变量,这意味着不再需要额外的闭包。

# 内存管理

一般情况下,函数执行会形成一个新的私有的作用域,当私有作用域中的代码执行完成后,当前作用域都会主动的进行释放和销毁。但当 遇到函数执行返回了一个引用数据类型的值,并且在函数的外面被一个其他的东西给接收了,这种情况下一般形成的私有作用域都不会销毁。

在闭包中调用局部变量,会导致这个局部变量无法及时被销毁,相当于全局变量一样会一直占用着内存。如果需要回收这些变量占用的内存,可以 手动将变量设置为null

	function f2(){
		var n=22;
		var nAdd=function(){n++};
		return function(){
			return {
				n:n,
				nAdd:nAdd
			}
		}
	}
	//result2就是创建了一个匿名函数
	var result2=f2();
	//调用函数
	console.log(result2());//{n: 22, nAdd: ƒ}
	result2().nAdd();
	console.log(result2());//{n: 23, nAdd: ƒ}
	result2().nAdd();
	console.log(result2());//{n: 24, nAdd: ƒ}
	console.log(result2)//f
	//解除对匿名函数的引用,以便释放内存
	result2=null;
	console.log(result2)//null
	console.log(f2) //f2()

TIP

在使用闭包的过程中,比较容易形成 JS对象和DOM对象的循环引用,就有可能造成内存泄露。这是因为浏览器的垃圾回收机制中,如果两个对象之间形成了循环引用,那么它们都无法被回收。

两个对象:JS对象test,DOM对象简称为'testId' 在func方法中,test引用了DOM对象testId, 然后以test为中转,给testId这样一个DOM对象的onclick事件绑定了一个闭包。如果在被绑定闭包中,访问test这个JS对象。 那么,就形成了互相依赖:

  • test依赖testId(引用)
  • testId(的点击事件handler)依赖test(在绑定事件中的handler中操作了test)
function func() {
    var test = document.getElementById('test');
    test.onclick = function () {
        console.log('hello world');
    }
}

在上面例子中,func 函数中用匿名函数创建了一个闭包。变量 test 是 JavaScript 对象,引用了 id 为 test 的 DOM 对象,DOM 对象的 onclick 属性又引用了闭包,而闭包又可以调用 test ,因而形成了循环引用,导致两个对象都无法被回收。要解决这个问题,只需要把循环引用中的变量设为 null 即可。

function func() {
    var test = document.getElementById('test');
    test.onclick = function () {
        console.log('hello world');
    }
    test = null;
	//再给test赋值onclick的时候点击事件已经存在dom上了,test只是一个暂存dom元素的变量
	//这仅仅断开了局部变量 test 和 DOM 元素之间的引用,但并没有改变 DOM 元素本身或它的任何属性,包括 onclick 事件处理器。,如果是要解绑点击事件,需要把dom上的onclick设置为null
	// document.getElementById('test').onclick = null;
}

提示

干掉作为“中转变量”只是为了绑定click handler的test,打破循环。(类比死锁解决方案中的检测与解除--剥夺)

function func() {
    var test = document.getElementById('test');
    test.onclick = funcTest;
}
function funcTest(){
    console.log('hello world');
}

打破闭包,使click的handler无法再满足闭包条件,来访问func内部变量,从根源(闭包的产生的三条件)上杜绝相互依赖的发生。(类比死锁解决方案中的预防--破坏形成死锁的先决条件)

# 性能考量

如果不是某些特定任务需要使用闭包,在其它函数中创建函数不明智,闭包在 处理速度内存消耗 方面对脚本性能有负面影响。

例如,在创建新的对象或者类时,方法通常应该关联于对象的原型,而不是定义到对象的构造器中。原因是这将导致每次构造器被调用时,方法都会被重新赋值一次(也就是,每个对象的创建)。

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;
};

继承的原型可以为所有对象共享,不必在每一次创建对象时定义方法。

# 闭包应用场景

  • 运用场景:封装变量 收敛权限 模拟私有属性、方法
//封装变量 收敛权限
//记录是否存在,不存在录入,存在则返回false,如果不用闭包,就需要暴露一个数组,容易被人为修改 
function quanxian(){
	let arr=[]
	return function(val){
		if(arr.indexOf(val)>=0){
			console.log(false)
			return false
		}else{
			arr.push(val)
			console.log(true)
			return true
		}
	}
}

let ceshi=quanxian()
ceshi(1)//true 
ceshi(1)//false
ceshi(2)//true
  • 抖动和节流
<html>
<style>
	#test{
		width:400px;
		height:400px;
		border:1px solid red
	}
</style>
<body class="m-2">
	<div id="test">1111</div>
	<div id="test">1111</div>
	<div id="test">1111</div>
	<div id="test">1111</div>
	<div id="test">1111</div>
	<div id="test">1111</div>
	<div id="test">1111</div>
	<div id="test">1111</div>
  <script>
		function throttle(fn, delay) {
		  // last为上一次触发回调的时间, timer是定时器
		  let last = 0, timer = null
		  // 将throttle处理结果当作函数返回
		
		  return function () { 
		    // 保留调用时的this上下文
		    let context = this
		    // 保留调用时传入的参数
		    let args = arguments
		    // 记录本次触发回调的时间
		    let now = +new Date()
		
		    // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
		    if (now - last < delay) {
		    // 如果时间间隔小于我们设定的时间间隔阈值,则为本次触发操作设立一个新的定时器
		       clearTimeout(timer)
		       timer = setTimeout(function () {
		          last = now
		          fn.apply(context, args)
		        }, delay)
		    } else {
		        // 如果时间间隔超出了我们设定的时间间隔阈值,那就不等了,无论如何要反馈给用户一次响应
		        last = now
		        fn.apply(context, args)
		    }
		  }
		}
		
		// 用新的throttle包装scroll的回调
		const better_scroll = throttle(() => console.log('触发了滚动事件'), 1000)
		
		document.addEventListener('scroll', better_scroll)
  </script>
</body>

</html>

# 闭包面试题

var a=0,
	b=0;
function A(a){
	A=function(b){
		console.log('function A')
		console.log(a+b++)
	}
	console.log('function B')
	console.log(a++)
}
A(1)//function B 1
A(2)//function A 4
const foo = (function() {
   var v = 0
   return () => {
       return v++
   }
}())

for (let i = 0; i < 10; i++) {
   foo()
}
console.log(foo)
// () => {
//        return v++
// }
console.log(foo()) // 10

在循环执行时,执行 foo(),这样引用自由变量 10 次,v 自增 10 次,最后执行 foo 时,得到 10。(自由变量是指没有在相关函数作用域中声明,但是使用了的变量。)

const foo = () => {
   var arr = []
   var i

   for (i = 0; i < 10; i++) {
       arr[i] = function () {
           console.log(i)
       }
   }

   return arr[0]
}

foo()() // 10
var fn = null
const foo = () => {
   var a = 2
   function innerFoo() {
       console.log(a)
   }
   fn = innerFoo    
}

const bar = () => {
   fn()
}

foo()
bar() // 2

解析

正常来讲,根据调用栈的知识,foo 函数执行完毕之后,其执行环境生命周期会结束,所占内存被垃圾收集器释放,上下文消失。但是通过 innerFoo 函数赋值给 fn,fn 是全局变量,这就导致了 foo 的变量对象 a 也被保留了下来。所以函数 fn 在函数 bar 内部执行时,依然可以访问这个被保留下来的变量对象,输出结果为 2

var fn = null
const foo = () => {
   var a = 2
   function innerFoo() {
       console.log(c)            
       console.log(a)
   }
   fn = innerFoo
}

const bar = () => {
   var c = 100
   fn()    
}

foo()
bar() // 报错

在 bar 中执行 fn() 时,fn() 已经被复制为 innerFoo,变量 c 并不在其作用域链上,c 只是 bar 函数的内部变量。因此报错 ReferenceError: c is not defined。

# 内存管理基本概念

内存空间可以分为栈空间和堆空间,其中

  • 栈空间:由操作系统自动分配释放,存放函数的参数值,局部变量的值等,其操作方式类似于数据结构中的栈。
  • 堆空间:一般由开发者分配释放,这部分空间就要考虑垃圾回收的问题。
var a = 11
var b = 10
var c = [1, 2, 3]
var d = { e: 20 }

对于分配内存和读写内存的行为所有语言都较为一致,但释放内存空间在不同语言之间有差异。例如,JavaScript 依赖宿主浏览器的垃圾回收机制,一般情况下不用程序员操心。但这并不表示万事大吉,某些情况下依然会出现内存泄漏现象。

内存泄漏是指内存空间明明已经不再被使用,但由于某种原因并没有被释放的现象。这是一个非常「玄学」的概念,因为内存空间是否还在使用,某种程度上是不可判定问题,或者判定成本很高。内存泄漏危害却非常直观:它会直接导致程序运行缓慢,甚至崩溃。

内存泄漏场景举例 我们来看几个典型引起内存泄漏的例子:

var element = document.getElementById("element")
element.mark = "marked"
// 移除 element 节点
function remove() {
   element.parentNode.removeChild(element)
}

上面的代码,我们只是把 id 为 element 的节点移除,但是变量 element 依然存在,该节点占有的内存无法被释放。

需要在 remove 方法中添加:element = null,这样更为稳妥。

function foo() {
 var name  = 'lucas'
 window.setInterval(function() {
   console.log(name)
 }, 1000)
}

foo()

这段代码由于 window.setInterval 的存在,导致 name 内存空间始终无法被释放,如果不是业务要求的话,一定要记得在合适的时机使用 clearInterval 进行清理。

闭包使用不当,极可能引发内存泄漏,需要格外注意。

function foo() {
   let value = 123
   function bar() { alert(value) }
   return bar
}

let bar = foo()

这种情况下,变量 value 将会保存在内存中,如果加上:

bar = null

这样的话,随着 bar 不再被引用,value 也会被清除。

参考 (opens new window)

最后更新: 11/10/2024, 7:57:43 PM