# 闭包

# 什么是闭包

MDN 定义:闭包是指能够访问自由变量的函数。

自由变量 是指在函数中使用,但既不是函数参数也不是函数的局部变量的变量。闭包 = 函数 + 函数能够访问的自由变量。

ECMAScript 中,闭包有以下定义:

理论角度的定义:所有的函数都是闭包。用为它们在创建的时候就将上层上下文的数据保存起来。在函数中访问全局变量就相当于访问自由变量,这时使用最外层的作用域。可以访问其他函数作用域的内部函数叫做闭包。闭包让你可以在一个内层函数中访问到其外层函数的作用域

从实践角度定义:以下函数才算闭包:

  • 即使创建它的上下文已销毁,它任然存在。(比如内部函数从父函数中返回)。
  • 代码中引用了自由变量。

一般函数的词法环境在函数返回后就被销毁,但是闭包会保存对创建时所在词法环境的引用,即便创建时所在的执行上下文被销毁,但创建时所在词法环境依然存在,以达到延长变量的生命周期的目的

一个函数内部有外部变量的引用,比如函数嵌套函数时,内层函数引用了外层函数作用域下的变量,即形成了闭包。最常见的场景为:函数作为一个函数的参数,或函数作为一个函数的返回值时

可以在内部函数访问到外部函数作用域。使用闭包,一可以读取函数中的变量,二可以将函数中的变量存储在内存中,保护变量不被污染。而正因闭包会把函数中的变量值存储在内存中,会对内存有消耗,所以不能滥用闭包,否则会影响网页性能,造成内存泄漏。当不需要使用闭包时,要及时释放内存,可将内层函数对象的变量赋值为 null。

接下来就来讲讲实践上的闭包:

首先我们要分析一下这段代码中执行上下文栈和执行上下文的变化情况

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}

var foo = checkscope();
foo();
1
2
3
4
5
6
7
8
9
10
11

执行过程:

  1. 进入全局代码,创建全局执行上下文,全局执行上下文压入执行上下文栈
  2. 全局执行上下文初始化
  3. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 执行上下文被压入执行上下文栈
  4. checkscope 执行上下文初始化,创建变量对象、作用域链、this等
  5. checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出
  6. 执行 f 函数,创建 f 函数执行上下文,f 执行上下文被压入执行上下文栈
  7. f 执行上下文初始化,创建变量对象、作用域链、this等
  8. f 函数执行完毕,f 函数上下文从执行上下文栈中弹出

当 f 函数执行的时候,checkscope 函数上下文已经被销毁了啊(即从执行上下文栈中被弹出),怎么还会读取到 checkscope 作用域下的 scope 值呢?

当我们了解了具体的执行过程后,我们知道 f 执行上下文维护了一个作用域链:

fContext = {
    Scope: [AO, checkscopeContext.AO, globalContext.VO],
}
1
2
3

就是因为这个作用域链,f 函数依然可以读取到 checkscopeContext.AO 的值,说明当 f 函数引用了 checkscopeContext.AO 中的值的时候,即使 checkscopeContext 被销毁了,但是 JavaScript 依然会让 checkscopeContext.AO 活在内存中,f 函数依然可以通过 f 函数的作用域链找到它,正是因为 JavaScript 做到了这一点,从而实现了闭包这个概念。

理解闭包,首先要理解词法作用域、作用域链、执行上下文等前置知识,闭包指的是一个函数能够“记住”并访问其词法作用域,即使在其外部被调用时。换句话说,闭包是由函数及其创建时的作用域组成的。当一个函数被定义时,会创建一个作用域。在这个作用域中,定义的变量和参数会被保留。当内部函数访问外部函数的变量时,形成闭包。即使外部函数已经执行完毕,内部函数依然可以访问外部函数的变量

# 闭包的原理

函数执行分成两个阶段(预编译阶段和执行阶段)。

预编译阶段,如果发现内部函数使用了外部函数的变量,则会在内存中创建一个“闭包”对象并保存对应变量值,如果已存在“闭包”,则只需要增加对应属性值即可。

执行完后,函数执行上下文会被销毁,函数对“闭包”对象的引用也会被销毁,但其内部函数还持用该“闭包”的引用,所以内部函数可以继续使用“外部函数”中的变量

利用了函数作用域链的特性一个函数内部定义的函数会将包含外部函数的活动对象添加到它的作用域链中,函数执行完毕,其执行作用域链销毁,但因内部函数的作用域链仍然在引用这个活动对象,所以其活动对象不会被销毁,直到内部函数被烧毁后才被销毁。

综上,JavaScript 是采用词法作用域的,这就意味着函数的执行依赖于函数定义的时候所产生(而不是函数调用的时候产生的)的变量作用域。为了去实现这种词法作用域,JavaScript 函数对象的内部状态不仅包含函数逻辑的代码,除此之外还 包含当前作用域链的引用。函数对象可以通过这个作用域链相互关联起来,如此,函数体内部的变量都可以保存在函数的作用域内,这在计算机的文献中被称之为 闭包。所以闭包是指一个函数能够访问其定义时的作用域中的变量,即使在其定义的作用域已经执行完毕后。这意味着闭包可以让函数“记住”它被创建时的环境,即使它在另一个环境被调用,而这种“记住”正是通过作用域链实现。

# 闭包与作用域的理解

JavaScript 是采用词法作用域的,这就意味着 函数的执行依赖于函数定义的时候所产生(而不是函数调用的时候产生的)的变量作用域。为了去实现这种词法作用域,JavaScript 函数对象的内部状态不仅包含函数逻辑的代码,除此之外还 包含当前作用域链的引用。函数对象可以通过这个 作用域链 相互关联起来,如此,函数体内部的变量都可以保存在函数的作用域内,这在计算机的文献中被称之为 闭包。所以闭包是指一个函数能够访问其定义时的作用域中的变量,即使在其定义的作用域已经执行完毕后。这意味着闭包可以让函数“记住”它被创建时的环境,即使它在另一个环境被调用,而这种“记住”正是通过作用域链实现

从技术的角度去讲,所有的 JavaScript 函数都是闭包:他们都是对象,他们都有一个关联到他们的作用域链。绝大多数函数在调用的时候使用的作用域链和他们在定义的时候的作用域链是相同的,但是这并不影响闭包。当 调用函数的时候闭包所指向的作用域链定义函数时的作用域链 不是同一个作用域链的时候,闭包 become interesting。这种 interesting 的事情往往发生在这样的情况下: 当一个函数嵌套了另外的一个函数,外部的函数将内部嵌套的这个函数作为对象返回。一大批强大的编程技术都利用了这类嵌套的函数闭包。

在 JavaScript 中,外部函数无法直接访问内部函数的变量,这是由于作用域链的单向性决定的。当函数嵌套时,内部函数可以访问外部函数的变量(包括参数、局部变量和外部函数的内部函数的变量),但外部函数不能访问内部函数的变量。这是因为内部函数的作用域链包含了外部函数的作用域,而外部函数的作用域链中并不包含内部函数的作用域。因此,内部函数可以访问外部函数的变量,但外部函数不能访问内部函数的变量。

尽管外部函数不能直接访问内部函数的变量,但可以通过返回内部函数来创建闭包,从而间接访问这些变量。闭包使得内部函数即使在其外部函数执行完毕后,仍然可以访问外部函数的作用域中的变量。使内部函数返回一个函数形成闭包,通过闭包,外部函数可操作内部变量。这样做的话一是可以读取函数内部的变量,二是可以让这些变量的值始终保存在内存中。

# 闭包的特性

  • 函数嵌套函数。
  • 函数内部可以引用外部的参数和变量。
  • 参数和变量不会被垃圾回收机制回收。

# 闭包的使用场景

  • setTimeout:原生的 setTImeout 传递的第一个函数不能带参数,通过闭包可以实现传参效果。
  • 回调:定义行为,然后关联到用户事件上,代码通常会作为一个回调(事件触发时调用的函数)绑定到事件。
  • 防抖函数、记忆函数等。
  • 私有变量的数据封装。
  • 延长变量的生命周期。
  • 防止全局变量污染。典型应用是模块封装,在各模块规范出现之前,都是用这样的方式防止变量污染全局
  • 在循环中创建闭包,防止取到意外的值。
// util.js
export const debounce = (fn, time) => {
  let info = {
     arr: new Array(10 * 1024 * 1024).fill(1),
     timer: null
  };
  return function (...args) {
    // info 对象被用作闭包的一部分,它将不会被垃圾回收,直到这个闭包不再被引用。
    info.timer && clearTimeout(info.timer);
    info.timer = setTimeout(() => {
      fn.apply(this, args);
    }, time);
  };
};

如果把 info 变量放到 debounce 函数外部,从 A 页面跳转到 B 页面后,该 info 变量所占的内存会被释放掉吗?

let info = {
  arr: new Array(10 * 1024 * 1024).fill(1),
  timer: null
};
export const debounce = (fn, time) => {
  return function (...args) {
    // 引用全局变量
    info.timer && clearTimeout(info.timer);
    info.timer = setTimeout(() => {
      fn.apply(this, args);
    }, time);
  };
};

info 变量所占的内存也不会被释放掉。
由于 info 对象在模块的整个生命周期内都存在,并且随着时间的推移可能会占用大量内存,这可能导致内存泄漏。
此外,如果 debounce 函数被用于不同的上下文或事件,使用同一个 info 对象可能会导致不可预见的副作用,
因为所有的 debounce 调用都会共享同一个 timer。

为了避免潜在的内存问题和副作用,可以考虑将 info 对象的作用域限制在 debounce 函数内部,
这样每次调用 debounce 都会创建一个新的 info 对象。

// vue 页面使用
import { debounce } from './util';
mounted() {
    this.debounceFn = debounce(() => {
      console.log('1');
    }, 1000)

    // this.debounceFn = null
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

引用者不被回收,被引用者就不会被回收,所以要内存溢出也很简单:函数内部声明一个局部变量(包含闭包本身),把它传递给一个不被回收的对象以产生引用即可,如全局变量、模块局部变量、setInterval、给某个不打算删除的 html 元素加个监听事件等,都能引起内存溢出。

# 闭包的优点与缺点

优点

  • 变量长期存储在内存中,实现变量数据共享。
  • 避免全局变量的污染。
  • 把变量存到独立的作用域,作为私有成员存在。

缺点

  • 常驻内存,增加内存使用量。
  • 使用不当会很容易造成内存泄露。
  • 闭包在处理速度和内存消耗方面对脚本性能具有负面影响。

注意:需要明确的是,大量使用闭包,并不会造成内存泄漏。只要闭包使用得当,只要引用闭包的函数被正常销毁,闭包所占的内存都会被 gc 自动回收,并不会造成内存泄漏

垃圾回收机制

# 为什么闭包不会被垃圾回收立即清除

判断一个对象是否会被垃圾回收的标准: 从全局对象 window 开始,顺着引用表能找到的都不是内存垃圾,不会被回收掉。只有那些找不到的对象才是内存垃圾,才会在适当的时机被 gc 回收 -- 引用计数垃圾回收机制。

  • (1)闭包函数保留了对其父函数作用域的引用。这意味着即使父函数已经执行完毕,闭包函数仍然可以访问父函数的变量。由于这种引用关系,闭包所引用的变量不会被垃圾回收器视为无用,因为它们仍然被某个引用指向。
  • (2)JavaScript 的垃圾回收机制通常是基于引用计数或者标记-清除算法。在引用计数中,当一个变量的引用数为零时,意味着没有任何引用指向该变量,因此可以被回收。而在标记-清除算法中,垃圾回收器会定期遍历内存,标记被引用的变量,并清除未被标记的变量。由于闭包函数保持了对某些变量的引用,这些变量不会被标记为可回收。
  • (3)只有当闭包函数的所有引用都被清除(作用域链断裂,无法再访问其外部作用域中的变量),且没有没有任何变量、对象等可以引用该闭包时(没有外部引用),闭包所占用的内存才会被垃圾回。

不只是闭包,只要是仍处于被引用状态的堆内存数据,都不会被垃圾回收清除,根本没必要单独拿出闭包来说一下嘛。闭包所保存的,无非是一些存放在堆上的数据而已。有用就不会被清除,没用自然会清除,GC 对闭包做的,跟对其它内存做的事情没什么两样。

let closure;

function createClosure() {
    var privateVar = "I'm a private variable";
    closure = function() {
        console.log(privateVar);
    };
}

createClosure(); // closure现在引用了一个闭包函数

// 当不再需要闭包时
closure = null; // 显式地移除对闭包的引用
1
2
3
4
5
6
7
8
9
10
11
12
13

闭包导致的内存泄露原因、排查方法和解决手段