你真的理解JavaSctipt作用域和闭包了?
作用域
我们先给作用域下个定义:
作用域是收集变量并且确定这些变量在当前执行的代码可访问的范围。
为了理解这个定义然后再看以下例子:
{
var a = 1;
console.log(a)
}
console.log(a)
这个程序运行的过程将涉及到
- 引擎: 负责整个
JavaScript
程序的编译及执行过程 - 编译器: 引擎的助手,负责语法分析及代码生成
- 作用域: 同上
在花括号中,引擎会将 var a = 1
该程序分成两个步骤,一个由编译器在编译时处理,另一个在引擎运行时处理。
处理步骤如下:
-
编译器:检查作用域是否存在变量
a
,不存在则在作用域声明a
。接着会生成引擎运行时需要的代码,这些代码被用来处理a = 1
这个赋值操作。 -
引擎:运行时会先检查当前作用域,是否存在一个叫作
a
的变量。如果存在就会使用这个变量;如果不存会向父级作用域继续查找该变量,最后找到变量a
,就会将赋值1
给它,最后console.log
输出a
。如果最终没找到,就会抛出一个异常!
在花括号外面的 console.log
读取变量 a
时,按照以上步骤,由于引擎没有找到变量a,所以会抛出一个异常!这一步体现了,作用域规定了变量可访问范围。
看到这,我想大家应该知道作用域在这个过程中所承担的责任:
- 负责变量的收集
- 确定这些变量在当前执行的代码可访问的范围。
接下来我来讲一下与作用域相关的内容:作用域链。其实在上面的内容已经涉及到,但我还没来得及说。
作用域链
在理解作用域的例子中,我们考虑的只有一层作用域的情况,实际在开发的时候可能有遇到嵌套多层的作用域。
一个函数或者块在另一个函数或者块中时,就会形成作用域的嵌套。
来看看下面的例子
function foo(){
var a = 1;
function bar() {
var c = a + b;
console.log(c);
}
bar()
}
var b = 2
foo()
在 bar
函数中,运算时,会去查找变量 a
和 b
。
引擎在 foo
的作用域中找到变量a
,但是b
却找不到,就会往上一级的作用域查找,最后在全局作用域找到了变量b。
也就是说,当作用域出现嵌套的情况时,作用域查找规则是 会先查找当前作用域是否存在需要的变量,不存则向上一级作用域查找,直到全局作用域,最后没找到就会报错。这样一层嵌套着一层的作用域,就会形成一个作用域链
因此就会在存在一种情况就是一个函数引用上级变量的情况。这与我们接下来讲的闭包有着很大的关联。
闭包
对于初学者来说这个概念是很难理解的,我也是花费了多年时间,每次都有不同的理解。我现在把目前的理解写出来分享给大家。
什么是闭包
看过挺多版本的概念,但是我更倾向于这样的一种解释:当一个函数去使用所在作用域的变量时,该作用域就会被函数引用,这个引用就称为闭包,即使是在该作用域外执行。
为了理解这个概念,先来看下以下例子:
运行该段代码,看下控制台输出,如图所示:
可以看到constructor
中的[[Scopes]]
属性中有两个对象:Closure
和 Global
。Global
就是全局对象window
,Closure
就是我们希望理解的闭包了,
现在将例子带入概念来理解下:一个函数(bar)使用了作用域(foo)中的变量时,该作用域就会被函数引用,该引用(如上图所示Closure
所在的位置)就是闭包,但这也仅仅是从纯学术角度观察到的。这样情况下的闭包,对于技术角度来说,没有实用性。
我们看一个网上非常常见的闭包例子:
function foo() {
var a = 1
function bar(){
console.log(a)
}
return bar
}
var baz = foo()
console.log(baz()) // 1
console.log(window.baz.prototype)
正常情况下在函数外部,无法访问到函数内的变量。上例中却能够在外部取到内部变量 a
的值。
因为 bar
使用了foo
的变量a
,产生了闭包,并通过return
返回bar
,使得foo
作用域被保存下来。
闭包绝不仅仅是上述例子中这样简单的使用,我举些例子,你就会发现你经常在用闭包。
function notify(msg) {
function timer() {
console.log(msg)
}
setTimeout(timer, 3000)
}
notify('你快来呀~')
notify
内部的函数 timer
访问了外部的变量 msg
,于是产生了闭包。
这些例子本质都是一样的,如果将函数当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其他的异步(或者同步)任务中
为什么需要闭包
先来看个例子:
function foo() {
var a = 1
function bar(){
console.log(a)
}
return bar
}
var baz = foo()
baz()
思考一个问题:因为 bar
被返回了所以不会被摧毁,那么 foo
执行完被销毁,这时候父作用域销不销毁?
答案是会被销毁。因为,父作用域中可能有很多与子函数无关的变量或者函数,会导致性能问题。
但是销毁了父作用域不能影响子函数,于是 JavaScript 就设计了闭包的机制。
所以子函数要创建一个对象存放的父作用域的引用即 [[Scopes]]
里的 Closure
,这样就不会影响子函数的运行又可以解决性能问题。
内存泄漏
内存泄漏是指在应用程序中分配的内存没有被正确释放,导致内存使用量不断增加,最终导致应用程序崩溃或变得非常缓慢。在JavaScript中,内存泄漏通常是由闭包引起的。
上文提到过,因为子函数会创建一个对象存在父作用域的引用闭包。也就是说假如这个闭包引用了很大的对象,它们将一直保留在内存中,直到闭包被销毁。
可以说,如果不小心创建了太多的闭包,或者这些闭包引用了太多的内存,应用程序就可能会遭受内存泄漏的问题。
我们举一些例子来帮助理解在JavaScript
中闭包导致的内存泄漏
- 定时器中的闭包
function startTimer() {
var count = 0;
setInterval(function() {
console.log(count); count++;
}, 1000);
}
startTimer();
在这个例子中,setInterval
函数创建了一个闭包,它引用了 count
变量。由于这个闭包被传递给 setInterval
,它将一直存在于内存中,直到定时器被清除。因此,如果你调用了 startTimer()
多次,就会创建多个闭包,导致内存泄漏的问题。为了避免这个问题,你应该手动清除定时器。
- 事件监听器中的闭包
function addClickListener() {
var button = document.getElementById("myButton");
button.addEventListener("click", function() {
console.log("Button clicked");
})
}
addClickListener();
在这个例子中,addEventListener
函数创建了一个闭包,它引用了 button
变量。由于这个闭包被传递给 addEventListener
,它将一直存在于内存中,直到事件监听器被移除。因此,如果你多次调用 addClickListener()
,就会创建多个闭包,导致内存泄漏的问题。为了避免这个问题,你应该手动移除事件监听器。
- 循环中的闭包
function createButtons() {
var container = document.getElementById("myContainer");
for (var i = 0; i < 10; i++) {
var button = document.createElement("button");
button.innerText = "Button " + i;
button.addEventListener("click", function() { console.log("Button " + i + " clicked");
});
container.appendChild(button);
}
}
在这个例子中,循环中的闭包引用了 i
变量。由于这些闭包被传递给 addEventListener
,它们将一直存在于内存中,直到事件监听器被移除。因此,如果你多次调用 createButtons()
,就会创建多个闭包,导致内存泄漏的问题。为了避免这个问题,你可以使用立即执行函数来创建一个新的作用域,以便在每个循环迭代中保存一个新的变量。
总结
闭包虽然会造成内存泄漏但不意味着不能用闭包,只要再使用后清除即可。其实大多数情况下都不会有内存泄漏的情况,特别是现在使用React、Vue的年代,一般切换页面就会被销毁,组件就会被销毁,即使你写了闭包也会被摧毁,所以大胆的去使用闭包。
转载自:https://juejin.cn/post/7220310687482363964