这篇文章主要介绍了 JavaScript 中的作用域链和闭包。先讲解了全局作用域、函数作用域和块级作用域的概念及特点,包括变量的访问规则和小测验。接着阐述了作用域链,通过代码和图解解释了词法作用域、outer 及作用域链的概念。然后说明了闭包的概念、产生原因,通过示例展示其应用,并给出了相关测验。最后总结了闭包的优缺点。
关联问题: 作用域链如何优化 闭包内存如何管理 全局作用域有何限制
前言
在之前的写文章中我们已经对js的运行机制有了初步的了解,并且在其中简单认识了三个作用域。那么接下来我们将由浅入深继续学习作用域相关的知识,先域的概念再学习作用域链和闭包
1.作用域
概念:变量/函数可以被访问的区域
特点:外层作用域无法直接访问内层作用域
在一段js的代码中,我们可以将代码分为三种区域:全局作用域、函数作用域和块级作用域。下面我们通过几段代码来了解一下三个作用域分别是是什么以及他们的特征
全局作用域
概念:{}和函数最外层的区域
1let a = 2
2
3function varTest() {
4 console.log(1);
5}
6
7function test() {
8 varTest()
9 console.log(a);
10}
11test()
在上面的代码中let定义的变量a、函数test和varTest都处于全局作用域中,varTest和变量a可以在函数test内部被访问,所以输出的结果为1 2
- 全局作用域中的声明的变量是全局变量,在页面的任意的部分都可以访问
- 全局作用域中无法访问函数作用域的变量
函数作用域
概念:函数体内部的区域(即function(){}中{}部分的区域)
1function varTest() {
2 var b = 3
3 console.log(1);
4}
5
6
7varTest()
8console.log(b);
上面代码之所以报错,是因为变量b定义在函数作用域内,而作用域的规则是内层可以访问外层而外层不能访问内层,所以在全局想要打印变量b时,找不到变量b从而报错
块级作用域
概念:由let和{}组成
1{
2 var a = 1
3 var b = 2
4}
5{
6 let c = 3
7 let d = 4
8}
9
10console.log(a, b);
11console.log(c, d);
输出:
在上面代码中,a和b之所以可以被访问是因为是由var所定义,而var和{}不会构成块级作用域。下面的c和d之所以报错,是因为由let所定义,而let和{}构成了块级作用域,不能被外层作用域所访问
小测验
在了解完了三个作用域之后,我们来看一下下面一段代码会输出什么:
1function varTest() {
2 var x = 1
3 if (true) {
4 let x = 2
5 console.log(x);
6 }
7 console.log(x);
8}
9varTest()
10
11
在这里第一个输出x输出是2大家可能没什么问题,而第二个x输出的是1,这里可能会有点问题。这是 因为let x = 2和{}构成了块级作用域,所以下面输出x访问不了块级作用域里面的变量这才会输出1。可能有些人会问为什么不是输出别的而是找到上面那个变量呢,这就关系到一个小知识了——上下文中变量的查找。
上下文中变量的查找
在前一篇文章js的运行机制中说到,在V8引擎中函数在被编译的时候会生成函数上下文压入调用栈中,而这个上下文中包含了变量环境和词法环境,var所定义的变量储存在变量环境中。那么块级作用域是存储在哪里呢?答案是词法环境中。
下面我们通过一段代码来解释在上下文中变量的查找:
1function foo() {
2 var a = 1
3 let b = 2
4 {
5 let b = 3
6 var c = 4
7 console.log(a);
8 console.log(b);
9 }
10 console.log(b);
11 console.log(c);
12}
13foo()
在foo这个函数的上下文中var所定义的变量都是存储在变量环境中,而let定义的变量和块级作用域都是储存在词法环境中,在执行过程中,会先从词法环境(内部结构类似于调用栈)从上到下依次查找,如果查找不到再去变量环境中进行查找,下面来看一下查找变量c的图解:
2.作用域链
在通过上面了解了三个作用域之后,接下来我们来深入一下,了解js中的作用域链。我们先通过一段代码初步认识一下作用域链是什么:
1function bar() {
2 console.log(myname);
3}
4function foo() {
5 var myname = '牛哥'
6 bar()
7 console.log(myname);
8}
9var myname = '小朱'
10foo()
这时候可能就会有些困惑了,明明是在函数foo中myname定义的变量值是’牛哥’,为什么bar函数调用的时候不是输出’牛哥’而是输出了小朱呢。这就不得不提到词法作用域和函数中自带的outer了,下面我们来了解一下这两个东西。
词法作用域和outer
词法作用域的概念:函数定义在了哪个域中,这个域就叫该函数的词法作用域。
outer:执行上下文当中存在一个outer属性,而这个outer会指向该函数的词法作用域,简而言之就是内部函数可以访问外部函数声明的变量。(可以看成一个指针)
在了解完了这两个东西的概念之后,我们来解析一下上面的代码。
函数bar和函数foo都是定义在全局上下文当中,当函数进行编译的时候,先将var定义的变量和函数bar、foo都放入全局上下文的变量环境中并且赋予默认值undefined和函数,将全局上下文压入调用栈内,然后执行代码。当调用函数foo时,先创建foo上下文放入调用栈内,然后将foo函数内部定义的变量myname放入foo上下文的变量环境中,然后执行代码。
当运行到调用函数bar时,可以看到函数bra内部要打印myname。此时的打印语句由于是存放于函数bar内部,而bar内部并没有myname这个变量,那么函数内部的outer就会指向bar的词法作用域内寻找myname变量,而由于bar的词法作用域是全局,全局中定义了变量myname = ‘小朱’,此时找到了myname就会输出小朱。然后继续向下执行打印foo函数内部myname所定义的牛哥。
下面让我们看一下上述代码在调用栈中的图解:
作用域链
在了解完了词法作用域和outer之后,下面我们来了解一下作用域链。这时候可能会问了,什么是作用域链呢,是不是一条链子把作用域给串起来了呢?
概念:V8 在查找变量的过程中,顺着执行上下文中的 outer 指向查清一整根链,这种链状关系就叫作用域链
下面呢我们通过一段代码来展示一下作用域链:
1let count = 1
2function main() {
3 let count = 2
4 function bar() {
5 let count = 3
6 function foo() {
7 let count = 4
8 }
9 foo()
10 }
11 bar()
12}
13main()
在上面的代码中并没有输出,我们只需要知道每个函数中的查找关系即可。相信大家已经猜到了,在上面这个例子中,三个函数的嵌套组成了一条作用域链:foo -> bar -> main -> 全局。
下面用调用栈中上下文来展示一下作用域链:
3.闭包
闭包的概念
在了解完了作用域链之后接下来我们最后来深入一下,了解一下闭包。首先我们得知道什么是闭包,接下来用一段代码来演示:
1function foo() {
2 function bar() {
3 var a = 1
4 console.log(b);
5 }
6 var b = 2
7 return bar
8}
9const baz = foo()
10baz()
在执行函数baz的时候(就相当于执行函数bar),这时候会发现,根据之前学习的js运行机制foo函数已经被出栈销毁,而函数bar需要变量b,这时候变量b跟随着函数foo销毁了,那么最后为什么还能输出b呢。
下面用调用栈来解释一下上述代码的执行过程:
在上面的图片中我们可以看到foo在销毁之后旁边出现了一个区域用来储存b = 2,而这个b正是函数foo当中的变量b。当执行函数bar的时候由于在函数内部找不到变量b,所以bar内部的outer会往词法作用域查找变量,而词法作用域已经被销毁了。这时候就得用到闭包了,js官方为了弥补这一缺点,设置了一片区域用来储存需要用到的变量来方便调用,这时候outer便会去这片区域查找变量b,而这正是闭包。
闭包的概念:在 js 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量。当通过调用一个外部函数返回的一个内部函数时(参考上面代码的baz()),即使外部函数调用完了,但是内部函数引用了外部函数中的变量,那么这些变量依然需要保存在内存中,我们把这些变量的集合称为闭包(只有被用到的变量才会保存)。
为什么会有闭包呢
在看完了上面闭包的概念以及闭包的例子之后,大家可能会想,为什么会有闭包呢?相信大家已经想到了,这是因为在js中词法作用域的规则 和 函数调用完毕它的执行上下文一定会被销毁这一规则冲突(不一定所有场景都冲突)
。而这,正是闭包所产生的原因。
4.小测验
在经过了前面对作用域链以及闭包的学习之后,下面我们来检测一下自己的学习成果如何,接下来呢我会提供两个题目给大家测试一下对闭包的掌控如何。
4.1 利用闭包设置一个计数器
1function add() {
2 let num = 0
3 return function foo() {
4 console.log(++num);
5 }
6}
7const res = add()
8res()
9res()
10res()
在上面的代码中,我们利用闭包的性质设置了一个计数器,用num来储存数量。上面代码在执行的时候,由于foo函数是在函数add内部,并且foo函数内部需要调用num。而num并不在函数foo内部,而是在foo的词法作用域,所以num会形成一个闭包用来储存每次所记录的数量。
下面用调用栈内的两个函数上下文来展示一下:
4.2 观察一下下面代码会输出什么
1function fn() {
2 var arr = []
3 for (var i = 0; i < 5; i++) {
4 arr.push(function () {
5 console.log(i);
6 })
7 }
8 return arr
9}
10
11var funcs = fn()
12for (var j = 0; j < 5; j++) {
13 funcs[j]()
14}
在这里呢,大家可能就会有点疑惑了,为什么最后会是五个5呢,而不是0或者0 1 2 3 4呢。这是因为fn中的for循环执行完之后,i的值是5。而i要被数组arr中的函数所调用,所以此时i = 5就会被保存下来用以接下来对数组中函数的调用。
下面我简单用这几个函数在调用栈内的样子画个图(全局上下文省略了):
接下来我们再思考一下,如何利用闭包,让这个函数输出0-4呢。想必大家已经想到了,接下来我就展示一下代码:
1function fn() {
2 var arr = []
3 for (var i = 0; i < 5; i++) {
4 (function(n){
5 arr.push(function () {
6 console.log(n);
7 })
8 })(i)
9 }
10 return arr
11}
12var funcs = fn()
13for (var j = 0; j < 5; j++) {
14 funcs[j]()
15}
这时候大家可能会有点疑惑,为什么在for循环中加个立即执行函数就能实现了呢,这就利用到了闭包的特性。大家可以想一想闭包的概念(忘了可以往上面翻翻),当一个函数执行完被销毁时,它里面的变量需要被它内部的函数所使用时,它里面的变量会被保存在一个集合中。利用这一特性,我们在每一次循环的时候都立即执行一下函数,这样每一个i都会被保存在一个集合中,那么接下来在依次执行数组中的函数时,这些i就会被依次使用。
接下来简单画个图让大家看得更清晰:
(由于画布有点小,立即执行函数就省略了)上图中的紫色箭头为outer指向,右边的每一块绿色就是for循环中每一次立即执行函数中的i所产生的闭包。
5.总结
- 闭包的缺点:内存泄漏(指的是调用栈的可用空间在缩小。闭包越多,那么存的变量越多从而导致调用栈空间变小)
- 闭包的优点:变量私有化,封装模块
所以说呢大家在平时使用闭包的时候,得节制一点,注意不要过度使用闭包,不然会内存不够。
最后呢谢谢大家的观看,喜欢的话点个赞吧。