对闭包的理解

什么是闭包

我一直认为,MDN是前端学习的在线指南。只要有不懂的语法,或者不懂的概念,查找资料的首选就应该是它。虽然早些年做iOS开发的时候就就接触过闭包,但如今已经忘得一干二净,所以在想要了解闭包的时候,第一件事就是在MDN中搜索。其中给到了一个闭包的例子:

1
2
3
4
5
6
7
8
9
10
function makeFunc() {
var name = "Mozilla"; // name 是一个被 init 创建的局部变量
function displayName() { // displayName() 是内部函数,一个闭包
alert(name); // 使用了父函数中声明的变量
}
return displayName; // 返回 displayName 这个函数
}

var myFunc = makeFunc();
myFunc(); // 当执行myFunc时,其实调用了displayName,会弹窗显示"Mozilla"

在上面这个例子中,一个函数套了一个函数,且这个内部函数使用到了外部函数中的变量,这个时候就形成了闭包。有什么用呢?其中一个用处就如上面代码所示,能够让 makeFunc 函数外面也能访问 name 这个局部变量。换句话说,闭包缓存了数据,延长了作用域链。

Kyle Simpson编写的You-Dont-Know-JS中对闭包的总结我觉得很精髓:

闭包就是当一个函数即使是在它的词法作用域之外被调用时,也可以记住并访问它的词法作用域。

闭包的特性

  1. 函数内再嵌套函数
  2. 内部函数可以引用外层的参数和变量
  3. 参数和变量不会被垃圾回收机制回收

闭包的用途

读取正确的值

一个经典的例子,给 ul>li 下面的每个 li 节点添加点击事件,让其弹出当前 li 元素的索引:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<ul id="testUL">
<li> index = 0</li>
<li> index = 1</li>
<li> index = 2</li>
<li> index = 3</li>
</ul>
<script type="text/javascript">
var nodes = document.getElementsByTagName("li");
for (var i = 0; i < nodes.length; i += 1) {
nodes[i].onclick = function () {
console.log(i);
}
}
</script>

运行上述代码后就会发现,每次打印的结果都显示为4,而不是真正的索引值。这是因为点击事件的匿名函数发生在for循环之后,而for循环执行完毕时 i 的值就为4,所以点谁都是同样的结果。那么这个时候就能通过立即执行函数 + 闭包的方法解决此问题:

1
2
3
4
5
6
7
8
9
10
11
<script type="text/javascript">
var nodes = document.getElementsByTagName("li");
for (var i = 0; i < nodes.length; i += 1) {
nodes[i].onclick = (function (i) { // 1. IIFE创建一个函数作用域
return function () { // 4. 返回这个匿名函数,延长作用域链
// 3. 内部嵌套的匿名函数使用到了IIFE的参数i,形成闭包
console.log(i);
}
})(i); // 2. 给IIFE传递每次for循环的i
}
</script>

这个时候触发click事件,打印的值就是li元素的索引了。

高级排他

这个例子有点像上面的,现在我们要个需求:在一个 ul li 列表中,鼠标移入时高亮当前li标签,移除之前li标签的高亮状态。

常规写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 假设给li添加active类可以让标签高亮
window.onload = function() {
let list = document.querySelectorAll('li')
// 遍历每个标签
for (let i = 0; i < list.length; i++) {
const li = list[i]
li.onmouseover = function() {
// mouseover触发后遍历整个列表,将所有标签class设为空
for (let j = 0; j < list.length; j++) {
list[j].className = ''
}
// 最后给当前li标签添加active
this.className = 'active'
}
}
}

上面的代码能够实现功能,然而一旦列表非常长性能就不高,所以可以利用闭包来缓存li标签的索引,使其与标签一一对应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
window.onload = function() {
let list = document.querySelectorAll('li')
// 记录上一次选中的li标签的对应索引
let preActiveIndex = 0
for (let i = 0; i < list.length; i++) {
(function(j) {
const li = list[i]
li.onmouseover = function() {
// 清除上次li标签的高亮
list[preActiveIndex].className = ''
// 设置当前位高亮
this.className = 'active'
// 赋值
preActiveIndex = j
}
})(i)
}
}

模块化开发

在团队开发中,为了避免命名冲突通常不同成员会把自己的代码单独封装起来,最后return一个对象出去,挂载到window上。这样其他人也可以使用,而且能在一定程度上避免命名冲突:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(function(w) {
var money = 1000;
function get() {
money *= 10
console.log("赚了一笔钱,总资产:" + money + "元")
}
function send() {
money--
console.log("花了一笔钱,总资产:" + money + "元")
}

// 向外暴露对象
w.myTools = {
get,
set
}
})(window)

函数节流防抖

在日常开发中,我们也经常用到函数节流与防抖,通常我们会将它们封装为函数,这样可以在需要的地方直接调用使用,还不会污染全局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>防抖节流</title>
</head>
<body>
<input id="input" type="text">
<!-- 函数防抖 -->
<script>
function debounce(fn, delay) {
var timer = null
return function() {
var ctx = this,
args = arguments
if (timer) {
window.clearTimeout(timer)
}
timer = setTimeout(function() {
fn.apply(ctx, args)
timer = null
}, delay);
}
}
// 搜索框做函数防抖避免用户高频输入内容
input.addEventListener('input', debounce(function(e) {
console.log(e.target.value)
}, 500))
</script>
<!-- 函数节流 -->
<script>
function throttle(fn, delay) {
var canUse = true
return function() {
var ctx = this,
args = arguments
if (canUse) {
fn.apply(ctx, args)
canUse = false
setTimeout(() => {
canUse = true
}, delay);
}
}
}
// 对于像onresize这样的高频事件,可以使用函数节流让其回调函数中的代码隔一段时间再次执行
window.onresize = throttle(function(e) {
input.value = Math.random() * 100
}, 1000)
</script>
</body>
</html>

内存泄漏

其实闭包并不会造成内存泄漏,现在的垃圾回收机制已经很成熟了。但早期的IE(ie4-ie6)版本里,对宿主对象(也就是document对象)采用是引用计数的垃圾回收机制,闭包导致内存泄漏的一个原因就是这个算法的一个缺陷。循环引用会导致没法回收,这个循环引用只限定于有宿主对象参与的循环引用,而js对象之间即使形成循环引用,也不会产生内存泄漏,因为对js对象的回收算法不是计数的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function closure(){
var oDiv = document.getElementById('oDiv');//oDiv用完之后一直驻留在内存中
oDiv.onclick = function () {
alert('oDiv.innerHTML');//这里用oDiv导致内存泄露
};
}

// 以上代码创建了一个作为 div 元素事件处理程序的闭包,而这个闭包则又创建了一个循环引用。
// 由于匿名函数保存了一个对closure()的活动对象的引用,因此就会导致无法减少 div 的引用数。
// 只要匿名函数存在,element 的引用数至少也是1,因此它所占用的内存就永远不会被回收

closure();


//最后应将oDiv解除引用来避免内存泄露
function closure(){
var oDiv = document.getElementById('oDiv');
var test = oDiv.innerHTML;
oDiv.onclick = function () {
alert(test);
};
oDiv = null;
}
// 解决办法: 把 oDiv.innerHTML 的一个副本保存在一个变量中,
// 从而消除闭包中该变量的循环引用,同时将 oDiv 变量设为null。

所以我们要保持良好的编程习惯,在使用完闭包后记得释放内存:

1
2
3
4
5
6
7
8
9
10
11
12
function fn1() {
var arr = new Array(9999999999999)

function fn2() {
console.log(arr)
}
return fn2
}
var f = fn1()
f() // 调用完后没有释放,arr就会一直在内存中占着,导致内存泄漏
// 记得释放
f = null