📕 重学JavaScript:你理解闭包吗?
嗨,大家好!这里是道长王jj
~ 🎩🧙♂️
如果你用一个循环来设置多个 setTimeout
会发生什么呢?比如说,你想让它们分别在 1 秒、2 秒、3 秒…之后执行,并且打印出对应的数字🔢。你可能会写出这样的代码:
for (var i = 1; i <= 5; i++) {
setTimeout(function() {
console.log(i);
}, i * 1000);
}
你觉得这样会打印出什么呢?在解答这个题目之前,我们先学习一下我们今天的主题❤
❓什么是闭包?
闭包是指一个函数能够访问另一个函数作用域中的变量的函数。闭包可以让开发者从内部函数访问外部函数的作用域。
这个定义来自于《红宝书》第178页。
❓为什么会产生闭包?
针对这个问题我们需要举一个简单的函数例子在整个JavaScript中是如何运行的:
function template(num) {
if (num) {
const temp = num;
console.log(temp);
}
}
template();
JavaScript 的执行过程可以分为以下几个步骤,然后才真正开始运行代码:
graph LR;
A[JS源代码] --语法分析--> B[tokens];
B --语义分析--> C[AST];
C --> D[字节码];
D --> E[开始运行程序];
JS
源代码经过语法分析,转化成tokens
tokens
经过语义分析,转化为AST
(抽象语法树)- 抽象语法树会被转化为字节码
JS
运行时开始运行这段上面生成代码
如果你想更好地理解这个过程,可以把它想象成一个人在读一篇文章。首先,他会把文章分成很多小段,也就是 tokens
。然后,他会把这些小段组合起来,形成一个完整的句子或者段落,也就是 AST
。最后,他会理解这个句子或者段落的意思,并且可以用自己的话来表达出来,也就是字节码。
当函数运行时,会执行以下步骤:
函数声明:当代码执行到函数声明的时候,
JavaScript
会询问作用域链,看看是否已经声明了template
函数。如果没有声明,就会在当前作用域中创建一个template
函数(这里template
是没有声明的,所以会在全局作用域中创建)。console.log
中的console
是内置对象,虽然不是我们声明的,但是它已经在全局作用域了。执行
template
:JavaScript
同样会询问作用域链,看看是否已经声明了template
函数。如果没有声明,就会报Reference Error
。进入到template函数中:代码进入到了
template
函数中。我们创建了一个新的作用域,并将其指向全局作用域,从而形成了一个新的作用域链。检查
num
变量:JavaScript
同样会询问作用域链,看看是否已经声明了num
变量。在template
函数中的新的作用域中找不到num
变量时,它就会沿着链向上查找(如果当前作用域找到就返回),最终都找不到时就会报Reference Error
。这个过程类似于原型链。……
剩下的部分我就不再解释了,相信你应该能够理解。
实际上,作用域背后的原理是词法环境
。词法环境由两部分组成:
- 环境记录:这其实就是
JavaScript
用来存储变量的地方,一个key-value
对在这里被称为一个binding
。 - 外部环境的引用。
全局作用域,总是出现在作用域的最外层。全局作用域对应的环境就是
全局环境
,全局作用域的外部环境引用
是 null。
由此,我们发现由于 Javascript
的解析逻辑,就会产生作用域链。当访问一个变量时,解释器会首先在当前作用域查找标示符,如果没有找到,就去父作用域找,直到找到该变量的标示符或者不在父作用域中
每一个子函数都会拷贝上级的作用域,形成一个作用域的链条。 比如:
var x = 10;
function foo() {
var y = 20;
function bar() {
var z = 30;
console.log(x + y + z); // 60
}
bar();
}
foo();
由此,闭包产生的本质就是,当前环境中存在指向父级作用域的引用。
❓闭包有哪些表现形式?
明白了本质之后,我们就来看看,在真实的场景中,究竟在哪些地方能体现闭包的存在?
返回一个函数:
// 定义一个函数,接受一个参数 x,并返回一个新的函数 function makeAdder(x) { // 返回一个匿名函数,该函数接受一个参数 y,并返回 x + y return function(y) { return x + y; }; } // 调用 makeAdder() 函数,并传入 5 作为参数,得到一个新的函数 add5 var add5 = makeAdder(5); // 调用 add5() 函数,并传入 2 作为参数,得到结果 7 var result = add5(2); // 输出结果 console.log(result); // 7
把整个函数作为参数传入:
// 定义一个比较函数,按照字符串长度升序排列 function compareByLength(a, b) { return a.length - b.length; } // 创建一个字符串数组 let fruits = ["apple", "banana", "cherry", "durian", "elderberry"]; // 调用 sort() 方法,并传入比较函数作为参数 // 这就是闭包 fruits.sort(compareByLength); // 输出排序后的数组 console.log(fruits); // ["apple", "banana", "cherry", "durian", "elderberry"]
在定时器、事件监听、Ajax请求、跨窗口通信、Web Workers或者任何异步中,只要使用了回调函数,实际上就是在使用闭包。
// 定时器 setTimeout(function timeHandler(){ console.log('111'); },100)
IIFE(立即执行函数表达式)创建闭包, 保存了
全局作用域window
和当前函数的作用域
,因此可以全局的变量。var x = 2; (function IIFE(){ // 输出2 console.log(x) })();
❓思考:为什么不能像开头那样写?
我们回到文章首页那道题:
for (var i = 1; i <= 5; i++) {
setTimeout(function() {
console.log(i);
}, i * 1000);
}
你觉得这样会打印出什么呢?1、2、3、4、5 吗?不好意思,答错了。实际上,这样会打印出 6、6、6、6、6 。为什么呢?因为当你的 setTimeout
轮到执行的时候,循环已经结束了,i 的值已经变成了 6。
而且,用的是 var 来声明 i,这样就把 i 变成了全局变量🌎。
所以,当你的 setTimeout
里面的函数想要找 i 的时候,它就会去全局找,发现了 i 是 6,就打印出来了😂。
为什么会全部输出6?如何改进,让它输出1,2,3,4,5?(方法越多越好)
因为setTimeout
为宏任务,由于JavaScript
中单线程eventLoop
机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后setTimeout
中的回调才依次执行,但输出i的时候当前作用域没有,往上一级再找,发现了i,此时循环已经结束,i变成了6。因此会全部输出6。就像是一条单行道,一次只能走一个车🚗。所以,setTimeout
就像是一个耐心的司机,它会把自己的车停在路边,等到前面的车都走完了,才会开上去🚙。
解决方法:
1、利用IIFE(立即执行函数表达式)当每次for循环时,把此时的i变量传递到定时器中
for(var i = 1;i <= 5;i++){
(function(j){
setTimeout(function() {
console.log(j);
}, j * 1000);
})(i)
}
2、给定时器传入第三个参数, 作为timer函数的第一个函数参数
for(var i = 1;i <= 5;i++){
setTimeout(function timer(j){
console.log(j)
}, j * 1000, i)
}
3、使用ES6中的let
for (let i = 1; i <= 5; i++) {
setTimeout(function() {
console.log(i);
}, i * 1000);
}
🎉 你觉得怎么样?这篇文章可以给你带来帮助吗?如果你有任何疑问或者想进一步讨论相关话题,请随时发表评论分享您的想法,让其他人从中受益。🚀✨