你真的理解JavaSctipt作用域和闭包了?

1,952 阅读8分钟

作用域

我们先给作用域下个定义:

作用域是收集变量并且确定这些变量在当前执行的代码可访问的范围。

为了理解这个定义然后再看以下例子:

{
    let a = 1;
    console.log(a)
}
console.log(a)

这个程序运行的过程将涉及到引擎、编译器、作用域

引擎: 负责整个`JavaScript`程序的编译及执行过程
编译器: 引擎的助手,负责语法分析及代码生成
作用域: 收集变量并且确定这些变量在当前执行的代码可访问的范围

在花括号中,引擎会将 let a = 1 该程序分成两个步骤,第一步创建初始化由编译器在编译时处理,第二步赋值在引擎运行时处理。

处理步骤如下:

  • 编译器:检查作用域是否存在变量a,不存在则在作用域声明a。接着会生成引擎运行时需要的代码,这些代码被用来处理 a = 1 这个赋值操作。

  • 引擎:运行时会先检查当前作用域,是否存在一个叫作 a 的变量。如果存在就会使用这个变量;如果不存会向父级作用域继续查找该变量,最后找到变量 a,就会将赋值 1 给它,最后 console.log 输出 a。如果最终没找到,就会抛出一个异常!

在花括号外面的 console.log 读取变量 a 时,按照以上步骤,由于引擎没有找到变量a,所以会抛出一个异常!

引擎在作用域寻找作用域,说明了作用域具有收集变量的功能

let声明的 a是块作用域,只能在花括号内可以访问,所以这一步体现了定义中的作用域规定了变量可访问范围

看到这,我想大家应该知道作用域在这个过程中所承担的责任:

  • 负责变量的收集
  • 确定这些变量在当前执行的代码可访问的范围。

接下来我们来理解一个与作用域相关的词语:作用域链。其实在上面的内容已经涉及到,但我还没来得及说。

作用域链

在理解作用域的例子中,我们考虑的只有一层作用域的情况,而实际在开发的时候可能有遇到嵌套多层的作用域,如下例:

function foo(){
    var a = 1;
    function bar() {
        var c = a + b;
        console.log(c);
    }
    bar()
}
var b = 2
foo()

执行 bar 函数时,引擎会去查找变量 ab

引擎在 foo 的作用域中找到变量a,但是b却找不到,此时就会往上一级的作用域查找,最后在全局作用域找到了变量b

也就是说,当作用域出现嵌套的情况时,作用域查找规则是会先查找当前作用域是否存在需要的变量,不存则向上一级作用域查找,直到全局作用域,最后没找到就会报错。这样一层嵌套着一层的作用域,就会形成一个作用域链

因此就会在存在一种情况就是一个函数引用上级变量的情况。这与我们接下来讲的闭包有着很大的关联。

闭包

对于初学者来说这个概念是很难理解的,我也是花费了多年时间,每次都有不同的理解。我现在把目前的理解写出来分享给大家。

什么是闭包

看过挺多版本的概念,但是我更倾向于这样的一种解释:当一个函数去使用所在作用域的变量时,该作用域就会被函数引用,这个引用就称为闭包,即使是在该作用域外执行。

为了理解这个概念,我们看一个网上非常常见的闭包例子:

function foo() {
    var a = 2
    function bar(){
        console.log(a)
    }
    return bar
}

var baz = foo()
console.log(baz()) // 2
console.log(window.baz.prototype)

看下控制台输出,如图所示:

1679639825116.png

可以看到constructor中的[[Scopes]]属性中有两个对象:ClosureGlobalGlobal就是全局对象windowClosure就是我们希望理解的闭包了,

现在将例子带入概念来理解下:一个函数(bar)使用了作用域(foo)中的变量时,该作用域就会被函数(bar)引用,该引用(如上图所示Closure)就是闭包。

正常情况下在函数外部,无法访问到函数内的变量。上例中却能够在外部取到内部变量 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 作用域被返回了所以不会被摧毁

思考一个问题,bar 引用了 foo中的 a, 那么函数执行完毕 foo 作用域销不销毁?

答案是会被销毁。因为 foo 作用域中可能有很多与子函数无关的变量或者函数,会导致性能问题。

但是为了保证销毁了 foo 作用域不能影响 bar 函数访问变量 a,于是 JavaScript 就设计了闭包的机制。

所以 bar 函数的[[Scopes]]中要创建一个对象存放的 foo 作用域的引用即 [[Scopes]] 里的 Closure(闭包),这样就不会影响 bar 函数的运行又可以解决性能问题。

内存泄漏

在JavaScript中,内存泄漏通常是由闭包引起的。内存泄漏是指在应用程序中分配的内存没有被正确释放,导致内存使用量不断增加,最终导致应用程序崩溃或变得非常缓慢。

上文提到过,因为子函数会创建一个对象存在父作用域的引用闭包。因此,如果不小心创建了太多的闭包或者闭包内存太大,它们将一直保留在内存中,直到闭包被销毁,应用程序就可能会遭受内存泄漏的问题。

我们举一些例子来帮助理解在JavaScript中闭包导致的内存泄漏

  1. 定时器中的闭包
function startTimer() { 
    var count = 0; 
    setInterval(function() { 
        console.log(count); count++; 
    }, 1000); 
} 
startTimer();

在这个例子中,setInterval 函数创建了一个闭包,它引用了 count 变量。由于这个闭包被传递给 setInterval,它将一直存在于内存中,直到定时器被清除。因此,如果你调用了 startTimer() 多次,就会创建多个闭包,导致内存泄漏的问题。为了避免这个问题,你应该手动清除定时器。

  1. 事件监听器中的闭包
function addClickListener() {
    var button = document.getElementById("myButton");
    button.addEventListener("click", function() {
        console.log("Button clicked");
    })
}
addClickListener();

在这个例子中,addEventListener 函数创建了一个闭包,它引用了 button 变量。由于这个闭包被传递给 addEventListener,它将一直存在于内存中,直到事件监听器被移除。因此,如果你多次调用 addClickListener(),就会创建多个闭包,导致内存泄漏的问题。为了避免这个问题,你应该手动移除事件监听器。

  1. 循环中的闭包
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的年代,一般切换页面,组件就会被销毁,即使你写了闭包也会被摧毁,所以大胆的去使用闭包。