主题
执行上下文和执行栈与闭包(Day17 3/3)
本页对应 Day17_Senior,共 5 段视频,约 41 分钟。
学习目标
- 能用自己的话解释闭包与作用域的关系,并从作用域角度分析指定代码为何能访问外层变量。
- 能判断一段代码是否形成闭包,准确指出其中的函数 A、函数 B、被闭包引用的变量及外部引用方式。
- 能说明闭包对垃圾回收和变量生命周期的影响,并区分“延长生命周期”与“内存泄漏”。
- 能说明闭包的缺点及适用边界,判断闭包是否增加内存溢出风险,并避免将其误判为必然内存泄漏。
- 能在选项卡等循环绑定事件场景中,分别用
forEach或立即执行函数改写代码,解决变量共享导致的问题。 - 能分析包含
this、返回值、连续调用等结构的闭包题目,写出输出结果并给出可检验的推导过程。
知识点
为什么要从执行上下文理解闭包
本节笔记标题是“执行上下文和执行栈”,但转写稿主要通过闭包案例反推:函数调用时会创建执行上下文对象,调用结束后出栈;可为什么有些局部变量没有立刻销毁?因为闭包让内部函数继续引用外部函数的变量。转写稿里的“B包”应纠正为“闭包”,“自用域/纵域”应纠正为“作用域”,“出站”应纠正为“出栈”,“执行筛项是对象”应纠正为“执行上下文对象”。先明确目标:执行上下文解释“变量和 this 从哪里来”,执行栈解释“函数调用如何一层层进入、又一层层退出”,闭包则解释“为什么出栈后变量还可能被保留”。
执行上下文、执行栈与作用域的关系
怎么想:函数每次调用都会产生一个执行上下文,里面放局部变量、参数、this 等信息;调用时入栈,调用结束出栈。作用域则看函数声明的位置,不看调用位置。转写稿反复强调:函数 B 能访问上层作用域数据,是因为函数 B 声明在函数 A 内部;哪怕函数 B 在别处被调用,它的上层作用域仍是 A。易错点是把“调用位置”当成“作用域位置”,也容易把“执行上下文出栈”直接等同于“变量立即销毁”。转写稿中 n1、n2 本应随函数 A 的执行上下文出栈而销毁,但因为被内部函数引用,所以继续留在内存中,这叫延长数据生命周期,不叫内存泄漏。
用选项卡案例观察闭包与上下文保留
怎么做:转写稿用选项卡代码调试。事件回调里使用 index,断点停在事件回调函数中,可以看到 index 被闭包保存。按转写稿整理的关键代码形态如下:
js
// 事件回调中使用 index,形成闭包
tableNavItems[i].onclick = function () {
// 使用 index
}转写稿指出,函数 A 是外层回调,函数 B 是事件回调;函数 B 使用了函数 A 的 index,又被作为事件回调交给外部引用,于是构成闭包。用 forEach 时,每个外层回调都有自己的 index,所以各用各的;用普通 for 时,i 是外层变量,点击时可能已经变成循环结束后的值,导致找不到对应内容。易错点是只记“for 循环有闭包问题”,却不会看“变量到底被谁保存”。调试时应先看断点处作用域链,再看变量被哪个函数引用。
手动闭包:IIFE 如何补出独立上下文
如果不用 forEach,转写稿演示了手动闭包:在事件回调外再套一个立即执行函数,把 i 作为实参传进去,形成局部形参。按转写稿整理的关键片段:
js
(function (index) {
// 事件回调里使用 index
})(i)怎么想:外层立即执行函数每循环一次就执行一次,产生独立的执行上下文,每个上下文里都有一个 index;事件回调再引用这个 index,于是每次点击拿到的是各自保存的值。易错点是把内外两个 i/index 混为一谈:外层是循环变量,内层是立即执行函数的局部变量。转写稿还提醒,forEach 本身带回调,天然容易形成闭包,不必刻意“炫技”写 IIFE。
闭包题目的调试推导
转写稿中的题目一:object.getFunc() 先调用对象方法,后面小括号再调用它的返回值。按转写稿整理的关键形态是:
js
object.getFunc()()关键不是 object 调用了最终函数,而是 object.getFunc() 的返回值被直接调用;返回值函数前面没有对象点,所以其中 this 指向 window,输出 window。题目二用 that 缓存外层 this,内部函数没有自己的 that,就沿作用域链找到外层 that,因此输出 my object。这里闭包保存的是 that。易错点是看到 object. 就以为内部 this 一定是 object,要分清“谁调用了哪个函数”。转写稿后面的链式返回对象题较绕,核心仍是:每次调用返回对象,再继续点方法,逐层看参数和上层作用域,不要凭感觉猜。
本节易错点总结
第一,执行上下文出栈不等于变量一定立刻回收,闭包引用会延长生命周期。第二,作用域看声明位置,不看调用位置。第三,闭包不是“神秘语法”,而是内部函数引用外部函数变量的现象。第四,内存溢出和内存泄漏要区分:转写稿说闭包增加内存溢出风险,但数据最终仍可能被销毁,不能随口说成泄漏。第五,调试时先断点看作用域链,再判断变量归属;不要只背结论。
闭包和作用域:先别背概念,从作用域看
为什么需要:网上对闭包的解释角度太多,容易越看越糊涂。转写稿建议先把闭包放回作用域里理解:很多代码单从作用域也能讲通。
怎么想:函数能访问上层作用域的数据;函数的上层作用域由“声明位置”决定,与“调用位置”无关。闭包本质上是对某种作用域现象的描述,便于交流,不是神秘语法。
怎么做:遇到闭包题先找三件事:谁是外层函数A,谁是内层函数B,B有没有引用A里的数据,B有没有被外部引用。
易错点:把“调用位置”当成作用域来源;把闭包当成必须写return才成立。转写稿明确说,事件回调等外部引用也能形成闭包。
闭包和垃圾回收:延长生命周期,但不等于内存泄漏
为什么需要:闭包会让数据常驻内存,需要分清“泄漏”和“延长生命周期”。
怎么想:函数A调用结束后,执行上下文对象出栈,按理局部变量应销毁;但内层函数B被外部引用,还引用着A里的变量,所以这些变量继续留在内存。
怎么做:调试时在事件回调打断点,可以看到外层变量被“闭”住。等外部引用释放、脚本结束等时机,数据仍可能被回收。
易错点:直接说闭包造成内存泄漏不准确。转写稿的表述是:闭包延长数据生命周期,增加内存溢出风险;它没有让垃圾永远无法销毁。
闭包的缺点:知道即可,不必刻意回避
为什么需要:闭包让数据常驻内存,可能增加内存压力。
怎么想:外部引用未销毁前,闭包数据可能一直保留;但现代计算机性能下,不必因此不敢写闭包。
怎么做:理解风险即可;真正做性能优化时再细究。
易错点:把“增加内存溢出风险”夸大成“绝对不能写闭包”。转写稿建议现阶段顺其自然,别刻意反闭包。
选项卡应用:for循环共享变量,forEach/IIFE各存一份
为什么需要:选项卡代码里事件回调使用外层index,这就是闭包应用。
怎么想:用for循环时,若所有事件回调共享同一个i,点击时循环早已结束,i变成tabs.length,找不到对应内容。forEach每次回调都有自己的index,事件回调定义在各自回调里,于是各闭包各的index。
怎么做:关键结构如下(按转写稿整理):
js
// for 循环:所有回调共享 i,点击时 i 已变
for (var i = 0; i < tabs.length; i++) {
tabs[i].onclick = function () {
// 这里若用 i 找内容,容易拿到 tabs.length
};
}
// forEach:每个事件回调各自闭包一个 index
tabs.forEach(function (tab, index) {
tab.onclick = function () {
// 使用 index,各用各的
};
});
// 手动闭包:IIFE 把当次 i 作为实参保存
for (var i = 0; i < tabs.length; i++) {
(function (index) {
tabs[index].onclick = function () {
// 使用 index
};
})(i);
}易错点:以为for循环里写函数就自动保存每次i;实际共享的是同一个变量。IIFE里外同名时,要分清外层实参是全局变量,内层形参才是局部保存值。
题目01:先看调用链,再看this
为什么需要:闭包题常混入this,要先判断谁调用了返回函数。
怎么想:object.getNameFunc()()中,第一个括号由object调用getNameFunc,返回一个函数;第二个括号是直接调用这个返回函数,前面没有对象点,按转写稿的普通脚本环境,this是window,所以输出window。该题返回函数没引用外层变量,转写稿说它“压根没有闭包”。
怎么做:
js
var name = "window";
var object = {
name: "my object",
getNameFunc: function () {
return function () {
return this.name;
};
}
};
console.log(object.getNameFunc()()); // window修改版用that保存外层this,返回函数引用that,形成闭包,输出my object:
js
var object2 = {
name: "my object",
getNameFunc: function () {
var that = this;
return function () {
return that.name;
};
}
};
console.log(object2.getNameFunc()()); // my object易错点:看到object.就以为最终函数也由object调用;实际object调用的是getNameFunc,不是它返回的函数。
题目02:链式调用中逐层展开闭包
为什么需要:复杂链式调用容易乱,要按返回值逐层分析。
怎么想:按转写稿,fun内部又调用fun,且方法返回的仍是可继续点fun的对象;闭包保存上一次的n,下一次调用读取上层作用域里的值。
怎么做:不要一眼猜答案,先写清每次调用传入了什么、返回什么、闭包保存了哪个变量。转写稿推导结果为undefined、0、1、2。
易错点:把链式调用当成一次执行;忽略“返回对象还能继续点方法”;把每次闭包保存的值混成同一个。
课堂画面





代码示例
示例 1:选项卡 —— forEach 自带闭包(转写稿视频3)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>选项卡 - forEach 自带闭包</title>
<style>
.tab-nav { display: flex; list-style: none; padding: 0; }
.tab-nav li { padding: 6px 16px; border: 1px solid #ccc; margin-right: 4px; cursor: pointer; }
.tab-nav li.active { background: #1677ff; color: #fff; }
.tab-content div { display: none; padding: 12px; border: 1px solid #ccc; }
.tab-content div.active { display: block; }
</style>
</head>
<body>
<ul class="tab-nav">
<li class="active">选项一</li>
<li>选项二</li>
<li>选项三</li>
<li>选项四</li>
<li>选项五</li>
</ul>
<div class="tab-content">
<div class="active">内容一</div>
<div>内容二</div>
<div>内容三</div>
<div>内容四</div>
<div>内容五</div>
</div>
<script>
const tabNavItems = document.querySelectorAll('.tab-nav li');
const tabContents = document.querySelectorAll('.tab-content div');
// 函数A:forEach 的回调函数(执行了 5 次,各自产生一个 index)
tabNavItems.forEach(function (item, index) {
// 函数B:事件的回调函数,被外部(事件系统)引用 —— 闭包成立
item.addEventListener('click', function () {
// 排他:先全部取消选中
tabNavItems.forEach(function (li) {
li.classList.remove('active');
});
tabContents.forEach(function (div) {
div.classList.remove('active');
});
// 再让当前项和对应内容选中,这里用到函数A的数据 index
item.classList.add('active');
tabContents[index].classList.add('active');
});
});
</script>
</body>
</html>关键行与易错点
forEach(function (item, index) {...}):回调每执行一次,就产生一个独立的index;5 次迭代产生 5 个互不干扰的index,事件回调各自“分到”一个,这就是闭包。- 闭包三条件对照:函数A 是
forEach的回调;函数B 是click回调且嵌套在函数A 内;函数B 使用了函数A 的局部数据index;函数B 作为事件回调被外部引用。 - 排他必须先“全部移除”再“当前添加”,顺序反了会出现多个选中项。
- 易错点:这里如果换成
for + var i,i是全局变量,点击时早已变成5(tabContents[5]是undefined),会直接报错 —— 这正是示例 2 要解决的问题。
示例 2:选项卡 —— for 循环 + 立即执行函数手动造闭包(转写稿视频3)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>选项卡 - for 循环 + IIFE 手动闭包</title>
<style>
.tab-nav { display: flex; list-style: none; padding: 0; }
.tab-nav li { padding: 6px 16px; border: 1px solid #ccc; margin-right: 4px; cursor: pointer; }
.tab-nav li.active { background: #1677ff; color: #fff; }
.tab-content div { display: none; padding: 12px; border: 1px solid #ccc; }
.tab-content div.active { display: block; }
</style>
</head>
<body>
<ul class="tab-nav">
<li class="active">选项一</li>
<li>选项二</li>
<li>选项三</li>
<li>选项四</li>
<li>选项五</li>
</ul>
<div class="tab-content">
<div class="active">内容一</div>
<div>内容二</div>
<div>内容三</div>
<div>内容四</div>
<div>内容五</div>
</div>
<script>
const tabNavItems = document.querySelectorAll('.tab-nav li');
const tabContents = document.querySelectorAll('.tab-content div');
for (var i = 0; i < tabNavItems.length; i++) {
// 立即执行函数:函数A。每次循环执行一次,产生 5 个执行上下文对象,
// 每个上下文里都有一个自己的形参 index
(function (index) {
// 函数B:事件回调,使用函数A的数据 index,并被事件引用 —— 手动造出来的闭包
tabNavItems[index].addEventListener('click', function () {
tabNavItems.forEach(function (li) {
li.classList.remove('active');
});
tabContents.forEach(function (div) {
div.classList.remove('active');
});
tabNavItems[index].classList.add('active');
tabContents[index].classList.add('active');
});
})(i); // 实参是全局变量 i,形参 index 是 IIFE 内的局部变量
}
</script>
</body>
</html>关键行与易错点
(function (index) { ... })(i):外层实参i是全局变量,内层形参index是立即执行函数的局部变量,二者名字可以不同,正是靠这个“拷贝”把当次的值锁住。- 立即执行函数执行 5 次 → 产生 5 个执行上下文对象 → 每个对象里有一个自己的
index,事件回调一人分一个。 - 易错点一:忘了写外层小括号
(function(){...})会直接语法报错;(function(){...}())也可以,但不要写成function(){}()。 - 易错点二:IIFE 内部不要再用
i,否则又回到全局变量上,问题原样复现。 - 转写稿原话的意思:
forEach是“顺其自然就闭了包”,for + IIFE是“手动加闭包”,两者本质是同一个作用域问题。
示例 3:闭包题目 —— this 与 that(转写稿视频4)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>闭包题目 01</title>
</head>
<body>
<script>
var name = 'The Window';
// ---------- 版本一:返回函数里用 this,没有用到外层变量,不构成闭包 ----------
var object = {
name: 'My Object',
getNameFunc: function () {
return function () {
return this.name; // 这里的 this 由「谁调用」决定
};
}
};
console.log(object.getNameFunc()()); // The Window
// ---------- 版本二:先存 that = this,返回函数里用 that,形成闭包 ----------
var object2 = {
name: 'My Object',
getNameFunc: function () {
var that = this; // 函数A:getNameFunc;that 是它的局部变量
return function () { // 函数B:返回的函数,被外部引用
return that.name; // 用到函数A的数据 that —— 闭包成立
};
}
};
console.log(object2.getNameFunc()()); // My Object
</script>
</body>
</html>关键行与易错点
object.getNameFunc()():第一个括号里,object.调用的是getNameFunc,所以getNameFunc内的this是object;第二个括号是单独调用返回的那个函数,前面没有任何对象,因此this是window,输出The Window。- 版本一根本不构成闭包:返回的函数里只用了
this,没有使用外层函数A 的任何局部变量,属于“障眼法”。 - 版本二
var that = this;把this(此时是object2)保存成函数A 的局部变量,返回的函数里访问that→ 上层作用域查找 →My Object。 - 易错点:
this的指向只看调用位置(object.xxx()还是xxx()),与函数声明位置无关;而变量查找只看函数声明的位置(作用域链),这两条规则别混。
常见错误
- 现象:看到函数嵌套就判定为闭包。原因:忽略“函数B使用函数A局部变量”和“函数B被外部引用”两个条件。修法:先标出函数A、函数B、被闭包变量和外部引用点,再下结论。
- 现象:把
this当作闭包变量分析。原因:this由调用方式决定,不属于词法作用域中的局部变量。修法:先确定调用者,再单独判断真正被闭包的变量。 - 现象:
for循环绑定事件后,所有点击都拿到同一个i。原因:var声明的循环变量没有为每次回调形成独立绑定。修法:优先用let或forEach回调;兼容旧写法时用 IIFE 手动闭包。 - 现象:看到闭包就判定“内存泄漏”。原因:混淆“生命周期延长”和“垃圾无法回收”。修法:判断外部引用是否释放,表述为“增加内存占用风险”,不要直接等同泄漏。
- 现象:分析
obj.getFunc()()时把返回函数当成obj的方法调用。原因:未拆解表达式,误判最终调用者。修法:先拆obj.getFunc(),再分析其返回值由谁调用,最后确定this和输出。 - 现象:题目中说有闭包,但找不到外部引用。原因:只看到
return或嵌套,没检查函数B是否真的被外部持有。修法:逐项核对闭包成立条件,缺一项就不按闭包解释。
练习
- 判断以下结构是否构成闭包,并指出函数A、函数B和被闭包变量:
function A(){ let x=1; return function B(){ console.log(x) } }。 - 选项卡代码改用
for循环绑定点击事件后,为什么所有点击都拿到同一个i?给出两种修复方案并比较。 - 分析
obj.getFunc()()的调用链,说明最终this指向、输出结果,以及该题是否构成闭包。 - 分析连续调用返回对象方法的题目:
obj.fun(1).fun(2)...,写出关键调用链,并说明参数如何影响后续输出。 - 判断“闭包一定导致内存泄漏”是否正确,并说明如何通过引用释放和断点调试验证。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 闭包和作用域以及垃圾回收 | 04:30 |
| 2 | 闭包的缺点 | 02:52 |
| 3 | 闭包应用 | 13:19 |
| 4 | 闭包题目01 | 11:29 |
| 5 | 闭包题目02 | 09:27 |
小结
- 闭包判断先看三条件,不要看到嵌套就下结论。
- 闭包题先拆调用链,再判断调用者、
this和返回值。 this不属于闭包变量,必须与词法作用域分开分析。- 循环绑定问题优先用
let/forEach,IIFE 是兼容性手动方案。 - 闭包延长数据生命周期,但是否泄漏要看引用能否释放。
- 断点调试可直接观察变量是否被闭包保留。
- 工程中不必刻意规避闭包,重点是可读性和必要优化。