在认识闭包之前,我们先简单了解两个知识点:JavaScript 中的作用域和作用域链,JavaScript 中的垃圾回收,目的就是为了方便我们更容易理解闭包
(1)JavaScript 中的作用域和作用域链
作用域就是一个独立的地盘,让变量不会外泄、暴露出去,不同作用域下同名变量不会有冲突。如果在当前作用域中没有查到值,就会向上级作用域去查,直到查到全局作用域,这么一个查找过程形成的链条就叫做作用域链。
可以看一个简单的例子:
var a = 100
function F1() {
var b = 200
function F2() {
var c = 300
console.log(a) // 顺作用域链向父作用域找
console.log(b) // 顺作用域链向父作用域找
console.log(c) // 本作用域的变量
}
F2()
}
F1()
(2)JavaScript 中的垃圾回收
Javascript 执行环境会负责管理代码执行过程中使用的内存,其中就涉及到一个垃圾回收机制。垃圾收集器会定期(周期性)找出那些不再继续使用的变量,只要该变量不再使用了,就会被垃圾收集器回收,然后释放其内存。如果该变量还在使用,那么就不会被回收。
有了以上两点铺垫,接下来我们再来看什么是闭包:
刚刚接触闭包的时候,实在搞不清楚这个东西的概念,看了好多地方对闭包概念的描述,像JS 忍者秘籍中对闭包的定义是闭包允许函数访问并操作函数外部的变量。红宝书上对于闭包的定义是闭包是有权访问另外一个函数作用域中的变量的函数。MDN 对闭包的定义是闭包是那些能够访问自由变量的函数。这里的自由变量是外部函数作用域中的变量。
其实总结下来,闭包不是一个具体的技术,而是一种现象,是指在定义函数时,周围环境中的信息可以在函数中使用。换句话说,执行函数时,只要在函数中使用了外部的数据,就创建了闭包,而作用域链,正是实现闭包的手段。
只要在函数中使用了外部的数据,就创建了闭包?真的是这样么?我们举个简单的例子可以证明一下:
在上面的代码中,我们在函数 a 中定义了一个变量 i,然后打印这个 i 变量。对于 a 这个函数来讲,自己的函数作用域中存在 i 这个变量,所以我们在调试时可以看到 Local局部变量中存在变量 i。
我们将上面的代码稍作修改,如下图:
在上面的代码中,我们将声明 i 这个变量的动作放到了 a 函数外面,也就是说 a 函数在自己的作用域已经找不到这个 i 变量了,它会怎么办?学习了作用域链之后肯定知道,它会顺着作用域链一层一层往外找,也就是函数使用了外部的数据的情况,就会创建闭包。仔细观察调试区域,我们会发现此时的 i 就放在 Closure 里面的,从而证实了我们前面的说法。
其实“闭”可以理解为“封闭,闭环”,“包”可以理解为“一个类似于包裹的空间”,因此闭包实际上可以看作是一个封闭的空间,那么这个空间用来干啥呢?实际上就是用来存储变量的。
(3)那么一个函数下所有的变量声明都会被放入到闭包这个封闭的空间里面么?
当然不是,放不放入到闭包中,要看其他地方有没有对这个变量进行引用,举个例子:
在上面的代码中,函数 c 中一个变量都没有创建,却要打印 i、j、k 和 x,这些变量分别存在于 a、b 函数以及全局作用域中,因此创建了 3 个闭包,全局闭包里面存储了 i 的值,闭包 a 中存储了变量 j 和 k 的值,闭包 b 中存储了变量 x 的值。但是仔细观察,就会发现函数 b 中的 y 变量并没有被放在闭包中,所以要不要放入闭包取决于该变量有没有被引用。
从上面我们发现一个新问题,那么多闭包,岂不是占用内存空间么?
实际上,如果是自动形成的闭包,是会被销毁掉的。例如:
运行结果如图:
在上面的代码中,我们在第 16 行尝试打印输出变量 x,显然这个时候是会报错的,在第 16 行打一个断点调试就可以清楚的看到,此时已经没有任何闭包存在,垃圾回收器会自动回收没有引用的变量,不会有任何内存占用的情况。
当然,这里指的是自动产生闭包的情况,关于闭包,有时需要根据需求手动的来制造一个闭包。来看下面的例子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script>
function eat(){
var food = "食物";
console.log(food);
}
eat(); // 食物
console.log(food); // 报错
</script>
</body>
</html>
在上面的例子中,声明了一个名为 eat 的函数,并对它进行调用。JavaScript 引擎会创建一个 eat 函数的执行上下文,其中声明 food 变量并赋值。当该方法执行完后,上下文被销毁,food 变量也会跟着消失。这是因为 food 变量属于 eat 函数的局部变量,它作用于 eat 函数中,会随着 eat 的执行上下文创建而创建,销毁而销毁。所以当我们再次打印 food 变量时,就会报错,告诉我们该变量不存在。
但是我们将此代码稍作修改:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script>
function eat(){
var food = '食物';
return function(){
console.log(food);
}
}
var look = eat();
look(); // 食物
</script>
</body>
</html>
运行结果如图:
在这个例子中,eat 函数返回一个函数,并在这个内部函数中访问 food 这个局部变量。调用 eat 函数并将结果赋给 look 变量,这个 look 指向了 eat 函数中的内部函数,然后调用它,最终输出 food 的值。
为什么能访问到 food,是因为垃圾回收器只会回收没有被引用到的变量,只要该变量还被引用着的,垃圾回收器就不会回收此变量。在上面的示例中,照理说 eat 调用完毕 food 就应该被销毁掉,但是我们向外部返回了 eat 内部的匿名函数,而这个匿名函数有引用了 food,所以垃圾回收器是不会对其进行回收的,这也是为什么在外面调用这个匿名函数时,仍然能够打印出 food 变量的值。
至此,闭包的一个优点或者特点也就体现出来了,那就是:
通过此特性,可以解决一个全局变量污染的问题。早期在 JavaScript 还无法进行模块化的时候,在多人协作时,如果定义过多的全局变量 有可能造成全局变量命名冲突,使用闭包来解决功能对变量的调用将变量写到一个独立的空间里面,从而能够一定程度上解决全局变量污染的问题。
(1) 函数作为返回值(最常用)
timeClouse5(){
function fn(){
var name="hello";
return function(){
return name;
}
}
var fnc = fn();
console.log(fnc()) //hello
},
运行结果如图:
fn()的返回值是一个匿名函数,这个函数在fn()作用域内部,,所以它可以获取fn()作用域下变量name的值,将这个值作为返回值赋值给全局作用域下的变量fnc,实现了在全局变量下获取局部变量中变量的值
(2) IIFE(自执行函数)
var n = '张三';
(function p(){
console.log(n)
})()
/* 输出
* 张三
/
运行结果如图:
同样也是产生了闭包p(),存在 window下的引用 n。
(3) 定时器与闭包
写一个循环,让它按顺序打印出每次循环i的值
timeClouse1(){
for(var i = 0; i < 5; i++){
setTimeout(function(){
console.log(i)
}, 1000)
}
},
运行结果如图:
分析:按照预期它应该输出0 1 2 3 4,而结果它输出了5,这是为什么呢?由于js是单线程的,在执行for循环的时候定时器setTimeout被安排到任务队列中排队等待执行,而在等待过程中for循环就已经在执行,等到setTimeout可以执行的时候 ,for循环已经结束,所以打印出来五个5,那么我们为了实现预期效果应该怎么改这段代码呢?
方案一:利用ES6中的let,将var改成let,也可以实现预期效果
timeClouse1(){
for(let i = 0; i < 5; i++){
setTimeout(function(){
console.log(i)
}, 1000)
}
},
运行结果如图:
方案二: 引入闭包来保存变量i,将setTimeout放入到立即执行函数中
// 采用自执行函数解决
timeClouse2(){
for(var i = 0; i < 5; i++){
(function(j){ //存在自执行函数
setTimeout(function(){
console.log(j)
}, 1000)
})(i)
}
},
运行结果如图:
(4) 防抖函数(业务场景中常用)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<input type="text" id="inputId" />
<script>
// 1.先定义一个业务处理函数
function func(){
console.log("接口请求");
}
// 2. 再定义一个防抖函数 (事件触发每次执行防抖函数,但不一定执行业务处理函数)
// 2.1 防抖函数设置1秒延迟执行业务处理函数
function fn(){
setTimeout(func,1000)
}
// 2.2 每次事件发生都要触发防抖函数,防抖函数每次执行,这个timer都是新的,无法实现我想要的清除上一次的触发。
function fn () {
// timer 定义在哪里?
let timer = null
clearTimeout(timer)
timer = setTimeout(func, 1000)
}
// 2.3关键点:这个timer如何维护?
// 如果维护在fn()函数内部,则每次fn()函数执行(创建函数执行上下文),timer都是一个新的变量,则无法完成清除的逻辑操作
// 需求:函数在第一次执行时就创建好这个timer,下次(以后)执行时一直都用这个“旧”的,这样就可以完成每次清除的逻辑了。
// 实现这个需求就是使用闭包,在防抖函数外部继续包一个函数,叫防抖生成函数,在这个函数内定义timer,并且返回防抖函数。这样在防抖函数第一次创建的时候内部就有了timer变量。并且每次执行都是访问这个变量。不是创建新的变量。
// 确认防抖函数在每次输入值时都会执行
// 防抖生成函数
function debounce() {
let timer = null
return function fn () {
console.log('防抖函数执行')
clearTimeout(timer)
timer = setTimeout(search, 1000)
}
}
// 2.4 最终代码
function debounce() {
let timer = null
return function () {
console.log('防抖函数执行,清除旧的', timer)
clearTimeout(timer)
timer = setTimeout(func, 1000)
console.log('防抖函数执行,生成新的', timer)
}
}
document.getElementById("inputId").oninput = debounce();
</script>
</body>
</html>
运行效果如图:
为什么要学习内存管理以及垃圾回收算法?
JavaScript中的内存释放是自动的,释放的时机就是某些值(内存地址)不在使用了,JavaScript就会自动释放其占用的内存。其实大多数内存管理的问题都在这个阶段,在这里最艰难的任务就是找到那些不需要的变量。现在打高级语言都有自己垃圾回收机制,虽然现在的垃圾回收算法很多,但是也无法智能的回收所有的极端情况。
不论什么样的编程语言,在代码执行的过程中都是需要给他分配内存的,有些编程语言(C、C++)需要我们自己手动管理内存,例如C语言,他们提供了内存管理的接口,比如malloc()用于分配所需的内存空间,free()用于释放之前所分配的内存空间。有的编程语言(Java、JavaScript)会自动帮我们管理内存
不论是手动还是自动,内存的管理都会有如下生命周期:
第一步:内存分配:例如当我们定义变量时,系统会自动为其分配内存
第二步:内存使用:在对变量进行读写时发生,(存放一些东西,比如对象,变量)
第三步:内存回收不需要使用时,对其进行释放
注意:后续画内存图补充相关原理
我们可以看一下内存泄漏的例子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button class="create">创建一系列数组对象</button>
<button class="destroy">销毁一系列数组对象</button>
<script>
function createArray() {
// V8引擎中小的数字占4个字节
var arr = new Array(1024 * 1024).fill(100)
function test() {
console.log(arr);
}
return test
}
// 点击按钮
var totalArr = []
var createBtnE1 = document.querySelector(".create")
var destroyBtnE1 = document.querySelector(".destroy")
createBtnE1.onclick = function() {
for(i = 0; i < 100; i++) {
totalArr.push(createArray())
}
}
destroyBtnE1.onclick = function() {
totalArr = null
// totalArr = []
}
</script>
</body>
</html>
第一步,我们先看用户未经过任何操作,内存的快照如下图:
此时未经过任何操作,发现js heap size中几乎保持平稳的趋势
第二步,我们点击按钮,创建数组,观察内存的快照如下图:
可以看到内存飙升,从大约一兆上升到四百多兆,很明显内存泄露了。
最后,我们点击销毁按钮,对没有进行自动释放的内存进行手动释放,效果图如下:
很明显,此时的内存经过我们手动释放后回到最初时的内存大小
JavaScript中的内存管理是自动的,在创建对象时会自动分配内存,当对象不在被引用或者不能从根上访问时,就会被当做垃圾给回收掉。
注意:JavaScript中的可达对象简单的说就是可以访问到的对象,不管是通过引用还是作用域链的方式,只要能访问到的就称之为可达对象。可达对象的可达是有一个标准的,就是必须从根上出发是否能被找到;这里的根可以理解为JavaScript中的全局变量对象,在浏览器环境中就是window、在Node环境中就是global。
(1)为了更好的理解引用的概念,看下面这一段代码:
let person = {
name: '一碗周',
}
let man = person
person = null
如下图:
根据上面那个图我们可以看到,最终这个{ name: ‘一碗周’ }是不会被当做垃圾给回收掉的,因为还具有一个引用。
(2)现在我们来理解一下可达对象,代码如下:
function groupObj(obj1, obj2) {
obj1.next = obj2
obj2.prev = obj1
return {
obj1,
obj2,
}
}
let obj = groupObj({ name: '大明' }, { name: '小明' })
调用groupObj()函数的的结果obj是一个包含两个对象的一个对象,其中obj.obj1的next属性指向obj.obj2;而obj.obj2的prev属性又指向obj.obj1。最终形成了一个无限套娃。
delete obj.obj1
delete obj.obj2.prev
此时的obj1就被当做垃圾给回收了。
注意:GC是怎么知道哪些对象不再使用呢?
这里就要用到GC的实现以及对应的算法
常见的GC算法
(1)引用计数
引用计数算法的核心就是引用计数器 ,由于引用计数器的存在,也就导致该算法与其他GC算法有所差别。引用计数器的改变是在引用关系发生改变时就会发生变化,当引用计数器变为0的时候,该对象就会被当做垃圾回收。
我们通过一段代码来看一下:
// { name: '一碗周' } 的引用计数器 + 1
let person = {
name: '一碗周',
}
// 又增加了一个引用,引用计数器 + 1
let man = person
// 取消一个引用,引用计数器 - 1
person = null
// 取消一个引用,引用计数器 - 1。此时 { name: '一碗周' } 的内存就会被当做垃圾回收
man = null
引用计数算法的优点如下:
引用计数算法的缺点如下:
就比如下面这段代码,就无法解决循环引用的问题
function fun() {
const obj1 = {}
const obj2 = {}
obj1.next = obj2
obj2.prev = obj1
return '一碗周'
}
fun()
上面的代码中,当函数执行完成之后函数体的内容已经是没有作用的了,但是由于obj1和obj2都存在不止1个引用,导致两种都无法被回收,就造成了空间内存的浪费。并且时间开销大,这是因为引用计数算法需要时刻的去监控引用计数器的变化。
(2)标记清除
标记清除算法解决了引用计数算法的⼀些问题, 并且实现较为简单, 在V8引擎中会有被⼤量的使⽤到。
标记清除的核心思路就是可达性,这个算法是设置一个根对象,垃圾回收器会定时从这个根开始,找所有从根开始有引用到的对象,对于哪些没有引用到的对象,就认为是不可用的对象,这个算法可以很好的解决循环引用的问题
**注意:**对于上图的M、N就会被认为不可用的对象,就会被清除掉