# 什么是闭包

闭包是一个组合:

  1. 一个函数;
  2. 以及该函数对外部环境的引用 (outer environment reference)。

# 词法环境

前一节我们有讲 JS 的执行过程:JS 引擎使用函数调用栈 (Call Stack) 的方式执行代码,对不同类型的执行上下文 (Execution Context) 执行入栈出栈操作,执行上下文在创建阶段会生成词法环境,其中包含一个对外部环境的引用:

ExecutionContext = {
  // 词法环境
  LexicalEnvironment: {
    EnvironmentRecord: {},
    // 外部环境引用
    outer: null,
    this: <globalObject>
  }
}
1
2
3
4
5
6
7
8
9

举例:

function fn1 () {
  let a = '111'
  let b = '222'
  function fn2 () {
    console.log(a);
  }
  return fn2;
}

let result = fn1();
result();
1
2
3
4
5
6
7
8
9
10
11

当我们的代码执行完let result = fn1()这一句的时候,fn1函数已经执行完毕,那它所对应的执行上下文就会从函数调用栈中出栈,词法环境也应该从内存中删除。但我们在执行下一行代码fn2()时却依然能打印出 111!这说明fn1的词法环境并没有被完全删除。

JavaScript 使用的是静态作用域,在函数调用栈执行函数时,执行上下文、词法环境也是被顺序创建和销毁的。但是 JS 中的函数也可以作为参数或返回一个函数,此时子函数的销毁时间晚于父函数的,假如父函数销毁了,子函数依然引用了父函数中的变量,怎么办?

闭包就是为了处理这种情况,父函数销毁前,引擎会把子函数引用到的变量(不是全部)保留,打包成 Closure 放到子函数的 [[Scopes]] 属性上,保证即使父函数销毁了,子函数也能访问这部分引用。

# Chrome 控制台打印的 result
# console.dir(result)

ƒ fn2()
  arguments: null
  caller: null
  length: 0
  name: "fn2"
  prototype: {constructor: ƒ}
  [[FunctionLocation]]: VM1110:4
  [[Prototype]]: ƒ ()
  [[Scopes]]: Scopes[3]
    0: Closure (fn1) {a: '111'} # 闭包在这里
    1: Script {func2: ƒ, clo: ƒ, result: ƒ}
    2: Global {window: Window, self: Window, document: document,}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 一个需要注意的点

有如下代码:

function fn1 () {
  const a = 1;
  const b = 2;
  const c = 3;
  function fn2() {
    console.log("xx");
  }
  function fn3() {
    console.log(a);
    console.log(c);
  }
  return fn2;
}
const clo = fn1();
console.dir(clo);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

在 chrome 控制台执行这段代码时,会有下面的情况:

ƒ fn2()
  arguments: null
  caller: null
  length: 0
  name: "fn2"
  prototype: {constructor: ƒ}
  [[FunctionLocation]]: VM1110:4
  [[Prototype]]: ƒ ()
  [[Scopes]]: Scopes[3]
    0: Closure (fn1) {a: 1, c: 3} # 请看这里
    1: Script {func2: ƒ, clo: ƒ, result: ƒ}
    2: Global {window: Window, self: Window, document: document,}
1
2
3
4
5
6
7
8
9
10
11
12

上面代码中,fn3 对 fn1 的变量进行了引用,但我们只 return 了 fn2,为什么 fn2 的 [[Scopes]] 中出现了 Closure 字段?

原因是引擎在销毁父函数的词法环境时,检测到被引用的变量时,直接将相关变量打包并插入到所有子函数的[[Scopes]]中,不对子函数进行区分。ES 规范也并没有对这一点进行强制优化。一部分引擎在优化这一部分时,发现付出与收获并不成正比,所以放弃了优化,直接分发给所有的子函数。

我也把这个问题发到了网上,有兴趣可以去看下讨论:segmentfault (opens new window)stackoverflow (opens new window)

# 出现场景

# 返回一个函数

function create () {
  const a = 1
  return function () {
    console.log(a)
  }
}

const fn = create()
const a = 2
fn()  // 1  外部引用指向的是函数被定义的父环境
1
2
3
4
5
6
7
8
9
10

面试题

编写一个函数 multiply() ,将两个数字相乘:

function multiply(num1, num2) {
  // 把你的代码写在这里。..
}
1
2
3

要求:

如果用 2 个参数调用 multiply(num1,numb2),则应返回这 2 个参数的乘积。

但是如果用 1 个参数调用,则该函数应返回另一个函数: const anotherFunc = multiply(num1) 。返回的函数在调用 anotherFunc(num2) 时执行乘法 num1 * num2

multiply(4, 5); // => 20
multiply(3, 3); // => 9

const double = multiply(2);
double(5);  // => 10
double(11); // => 22
1
2
3
4
5
6

一种实现方式:

function multiply(number1, number2) {
  if (number2 !== undefined) {
    return number1 * number2;
  }
  return function anotherFunc(number2) {
    return number1 * number2;
  };
}

multiply(4, 5); // => 20
multiply(3, 3); // => 9

const double = multiply(2);
double(5);  // => 10
double(11); // => 22
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这段代码中,anotherFunc 和它外部环境引用组合形成闭包。当我们执行double(5)时,按说multiply(2)已经执行完了,词法环境该被删除了,但是我们依然访问到了 multiply 词法环境中的 number1,因为 anotherFunc 对 multiply 的词法环境依然有引用。

# 函数作为参数

function print (fn) {
  const a = 1
  console.dir(fn)
  fn()
}

const aa = 2
function fn1 () {
  console.log(aa)
}
print(fn1) // 2
1
2
3
4
5
6
7
8
9
10
11

# 模拟私有方法(IIFE)

编程语言中,比如 Java,是支持将方法声明为私有的,即它们只能被同一个类中的其它方法所调用。

而 JavaScript 没有这种原生支持,但我们可以使用闭包来模拟私有方法。私有方法不仅仅有利于限制对代码的访问:还提供了管理全局命名空间的强大能力,避免非核心的方法污染全局。

var Counter = (function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  }
})();

var Counter1 = makeCounter();
var Counter2 = makeCounter();
console.log(Counter1.value()); /* logs 0 */
Counter1.increment();
console.log(Counter1.value()); /* logs 2 */
Counter1.decrement();
console.log(Counter1.value()); /* logs 1 */
console.log(Counter2.value()); /* logs 0 */
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

上面的例子中,三个函数incrementdecrementvalue共享一个词法环境,我们在访问这三个函数时并不会影响到这个词法环境内定义的变量 privateCounter 和函数 changeBy。

值得注意的是,两个计数器 Counter1 和 Counter2 所引用的词法环境是独立的。这是因为函数在每次调用时会生产新的执行上下文,即新的词法环境,所以互不干扰。

# 在循环中创建闭包

在 ECMAScript 2015 引入 let 关键字 之前,在循环中有一个常见的闭包创建问题。

var i = 0
for (; i < 3; i++) {
  setTimeout(function log() {
    console.log(i); // i 是在全局环境被定义的
  }, 1000);
}
1
2
3
4
5
6

这段代码中,函数 log 可以访问外部词法环境中的变量i,循环了三次,所以其实是三个 log 函数共享一个外部词法环境。由于 setTimeout 的异步行为,导致 log 函数在执行时 for 循环已经结束,词法环境中的变量i被修改成了 3,所以三个 log 函数打印出了三个 3.

// 解法一,闭包
var i = 0
for (; i < 3; i++) {
  (function () {
    var j = i
    setTimeout(function log() {
      console.dir(log)
      console.log(j); // => ?
    }, 1000);
  })()
}
1
2
3
4
5
6
7
8
9
10
11

我们拿 IIFE 将 setTimeout 包裹起来之后,三个 log 函数共享的是 IIFE 的词法环境。for 循环三次,IIFE 执行三次,会创建三个不同的词法环境,log 函数执行时虽然 IIFE 已经执行完了,但是词法环境没销毁,log 函数依然能访问,所以最后的结果将会是 0,1,2。

这种解法中,console.dir(log) 执行时可以看到 log 函数 [[scopes]] 属性上的 Closure 字样。

// 另一种解法
var i = 0
for (; i < 3; i++) {
  {
    var j = i
    setTimeout(function log() {
      console.dir(log)
      console.log(j); // => ?
    }, 1000);
  }
}
1
2
3
4
5
6
7
8
9
10
11

{} 将 setTimeout 包裹起来之后,for 循环三次会形成三个词法环境,最后的结果将会是 0,1,2。

# 实际应用

# 隐藏数据

function createCache () {
  const data = {}
  return {
    set (key, val) {
      data[key] = val
    }
    get (key) {
      return data[key]
    }
  }
}

// test
const c = createCache()
c.set('a', 1)
console.log(c.get('a'))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# li 标签绑定事件

创建多个 li 标签,点击分别弹出对应的序号。

// i 在全局作用域,点击全部都是 10
let i, a
for(i=0; i < 10; i++) {
  a = document.createElement('a')
  a.innerHTML = `<li>${i}</li><br>`
  a.addEventListener('click', (e) => {
    e.preventDefault
    console.log(i)
  })
  document.body.appendChild(a)
}

// 方案一
let a
// let 写在这个位置,i 在块级作用域
for(let i=0; i < 10; i++) {
  a = document.createElement('a')
  a.innerHTML = `<li>${i}</li><br>`
  a.addEventListener('click', (e) => {
    e.preventDefault
    console.log(i)
  })
  document.body.appendChild(a)
}

// 方案二
let i, a
for(i=0; i < 10; i++) {
  a = document.createElement('a')
  a.innerHTML = `<li>${i}</li><br>`
  // index 定义在块级作用域
  const index = i
  a.addEventListener('click', (e) => {
    e.preventDefault
    console.log(index)
  })
  document.body.appendChild(a)
}
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

# 性能问题

因为闭包可以保存词法环境,所以影响代码的处理速度和增加内存消耗。

# 常见的坑

todo

# 参考

上次更新: 3/29/2022, 12:45:04 PM