闭包的概念可以说是js里老生常谈的了,它可以说是javascript最基本最重要的概念之一,为什么呢?因为它涉及到很多js的基本概念。比如:作用域,作用域链,执行上下文,调用栈,内存管理等概念 为了更好的理解闭包,所以我们从基础走起,简单了解这些概念:
作用域
任何语言都有作用域这个概念,作用域是什么呢?说白了也就是寻找变量的规则,在javascript中现在为止有三种作用域:函数作用域,全局作用域,块级作用域
函数作用域和全局作用域
通过几个🌰总结一下
1
2
3
4
5
function a(){
var aa = '函数作用域';
console.log(aa)
}
a()
变量aa
在函数 a
的作用域内,所以执行a()
可以正常输出 函数作用域
1
2
3
4
5
var bb = '全局作用域'
function b(){
console.log(bb)
}
b()
执行函数b
,在函数b
自身作用域中没有变量bb
,所以向外扩大作用域寻找,在外层的全局作用域中找到了变量bb
因此成功输出全局作用域
1
2
3
4
5
6
7
function a(){
var a = '函数一'
}
function b(){
console.log(a)
}
b()
执行上述代码会报错,因为每一个函数都是一个单独的作用域,在函数b
中没有找到变量a
同样在外层的全局作用域中也没有找到,所以执行b
函数报错
1
2
3
4
5
6
7
8
9
10
var b = '全局'
function func1() {
var b = '一层'
function func2() {
console.log(b)
}
func2()
}
func1()
执行上述代码,对比第二个例子可以知道函数作用域的范围是逐级向外递增的 通过以上几条条例子可以简单总结几条规则:
- 每个函数作用域都是独立的
- 变量作用域的查找是一个扩散过程,一级一级向外就近寻找。
- 最外层就是全局作用域
由此我们可以发现,作用域的工作方式就像环环相扣的联调,逐级递进这也就是作用域链说法的由来
块级作用域
块级作用域是ES6
后才出现的由let
和 const
声明的变量,不涉及到变量提升,作用在块级作用域中,也就是代码块中,再通俗一点就是两个大括号中(当然这种书法并不全面)
由于块级作用域的变量不涉及变量提升所以出现了一个新鲜的概念,比如 暂存死区
由let
和const
声明的变量不存在变量提升,比如下面这种情况
1
2
3
4
function a(){
console.log(aa)
let aa = 111
}
会报错,以上在这个块级作用域内let aa
之前的区域被称为 暂存死区
还要一种情况就是函数参数也会有暂存死区的问题,不过这种情况比较少见
1
2
3
4
function a(arg1 = arg2, arg2){
console.log(arg1,arg2)
}
a(undefined,'arg2')
会报错,同样的暂存死区问题,各位老铁以后要注意一下哈
执行上下文
Javascript执行主要分为两个阶段:代码预编译阶段,代码执行阶段 预编译阶段是前置阶段,这个时候由编译器将 JavaScript 代码编译成可执行的代码,执行阶段主要任务是执行代码,执行上下文在这个阶段全部创建完成。 在通过语法分析,确认语法无误之后,JavaScript 代码在预编译阶段对变量的内存空间进行分配。预编译阶段我们需要注意的三点:
- 预编译阶段进行变量声明;
- 预编译阶段变量声明进行提升,但是值为 undefined;
- 预编译阶段所有非表达式的函数声明进行提升。
总结:作用域在预编译阶段确定,但是作用域链是在执行上下文的创建阶段完全生成的。因为函数在调用时,才会开始创建对应的执行上下文。执行上下文包括了:变量对象、作用域链以及 this 的指向
调用栈
总的来说就像是进一个只有一个门的大巴车(调用栈),先上车的坐最后面,依次来坐,那下车的时候(调用结束后)最先上车的一个就要最后一个下车。 看码好了:
1
2
3
4
5
6
7
8
9
10
11
12
13
function f1() {
f2()
}
function f2() {
f3()
}
function f3() {
f4()
}
function f4() {
console.log('f4')
}
f1()
这样形成的调用栈,最先入栈的是就是最先调用的f1
然后f2,f3,f4
相当于一个依次上车的人,那调用结束后也就是下车了,排队下车,最后上的排在最前面所以最先下车。所以f4
最先出栈,然后是f3,f2,f1
按顺序一个一个出栈
注意: 正常来讲,在函数执行完毕并出栈时,函数内局部变量在下一个垃圾回收节点会被回收,该函数对应的执行上下文将会被销毁,这也正是我们在外界无法访问函数内定义的变量的原因。也就是说,只有在函数执行时,相关函数可以访问该变量,该变量在预编译阶段进行创建,在执行阶段进行激活,在函数执行完毕后,相关上下文被销毁。
接下来就要进入正题了:闭包
内存管理
内存管理是计算机科学中的概念。不论是什么程序语言,内存管理都是指对内存生命周期的管理。 内存的生命周期包括以下几点:
- 分配内存空间
- 读写内存
- 释放内存空间
🌰:
1
2
3
let a = '分配存储空间'
console.log(a) // 使用内存空间
a = null // 释放内存空间
内存空间分为 堆内存 和 栈内存
- 栈内存:由操作系统自动分配释放,存放函数的参数值,局部变量的值等。
- 堆内存: 一般由开发者分配释放,这部分空间就要考虑垃圾回收的问题。
划重点
javascript
中基本数据类型存储在栈内存中,引用类型存储在堆内存中。引用类型一般是存储在栈内存的一个内存地址指向堆内存中的位置 主题是说闭包为什么这里提到了内存空间呢,前面了解了一下内存管理相关的基本知识,接下来说后面闭包会涉及到的内存泄漏 内存泄漏是指内存空间明明已经不再被使用,但由于某种原因并没有被释放的现象。
这是一个非常“玄学”的概念,因为内存空间是否还在使用,某种程度上是不可判定问题,或者判定成本很高。内存泄漏危害却非常直观:它会直接导致程序运行缓慢,甚至崩溃。这个时候就得开始举个🌰了:
1
2
3
4
5
6
let el = document.getElementById("el")
el.class = 'el-class'
// 移除 element 节点
function remove() {
element.parentNode.removeChild(element)
}
上面的代码,我们只是把 id
为 el
的节点移除,但是变量 el
依然存在,该节点占有的内存无法被释放。
我们需要在 remove
方法中添加:el = null
,这样更为稳妥
🌰2:
1
2
3
4
5
6
7
8
9
var el = document.getElementById('el')
el.innerHTML = '<button id="button">点我呀</button>'
var button = document.getElementById('button')
button.addEventListener('click', function() {
// ...
})
el.innerHTML = ''
这段代码执行后,因为 element.innerHTML = ''
,button
元素已经从 DOM 中移除了,但是由于其事件处理句柄还在,所以依然无法被垃圾回收。我们还需要增加 removeEventListener
,防止内存泄漏。
🌰3:
1
2
3
4
5
6
7
8
function func1() {
var name = 'Mopecat'
window.setInterval(function() {
console.log(name)
}, 1000)
}
func1()
这段代码由于 window.setInterval
的存在,导致 name
内存空间始终无法被释放,如果不是业务要求的话,一定要记得在合适的时机使用 clearInterval
进行清理。
闭包
看过各种各样的说法,晦涩且多样的定义仍然给初学者带来困惑,所以直接一点通俗的定义应该是:
函数嵌套函数时,内层函数引用了外层函数作用域下的变量,并且内层函数在全局环境下可访问,就形成了闭包。
简单的例子:
1
2
3
4
5
6
7
8
9
10
function numGenerator() {
let num = 1
num++
return () => {
console.log(num)
}
}
var getNum = numGenerator()
getNum()
这个简单的闭包例子中,numGenerator
创建了一个变量 num
,返回打印 num
值的匿名函数,这个函数引用了变量 num
,使得外部可以通过调用 getNum
方法访问到变量 num
,因此在 numGenerator
执行完毕后,即相关调用栈出栈后,变量 num
不会消失,仍然有机会被外界访问。
对比前述内容,我们知道正常情况下外界是无法访问函数内部变量的,函数执行完之后,上下文即被销毁。但是在(外层)函数中,如果我们返回了另一个函数,且这个返回的函数使用了(外层)函数内的变量,外界因而便能够通过这个返回的函数获取原(外层)函数内部的变量值。这就是闭包的基本原理。
因此,直观上来看,闭包这个概念为 JavaScript
中访问函数内变量提供了途径和便利。所以闭包是前端进阶必备基础。
接下来就看一些面试题来加深理解闭包:(都是求输出啥)
1
2
3
4
5
6
7
8
9
10
11
12
const foo = (function() {
var v = 0
return () => {
return v++
}
}())
for (let i = 0; i < 10; i++) {
foo()
}
console.log(foo())
在循环执行时,执行 foo()
,这样引用自由变量 10 次,v 自增 10 次,最后执行 foo 时,得到 10。(自由变量是指没有在相关函数作用域中声明,但是使用了的变量。)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const foo = () => {
var arr = []
var i
for (i = 0; i < 10; i++) {
arr[i] = function () {
console.log(i)
}
}
return arr[0]
}
foo()()
答案:10,这时自由变量为 i
,分析类似例题 1:foo()
执行返回的是 arr[0]
,arr[0]
是一个打印i
的function
而此时i
的值是10
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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。
将上面的题稍作修改
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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。
思考题:如何利用闭包实现单例模式? 单例模式,是一种常用的软件设计模式。GoF 在《设计模式:可复用面向对象软件的基础》一书中给出了如下定义:
Ensure a class only has one instance, and provide a global point of access to it.
保证一个类只有一个实例,并提供一个访问它的全局访问点。 使用闭包我们可以保持对实例的引用,不被垃圾回收机制回收,因此:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Person() {
this.name = 'lucas'
}
const getSingleInstance = (function(){
var singleInstance
return function() {
if (singleInstance) {
return singleInstance
}
return singleInstance = new Person()
}
})()
const instance1 = new getSingleInstance()
const instance2 = new getSingleInstance()
事实上,instance1 === instance2
。因为借助闭包变量 singleInstance
,instance1
和 instance2
是同一引用的(singleInstance
),这正是单例模式的体现。
结尾
闭包就分享到这里了,在js中闭包的应用非常之广泛,我们应该在在扎实的基础上更好的分析和利用它。 本文中部分内容摘自GitChat上的课程,如有侵权联系删除。