一文吃透 JavaScript 闭包:从作用域链到经典面试题,安排得明明白白

0 阅读6分钟

引言

闭包这个东西,刚学的时候像“玄学”,会了以后发现它其实很朴素:
函数记住了它出生时能访问的变量。
哪怕它离家出走,被拿到别的地方执行,它也没有“忘本”。

  本文会从作用域链讲起,一步一步拆开闭包的形成过程、常见用途、经典坑点以及面试题。读完之后,你再看到闭包,就不会只会说一句:“内部函数访问外部函数变量。”
当然,这句话没错,只是像说“做饭就是把菜弄熟”一样,正确但不够香。


1. 作用域链:变量查找的路线图

 1.1 什么是作用域链

  每一个函数在创建时,都会确定自己能访问哪些变量。
当函数执行时,如果要查找一个变量,JavaScript 引擎会按照下面的顺序查找:

  1. 先在当前作用域找。
  2. 当前作用域找不到,就去外层作用域找。
  3. 继续往外找,直到全局作用域。
  4. 全局还找不到,就报错。

这个一层一层向外查找变量的链条,就叫做 作用域链

每一个执行上下文的变量环境中都存在一个 outer 指针,用来指向外部的执行上下文。

换成人话就是:
当前作用域找不到变量时,它知道下一站去哪里找。

 1.2 作用域链示例

let count = 1
function main() {
  let count = 2
  function bar() {
    let count = 3
    foo()
  }
  bar()
}
function foo() {
  console.log(count)
}
main()

输出结果:

1

 1.2.1 为什么输出 1,不是 2 或 3

关键点来了:

  JavaScript 是词法作用域,也叫静态作用域。函数的作用域在“定义时”就确定了,不是在“调用时”确定。

foo 是在全局作用域定义的,所以它的外层作用域就是全局作用域。

虽然 foo() 是在 bar 里面被调用的,但这不影响它的作用域链。

所以 foo 查找 count 的路线是:

foo 函数内部 -> 全局作用域

而不是:

foo 函数内部 -> bar -> main -> 全局作用域

因此最终找到的是全局的:

let count = 1

这就是闭包前必须先理解的底层规则:
函数在哪里定义,作用域链就从哪里开始。


2. 闭包是什么

 2.1 官方一点的解释

  闭包是指:一个函数能够访问并记住它词法作用域中的变量,即使这个函数在其词法作用域之外执行。

通俗一点来讲闭包就是:

函数带着它能访问的外部变量,一起打包离开了原来的作用域。

 2.2 闭包产生的三个条件

通常闭包形成需要满足这几个条件:

  1. 函数内部定义了另一个函数。
  2. 内部函数访问了外部函数的变量。
  3. 内部函数被外部作用域引用,比如被返回、赋值、作为参数传递。

最经典的写法如下:

function foo() {
  var myName = '猪猪侠'
  function bar() {
    console.log(myName)
  }
  return bar
}

var baz = foo()
baz() // 猪猪侠

  2.2.1 这段代码发生了什么

执行过程可以拆成这样:

  1. 调用 foo()
  2. foo 内部创建变量 myName
  3. foo 内部创建函数 bar
  4. bar 访问了 foo 里的 myName
  5. foobar 返回给外部变量 baz
  6. foo 执行完毕,按理说 foo 的执行上下文应该销毁。
  7. 但是 bar 还在外部被引用,并且 bar 还要访问 myName
  8. 所以 myName 不能被销毁,它会被保留下来。

这个被保留下来的变量集合,就是闭包保存的内容。


3. 闭包的第一个经典用途:保存变量

 3.1 普通函数的问题

先看一个普通函数:

function add() {
  let count = 0
  count++
  return count
}

console.log(add()) // 1
console.log(add()) // 1
console.log(add()) // 1

  每次调用 add,都会重新创建一个新的 count,所以永远输出 1
这就像你每次数钱之前,都先把钱包清空,当然永远富不起来。

  使用闭包保存变量:

function add() {
  let count = 0
  return function() {
    count++
    return count
  }
}
var counter = add()

console.log(counter()) // 1
console.log(counter()) // 2
console.log(counter()) // 3

那为什么 count 没有被销毁

因为返回出去的匿名函数还在使用 count

return function() {
  count++
  return count
}

  只要 counter 还存在,这个匿名函数就还存在。匿名函数还存在,它引用的 count 就不能被垃圾回收。所以 count 被保存了下来,每次调用都会基于上一次的结果继续累加。

这就是闭包最常见的能力:
让函数执行完以后,某些变量还能继续活着。


4. 闭包的第二个经典用途:数据私有化

 4.1 为什么需要私有变量

先看一个普通函数:

function add() {
  let count = 0
  count++
  return count
}

console.log(add()) // 1
console.log(add()) // 1
console.log(add()) // 1

  每次调用 add,都会重新创建一个新的 count,所以永远输出 1
这就像你每次数钱之前,都先把钱包清空,当然永远富不起来。

使用闭包保存变量:

function add() {
  let count = 0

  return function() {
    count++
    return count
  }
}

console.log(add()) // 1
console.log(add()) // 2
console.log(add()) // 3

  4.2.1 为什么 count 没有被销毁

因为返回出去的匿名函数还在使用 count

return function() {
  count++
  return count
}

  只要 counter 还存在,这个匿名函数就还存在。匿名函数还存在,它引用的 count 就不能被垃圾回收。所以 count 被保存了下来,每次调用都会基于上一次的结果继续累加。

5. 经典面试题:for 循环里的闭包

 5.1 var 版本

var arr = []
for (var i = 1; i <= 5; i++) {
  arr.push(function() {
    console.log(i)
  })
}

for (let n = 0; n < arr.length; n++) {
  arr[n]()
}

输出结果:

6
6
6
6
6

  5.1.1 为什么都是 6

因为 var 没有块级作用域,循环里的 5 个函数引用的是同一个 i。循环结束后,i 已经变成了 6。当后面执行这些函数时,它们打印的都是同一个 i,所以全是 6。 可以理解成 5 个函数异口同声:

我们不关心你当年是多少,我们只看你现在是多少。

而现在的 i 就是 6

 5.2 解决方案一:使用 let

var arr = []

for (let i = 1; i <= 5; i++) {
  arr.push(function() {
    console.log(i)
  })
}

for (let n = 0; n < arr.length; n++) {
  arr[n]()
}

输出结果:

1
2
3
4
5

  5.2.1 为什么 let 可以

letfor 循环中会为每一次循环创建一个新的块级作用域。
每个函数保存的都是当次循环自己的 i

也就是说:

第 1 个函数 -> 保存 i = 1
第 2 个函数 -> 保存 i = 2
第 3 个函数 -> 保存 i = 3
第 4 个函数 -> 保存 i = 4
第 5 个函数 -> 保存 i = 5

所以最终能正常输出 1 2 3 4 5

6. 最后总结

闭包的核心可以记住这几句话:

  1. 变量查找会沿着作用域链向外查找。
  2. 内部函数访问外部函数变量,并且内部函数在外部继续使用时,会形成明显的闭包。
  3. 闭包可以保存变量状态,也可以实现私有变量。
  4. 闭包会延长变量生命周期,使用不当会造成额外内存占用。