一、引言
你是否遇到过这样的困惑:为什么一个函数内部可以“越过自己的领地”,访问到外部的变量?而当一个函数执行完毕后,本该被销毁的变量,有时却依然“活着”,甚至能被另一个函数使用?这两个看似矛盾的现象,背后正是
JavaScript作用域链与闭包在起作用。本文将带大家从原理到代码实践,彻底掌握这对既熟悉又陌生的核心概念。
二、作用域链
作用域链的定义
首先,让云宝来给大家简单解释一下究竟什么是作用域链:
每一个执行上下文的变量环境中都存在一个outer指针,用来指向外部的执行上下文,当V8在查找一个变量时,在当前执行上下文中没有找到,就会顺着outer所指向的执行上下文查找,以此类推,直到找到全局为止,我们把这个查找的链条叫做作用域链。
作用域链的“演示”
举个“栗子”:
let global = "全局";
function outer() {
let outerVar = "外";
function inner() {
let innerVar = "内";
console.log(innerVar, outerVar, global);-------------输出结果:内 外 全局
}
inner();
}
outer();
其实function属于引用变量,应该存放在堆里,调用栈中存放其地址,这里云宝偷个懒~
三、闭包
闭包的定义
很多小伙伴在这里就会有疑惑了,究竟什么是闭包呢?闭包到底是怎么产生的呢?我来给大家简单解释一下:
- 当一个函数执行完毕之后,他的执行上下文会被销毁
- 根据作用域的查找规则,内部函数一定可以访问外部函数中的变量,当一个外部函数中的内部函数被拿到外部函数之外来执行,哪怕外部函数执行完毕,被内部函数引用的那部分变量依然需要被保留,我们把这部分变量的集合称之为闭包
就比如下面这种情况:
function foo(){
var myName = '云宝'
var age = 18
function bar(){
console.log(myName)
}
return bar
}
var baz = foo()
baz()
执行过程分解
- 全局预编译与执行
-
全局预编译:找到
foo函数声明,提升;找到baz变量声明(var baz),初始为undefined。 -
执行全局代码:
var baz = foo():调用foo函数。baz():调用baz(即foo返回的bar函数)。
- 调用
foo()
-
创建
foo的执行上下文,压入调用栈。 -
在
foo的预编译阶段:- 创建 AO(Activation Object):
AO = {} - 找形参(无)、变量声明:
myName、age→AO.myName = undefined,AO.age = undefined - 找函数声明:
bar→AO.bar = function bar() {...}
- 创建 AO(Activation Object):
-
执行
foo的函数体:var myName = '云宝'→AO.myName = '云宝'var age = 18→AO.age = 18return bar→ 返回bar函数的引用(即AO.bar)。
-
foo执行完毕,按理说其执行上下文应从调用栈弹出并销毁。但因为返回的bar函数使用了foo内部的变量myName,V8 会把bar引用的这部分变量(myName以及可能的age)保留在堆中,形成一个 闭包(Closure) 。 -
foo的执行上下文被销毁,但闭包对象依然存在,其中包含myName: 'zls'(因为bar用到了myName,age未被引用,可以被回收删除)。
- 执行
baz()(即bar函数)
- 此时便可以在闭包中访问到理应被清除的变量
闭包的优缺点
优点:定义私有模块,防止全局变量被污染
这样我们就可以在多人共同编写大型程序时,将变量封装在闭包内,避免全局变量过多堆成一坨
缺点:内存泄漏
闭包会一直保留被引用的变量,导致这些变量无法被正常垃圾回收。 如果滥用闭包,尤其在大数据量或高频创建的场景下,会造成内存膨胀。
四、经典面试题与总结
现在让我们活学活用,来看下面这道经典面试题目
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,这是因为:
var i没有块级作用域
循环中var i属于全局(或函数作用域),整个循环共用同一个i。- 每次循环
arr.push(function() { console.log(i); })只是把函数放进数组,函数内部引用的i是同一个变量。- 循环结束后
i的值是 6
当i <= 5不成立时,i最后一次自增变成了6。- 调用函数时读取
i
所有函数执行时,取到的都是已经变成6的i,所以打印五次6。
那么如何将其输出1,2,3,4,5呢? 很显然便是用闭包来做到, 我们便可以利用好js作用域的查找规则,我们将代码改为:
var arr = []
for (var i = 1; i <= 5; i++) {
function fn(j){
arr.push(function() {
console.log(j);
})
}
fn(i)
}
for (let n = 0; n < arr.length; n++) {
arr[n]()
}
这样,fn每次调用时都会创建一个新的闭包,因为调用时fn已经编译完毕,其函数调用变量AO已经销毁,因此最后打印时都会读取到各自的闭包内的数值
最后小结一下!
- 作用域链是变量查找的机制,闭包是这种机制带来的特殊现象。
- 闭包既有优点(封装、模块化),也有缺点(内存泄漏风险)。
- 理解闭包的核心在于:函数定义时的词法作用域 + 函数被外部引用后导致变量存活。
- 我们要合理使用闭包,并在不需要时手动解除引用,写出更高效、更健壮的 JavaScript 代码。
学习结束!