学习地址:https://zh.javascript.info/closure(本文的截图都来自此地址,这些图画得都很漂亮。)
闭包是一种编程思想,了解它之前要先了解下面相关的知识点:
- 变量作用域
- 嵌套函数
变量作用域
❓ 什么是变量作用域呢?
✅ 变量作用域就是变量起作用的区域。
❓ 这个区域是怎么出来的呢?
✅ 用大括号{}
就可以划分出一个区域,这个区域叫做代码块。
❓ 划出来的这个区域有什么用呢?
✅ 在代码块内用let
和const
声明的变量(用var
声明的变量在这里就不讨论了,最好不要用),可以在当前这个块内被访问到,出了块就访问不到了,也就是限定了变量起作用的区域。
示例代码:
{
let a = '哈哈'
console.log(a) // 哈哈
}
console.log(a) // Uncaught ReferenceError: a is not defined
if (true) {
let a = '哈哈'
console.log(a) // 哈哈
}
console.log(a) // Uncaught ReferenceError: a is not defined
嵌套函数
❓ 什么是嵌套函数呢?
✅ 如果一个函数是在另一个函数中创建的,那么这个函数就叫嵌套函数。
❓ 嵌套函数有什么特点呢?
✅ 嵌套函数可以访问外部变量。
示例代码:
function func1() {
let a = '张三'
function func2() {
return a
}
console.log(`姓名是:${func2()}`) // 姓名是:张三
}
func1()
更有意思的是,可以返回一个嵌套函数:作为一个新对象的属性或作为结果返回。之后可以在其他地方使用。不论在哪里调用,它仍然可以访问相同的外部变量。
function makeCounter() {
let count = 0
return function() {
return count++
}
}
let counter = makeCounter()
console.log(counter()) // 0
console.log(counter()) // 1
console.log(counter()) // 2
那么问题来了:
- 为什么代码块能限制变量的作用范围?
- 限制了变量的作用范围后,为什么嵌套函数还能访问到外部变量?
这背后隐藏的大boss是谁呢?
这个大boss就是词法环境,是它制定的游戏规则。这个得好好了解一下。
词法环境
简介
在JavaScript中,每个运行的函数,代码块 {...} 以及整个脚本,都有一个被称为词法环境(Lexical Environment)的内部(隐藏)的关联对象。
“词法环境”是一个规范对象(specification object):它只存在于语言规范的“理论”层面,用于描述事物是如何工作的。我们无法在代码中获取该对象并直接对其进行操作。
词法环境对象由两部分组成:
- 环境记录(Environment Record) —— 一个存储所有局部变量作为其属性(包括一些其他信息,例如
this
的值)的对象。 - 对 外部词法环境 的引用,与外部代码相关联。
变量与词法环境的关系
一个“变量”只是环境记录这个特殊的内部对象的一个属性。“获取或修改变量”意味着“获取或修改词法环境的一个属性”。
比如,下图展示了一个全局词法环境,全局词法环境没有外部引用,所以箭头指向了 null
。
再看一个长一点的代码:
右侧的矩形演示了执行过程中全局词法环境的变化:
- 脚本开始运行,词法环境预先填充了所有声明的变量。
此时变量处于“未初始化(Uninitialized)”状态。这意味着引擎知道变量,但是在用 let
声明前,不能引用它。几乎就像变量不存在一样。
- 用
let
关键字定义了变量,但没有赋值,此时变量是undefined
。从这一刻起,就可以使用变量了。 - 变量被赋予了一个值。
- 变量被修改。
函数与词法环境的关系
函数其实也是一个值,就像变量一样。与变量的不同之处在于函数声明的初始化会被立即完成。
下图展示了添加一个函数时全局词法环境的初始状态。
当创建了一个词法环境(Lexical Environment)时,函数声明会立即变为即用型函数(不像 let 那样直到声明处才可用)。这就是为什么我们甚至可以在声明自身之前调用一个以函数声明(Function Declaration)的方式声明的函数。
内部和外部词法环境
通过下图,来解释一下内部和外部词法环境。
把自己想象成js引擎,现在由引擎你来运行上面的代码,请开始你的表演。
- 代码开始运行时,创建了一个全局词法环境,它具有
phrase
变量和say()
函数。 - 然后用
let
关键字给phrase
变量赋值为Hello,就相当于修改了全局词法环境的phrase
属性。 - 当执行到
say("John")
这行时,就跟上面的图对应上了(当前执行位置在箭头标记的那一行上)。
在say("John")
调用期间,此函数的词法环境由两部分组成 :内部一个(用于函数调用)和外部一个(全局)。
- 在内部环境中有一个名叫
name
的属性,它是函数的参数。我们调用了say("John")
,因此name
的值为John。 - 外部词法环境是全局词法环境。它具有
phrase
变量和函数本身。
当代码要访问一个变量时:首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境。(这就是变量作用域的游戏规则)
如果在任何地方都找不到这个变量,那么在严格模式下就会报错(在非严格模式下,为了向下兼容,给未定义的变量赋值会创建一个全局变量)。
变量的搜索过程如下图所示。
- 对于
name
变量,当say
中的alert
试图访问name
时,会立即在内部环境中找到。 - 对于
phrase
变量,由于内部环境中没有phrase
变量,它会顺着对外部词法环境的引用找到它。
返回函数
这一部分可是重点中的重点了,看懂了这块,就能明白闭包的原理了。
创建嵌套函数
有这样一段代码:
function makeCounter() {
let count = 0;
return function() {
return count++;
};
}
let counter = makeCounter();
在每次makeCounter()调用的开始,都会创建一个新的词法环境,以存储该makeCounter运行时的变量,如下图所示:
和之前的代码不同,在执行makeCounter()
的过程中,创建了一个嵌套函数。我们并未运行它,只是创建了它。
[[Environment]]属性
所有的函数在“诞生”时,都会记住创建它们的词法环境。所有函数都有名为[[Environment]]
的隐藏属性,该属性保存了对创建该函数的词法环境的引用。
所以,counter.[[Environment]]
有对{count: 0}
词法环境的引用。这就是函数记住它创建于何处的方式,与函数被在哪儿调用无关。[[Environment]]
引用在函数创建时被设置并永久保存。
之后,当调用counter()
时,会为该调用创建一个新的词法环境,并且其外部词法环境引用获取于counter.[[Environment]]
,如下图所示:
也就是说:JavaScript中的函数会自动通过隐藏的[[Environment]]
属性记住创建它们的位置,所以它们都可以访问外部变量。
在变量所在的词法环境中更新变量
现在,当counter()中的代码查找count变量时,它首先搜索自己的词法环境(为空,因为那里没有局部变量),然后是外部makeCounter()的词法环境,并且在哪里找到就在哪里修改。也就是说,在变量所在的词法环境中更新变量。如下图所示:
如果我们调用 counter()
多次,count
变量将在同一位置增加到 2
,3
等。
这就是嵌套函数能够访问到外部变量的游戏规则。
闭包
终于该聊一聊正题了。
简介
闭包是指一个函数可以记住其外部变量,并可以访问这些变量。闭包是一种编程思想,按照这种思想来组织的代码,叫做闭包结构。
为什么要使用闭包呢?闭包通过将变量保存(也可以说是“隐藏”,让外界不知道有这个变量)在外部函数中这种方式,可以避免该变量污染全局变量,也可以避免其他的代码修改该变量,影响程序的运行。
示例计数器:从普通结构到闭包结构
举个例子,来说明一下如何使用闭包。比如,现在有这样一个需求:页面上有个用于计数的按钮,点击一次,计数器就加1。
地板流:普通代码
先来个初始代码:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>闭包演示---计数器</title>
</head>
<body>
<button onclick="clickBtn()">点击</button>
<script>
function clickBtn() {
let count = 0;
console.log(++count);
}
</script>
</body>
</html>
执行效果,如图所示:
可以看到,多次点击按钮后,计数器显示的值始终是1,并没有累加。
为什么会出现这种现象呢?根据前边的知识,每点击一次按钮,clickBtn()
函数就执行一次,每次执行都会生成一个新的词法环境,跟本次函数对应,所以点击了几次按钮,就创建了几个词法环境,词法环境的count
属性开始时都是0,执行++
操作后,变成1。
空间型:将变量count放到全局词法环境中
现在改造一下代码,把变量count
放到全局词法环境中,让clickBtn()
函数的词法环境去全局词法环境中拿count
的值。代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>闭包演示---计数器</title>
</head>
<body>
<button onclick="clickBtn()">点击</button>
<script>
let count = 0;
function clickBtn() {
console.log(++count);
}
</script>
</body>
</html>
执行效果,如图所示:
可以看到,多次点击按钮后,计数器能正常工作。
脚踝被终结者:全局词法环境中的count变量被其他代码修改
上面的代码貌似正常了,但其实有一个隐患。代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>闭包演示---计数器</title>
</head>
<body>
<button onclick="clickBtn()">点击</button>
<script>
let count = 0;
function clickBtn() {
console.log(++count);
}
// ... 一堆其它的代码
setTimeout(function() {
count = 100;
}, 2000);
</script>
</body>
</html>
执行效果,如图所示:
可以看到,当多次点击按钮时,开始可以正常计数,但在某一时刻,count
的值变成了100,之后再点击按钮,计数器就会在100的基础上,进行累加操作。
为什么会有这种现象呢?因为变量count
保存在了全局词法环境中,那么clickBtn()
函数就可以从全局词法环境中拿到这个值,并修改(进行++
操作),那你clickBtn()
函数能从全局词法环境中拿count
的值,我其他函数(比如setTimeout
里的回调函数)也能从全局词法环境中去拿,你能改值,我也能改值啊,别忘了之前说过的话:在变量的词法环境中修改变量。在哪儿找到就在哪儿改,当回调函数修改了值(count = 100;
)后,clickBtn()
函数再去全局词法环境中拿count
的值,就变成100了。
所以,如果把count
变量放到全局词法环境中,它就有被其他函数修改的风险。
天花板:用闭包结构组织代码
既然把count
变量放到全局词法环境中并不是一个好的选择,那还是把它放到一个函数的词法环境中吧。
我们推导一下:因为每次点击按钮,都要执行一个函数clickBtn()
,在这个函数里对count
变量进行累加操作,而这个变量又不能放在全局,所以可不可以:
- 把这个
count
变量放到一个函数里,做为它的局部变量;(限制变量作用域范围) clickBtn()
函数也嵌套在这个函数中,同时也引用了count
变量;(变量来自函数的外部词法环境)- 这个函数执行时,返回了嵌套的
clickBtn()
函数;(提供给外界使用)
这样点击按钮时,执行clickBtn()
函数,clickBtn()
函数又顺着外部词法环境能够找到count
变量,而保存count
变量的这个词法环境,只有clickBtn()
函数能访问到,其他的函数访问不到,连访问都访问不到,更别提修改了。
代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>闭包演示---计数器</title>
</head>
<body>
<button onclick="clickBtn()">点击</button>
<script>
function addCount() {
let count = 0;
return function() {
console.log(++count);
}
}
let clickBtn = addCount();
</script>
</body>
</html>
执行效果,如图所示:
顶破天:用IIFE再精致一步
虽然上面的代码实现了闭包的结构,但还是有个问题:虽然我们把count
变量隐藏到addCount()
函数中,但却将addCount()
暴露到全局范围内,这隐藏了一个,又暴露了另一个,就没啥意思了。
我们可以借助IIFE来解决这个问题。
IIFE指的是立即调用函数表达式,是一个在定义时就会立即执行的js函数。它是一种设计模式,主要包含两部分:
- 第一部分是一个具有词法作用域的匿名函数,并且用圆括号运算符()闭合起来。这样不但阻止了外界访问IIFE中的变量,而且不会污染全局作用域。
- 第二部分创建了一个立即执行函数表达式(),通过它,js引擎将立即执行该函数。
我们将上面的代码改造一下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>闭包演示---计数器</title>
</head>
<body>
<button id="btn">点击</button>
<script>
btn.onclick = (function() {
let count = 0;
return function() {
console.log(++count);
};
})();
</script>
</body>
</html>
这样闭包还是那个闭包,也不会有全局污染的问题了。
应用示例
字段排序
const users = [{
name: 'Tom',
age: 5,
type: 'cat'
}, {
name: 'Jerry',
age: 3,
type: 'mouse'
}, {
name: 'Speike',
age: 10,
type: 'dog'
}];
function byField(field) {
return function(a, b) {
if (a[field] > b[field]) {
return 1;
} else if (a[field] < b[field]) {
return -1;
} else {
return 0;
}
}
}
console.log(users.sort(byField('name')));
找到数组中符合条件的数字
const numbers = [1, 3, 6, 7, 2, 11, 5, 12, 9, 4, 6];
function inBetween(num1, num2) {
return function(item) {
return item >= num1 && item <= num2;
}
}
console.log(numbers.filter(inBetween(3, 9)));
当你知道了事物背后的原理,你再看待这个事物就跟之前不一样了。以前是模糊混乱,现在是清晰透彻,以前是局外人,现在是引擎,是游戏规则制定者,代码怎么跑,要按我定的规则来。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。