引言
闭包这个东西,刚学的时候像“玄学”,会了以后发现它其实很朴素:
函数记住了它出生时能访问的变量。
哪怕它离家出走,被拿到别的地方执行,它也没有“忘本”。
本文会从作用域链讲起,一步一步拆开闭包的形成过程、常见用途、经典坑点以及面试题。读完之后,你再看到闭包,就不会只会说一句:“内部函数访问外部函数变量。”
当然,这句话没错,只是像说“做饭就是把菜弄熟”一样,正确但不够香。
1. 作用域链:变量查找的路线图
1.1 什么是作用域链
每一个函数在创建时,都会确定自己能访问哪些变量。
当函数执行时,如果要查找一个变量,JavaScript 引擎会按照下面的顺序查找:
- 先在当前作用域找。
- 当前作用域找不到,就去外层作用域找。
- 继续往外找,直到全局作用域。
- 全局还找不到,就报错。
这个一层一层向外查找变量的链条,就叫做 作用域链。
每一个执行上下文的变量环境中都存在一个 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 闭包产生的三个条件
通常闭包形成需要满足这几个条件:
- 函数内部定义了另一个函数。
- 内部函数访问了外部函数的变量。
- 内部函数被外部作用域引用,比如被返回、赋值、作为参数传递。
最经典的写法如下:
function foo() {
var myName = '猪猪侠'
function bar() {
console.log(myName)
}
return bar
}
var baz = foo()
baz() // 猪猪侠
2.2.1 这段代码发生了什么
执行过程可以拆成这样:
- 调用
foo()。 foo内部创建变量myName。foo内部创建函数bar。bar访问了foo里的myName。foo把bar返回给外部变量baz。foo执行完毕,按理说foo的执行上下文应该销毁。- 但是
bar还在外部被引用,并且bar还要访问myName。 - 所以
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 可以
let 在 for 循环中会为每一次循环创建一个新的块级作用域。
每个函数保存的都是当次循环自己的 i。
也就是说:
第 1 个函数 -> 保存 i = 1
第 2 个函数 -> 保存 i = 2
第 3 个函数 -> 保存 i = 3
第 4 个函数 -> 保存 i = 4
第 5 个函数 -> 保存 i = 5
所以最终能正常输出 1 2 3 4 5。
6. 最后总结
闭包的核心可以记住这几句话:
- 变量查找会沿着作用域链向外查找。
- 内部函数访问外部函数变量,并且内部函数在外部继续使用时,会形成明显的闭包。
- 闭包可以保存变量状态,也可以实现私有变量。
- 闭包会延长变量生命周期,使用不当会造成额外内存占用。