Skip to content

执行上下文和执行栈与闭包(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

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

示例 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()),与函数声明位置无关;而变量查找只看函数声明的位置(作用域链),这两条规则别混。

常见错误 ​

  1. 现象:看到函数嵌套就判定为闭包。原因:忽略“函数B使用函数A局部变量”和“函数B被外部引用”两个条件。修法:先标出函数A、函数B、被闭包变量和外部引用点,再下结论。
  2. 现象:把 this 当作闭包变量分析。原因:this 由调用方式决定,不属于词法作用域中的局部变量。修法:先确定调用者,再单独判断真正被闭包的变量。
  3. 现象:for 循环绑定事件后,所有点击都拿到同一个 i。原因:var 声明的循环变量没有为每次回调形成独立绑定。修法:优先用 let 或 forEach 回调;兼容旧写法时用 IIFE 手动闭包。
  4. 现象:看到闭包就判定“内存泄漏”。原因:混淆“生命周期延长”和“垃圾无法回收”。修法:判断外部引用是否释放,表述为“增加内存占用风险”,不要直接等同泄漏。
  5. 现象:分析 obj.getFunc()() 时把返回函数当成 obj 的方法调用。原因:未拆解表达式,误判最终调用者。修法:先拆 obj.getFunc(),再分析其返回值由谁调用,最后确定 this 和输出。
  6. 现象:题目中说有闭包,但找不到外部引用。原因:只看到 return 或嵌套,没检查函数B是否真的被外部持有。修法:逐项核对闭包成立条件,缺一项就不按闭包解释。

练习 ​

  1. 判断以下结构是否构成闭包,并指出函数A、函数B和被闭包变量:function A(){ let x=1; return function B(){ console.log(x) } }。
  2. 选项卡代码改用 for 循环绑定点击事件后,为什么所有点击都拿到同一个 i?给出两种修复方案并比较。
  3. 分析 obj.getFunc()() 的调用链,说明最终 this 指向、输出结果,以及该题是否构成闭包。
  4. 分析连续调用返回对象方法的题目:obj.fun(1).fun(2)...,写出关键调用链,并说明参数如何影响后续输出。
  5. 判断“闭包一定导致内存泄漏”是否正确,并说明如何通过引用释放和断点调试验证。

原视频对照 ​

#视频时长
1闭包和作用域以及垃圾回收04:30
2闭包的缺点02:52
3闭包应用13:19
4闭包题目0111:29
5闭包题目0209:27

小结 ​

  1. 闭包判断先看三条件,不要看到嵌套就下结论。
  2. 闭包题先拆调用链,再判断调用者、this 和返回值。
  3. this 不属于闭包变量,必须与词法作用域分开分析。
  4. 循环绑定问题优先用 let/forEach,IIFE 是兼容性手动方案。
  5. 闭包延长数据生命周期,但是否泄漏要看引用能否释放。
  6. 断点调试可直接观察变量是否被闭包保留。
  7. 工程中不必刻意规避闭包,重点是可读性和必要优化。