Skip to content

DOM 回顾与JavaScript 中的垃圾回收机制(GC)(Day17 2/3) ​

本页对应 Day17_Senior,共 6 段视频,约 1 小时 10 分。

学习目标 ​

  • 能用自己的话描述 JavaScript 标记清除算法的执行过程,并判断给定对象在何时变为不可达对象、何时会被回收。
  • 能说明全局执行上下文对象与函数执行上下文对象的创建时机,列出预处理阶段对变量声明、函数声明、形参、arguments 和 this 的处理结果。
  • 能解释执行栈“先进后出”的特点,并结合普通函数调用与递归调用说明执行上下文对象的入栈、出栈和销毁过程。
  • 能区分作用域与执行上下文对象:说明作用域在函数声明时确定、执行上下文对象在函数调用时创建,并描述二者的联系。
  • 能给出闭包的定义,写出至少三种产生闭包的方式(返回值、全局变量、事件回调),并解释闭包中外部函数变量为何在调用结束后仍被保留。

知识点 ​

先说明转写稿与本节笔记的错位 ​

本节笔记标题是“DOM 回顾”,条目也都是 DOM;但转写稿实际讲的是垃圾回收、执行上下文、执行栈、闭包。两者不是同一主题,不能把闭包示例硬说成 DOM 推导。下面按笔记条目写 DOM 教程;转写稿只借用“断点调试、调用栈”的调试思路。转写稿里的“直行上下文、调用对件、B包、自用域”应纠正为“执行上下文、调用栈、闭包、作用域”。本节笔记未附图片和代码,故不插入图片;下面代码是讲解用最小示例。

1. 节点 ​

为什么需要:DOM 把页面看成一棵树,操作页面本质是操作节点。怎么想:先区分“节点”和“元素节点”,元素只是节点的一种。怎么做:用 nodeType 判断类型(1 元素、3 文本、8 注释、9 文档),用 parentNode、childNodes、children、firstElementChild、nextElementSibling 遍历。易错点:childNodes 会包含空白文本节点,children 只含元素;firstChild 可能拿到换行空白,想要元素应优先 firstElementChild。

2. 获取元素和文档接口 ​

为什么需要:先定位元素,才能读写属性、样式和内容。怎么想:id 唯一用 getElementById;复杂选择器用 querySelector/querySelectorAll;类名、标签名可用 getElementsByClassName/TagName。怎么做:

js
const box = document.getElementById('box');
const firstItem = document.querySelector('.item');
const items = document.querySelectorAll('.item');

易错点:querySelectorAll 返回静态 NodeList,getElementsByClassName 返回动态集合;id 选择器不要写成 getElementById('#box');查不到时 querySelector 返回 null,要先判空。

3. 元素操作 ​

为什么需要:交互最终要落到属性、样式、内容、尺寸位置和滚动。怎么想:属性分 HTML 属性和 DOM 属性;样式优先 classList,读计算样式用 getComputedStyle;内容注意 innerHTML 与 textContent 的安全差异。怎么做:

js
el.setAttribute('data-id', '1');
el.classList.add('active');
el.style.color = 'red';
el.textContent = 'hello';
el.scrollTop = 100;
const rect = el.getBoundingClientRect();

尺寸上,offsetWidth 含边框,clientWidth 不含边框含 padding;位置可用 offsetLeft/offsetTop 或 getBoundingClientRect()。易错点:读样式不能只读 el.style.color,它只反映行内样式;innerHTML 拼用户内容有 XSS 风险;尺寸受盒模型、滚动条影响,调试时要在真实布局下看。

4. HTML DOM:表单与表格 ​

为什么需要:表单和表格有专用接口,比通用查询更直接。怎么想:表单走 form.elements,按 name 或 id 取控件;表格走 rows、cells、tBodies。怎么做:

js
form.addEventListener('submit', e => {
  e.preventDefault();
  console.log(form.elements.username.value);
});
const cell = table.rows[0].cells[1];

易错点:表单提交默认会刷新页面,要 preventDefault();复选框读 checked,下拉框读 value/selectedIndex;table.rows 可能包含 thead/tbody 中的行,别默认只有 tbody。

5. document ​

为什么需要:document 是 DOM 入口,负责创建、查询和承载页面信息。怎么想:页面级信息看 document.documentElement/body/head/title/readyState;动态创建用 createElement/createTextNode,批量插入可用 DocumentFragment。怎么做:

js
const div = document.createElement('div');
document.body.appendChild(div);

易错点:脚本在 head 里直接查 body 可能得到 null,应把脚本放 body 末尾或监听 DOMContentLoaded;document.write 少用,容易覆盖文档。

6. 事件 ​

为什么需要:DOM 回顾最终要能响应点击、输入、提交等交互。怎么想:先绑定监听,再在回调里用事件对象取 target/currentTarget;理解捕获、目标、冒泡三阶段;需要解绑时保留同一函数引用。怎么做:

js
const btn = document.querySelector('#btn');
function onClick(e) {
  console.log(this, e.target, e.currentTarget);
}
btn.addEventListener('click', onClick);
btn.removeEventListener('click', onClick);

常用事件有 click、input、change、submit、keydown、mouseover、load、DOMContentLoaded。事件回调中的 this:普通函数通常指向绑定监听器的元素;箭头函数没有自己的 this,会继承外层。转写稿讲执行上下文时强调 this 与调用者有关,放到事件里可这样理解:浏览器触发回调时决定 this。调试时可在回调第一行打断点,在 Sources 中刷新或触发事件,观察调用栈。易错点:removeEventListener 必须传同一个函数和同样的捕获标志;stopPropagation() 阻止冒泡,preventDefault() 阻止默认行为,两者不要混;事件委托要用 e.target 判断真实触发元素。

为什么需要垃圾回收:从引用计数到标记清除 ​

为什么需要:JS 对象、数组、函数等都占内存,程序不可能手动 free,所以需要 GC 自动找出“再也用不到”的对象并回收。

怎么想:引用计数看“还有没有变量引用我”,但两个对象互相引用时计数都不为 0,即使从 window 已经找不到它们,也会造成内存泄漏。标记清除换思路:不看谁引用谁,而看“从根对象出发能不能到达”。

怎么做:根对象可理解为 window。从 window 开始沿属性、变量、闭包引用向下找,能到达的标记为存活;找不到的标记为垃圾。转写稿中的例子是:window.user01、window.user02 互相通过 children 引用,只要 window.user01 还能到达,二者都保留;执行 window.user01 = null 后,从 window 已找不到它们,互相引用就变成无效引用,会被清除。页面关闭时 window 销毁,程序创建的对象都不可达,也会被回收。

易错点:不要以为“互相引用就一定泄漏”;也不要以为“某个属性断开就立刻一定回收”,GC 与代码执行是并行的,回收时机由引擎决定。全局变量、闭包、事件回调会让对象长期可达,这是更常见的泄漏来源。

标记清除的推导与调试观察 ​

为什么需要:只看定义容易把“可达”理解成“代码里写过”。需要借助执行上下文、执行栈理解变量何时还存在。

怎么想:全局执行上下文对象就是 window,打开页面、执行全局代码前创建;函数每调用一次,就创建一个函数执行上下文对象,放入执行栈,调用结束销毁。局部变量是该执行上下文对象的属性,所以两次调用中的同名局部变量互不影响。

怎么做:可在函数内部打断点,在调试工具的调用栈中观察:全局执行上下文在底部,函数执行上下文在上部;递归时会出现多个函数执行上下文叠在一起,体现“后进先出”。普通顺序调用则进一个、出一个。

js
function f(n) {
  console.log(n);
  if (n >= 1) {
    f(n - 1);
  }
}
f(3);

易错点:断点看到的是调用栈和执行上下文,不是 GC 的标记过程;函数执行上下文对象没有像 window 那样开放给开发者直接读取,但内存中确实存在。作用域在函数声明时确定,是静态规则;执行上下文在调用时创建,是动态的,两者不要混为一谈。

闭包与垃圾回收:为什么有些变量不被回收 ​

为什么需要:闭包常被说得很玄,其实它直接解释了“函数执行完了,为什么外层变量还在”。

怎么想:闭包是有权访问另一个函数作用域变量的函数。产生持久闭包通常要满足:函数嵌套;内层函数访问外层数据;内层函数被外部引用,例如作为返回值、赋给全局变量或作为事件回调。

怎么做:

js
function A() {
  var n1 = 100;
  var n2 = 200;
  function B() {
    console.log(n1 + n2);
  }
  return B;
}
var fn = A();
fn(); // 300

A() 调用结束后,按理其执行上下文应销毁,但 B 被 fn 引用,B 又引用 n1、n2,所以从根出发仍可达,二者保留。调试时在 B 内打断点,可在作用域面板看到 Closure 中保留了外层变量。事件回调写法 document.addEventListener('click', B) 同理,注意不要写成 B(),否则会立即调用。

易错点:只写嵌套但不把内层函数暴露到外部,不一定形成持久闭包;闭包不是“复制变量”,而是保持引用;不再需要时可将 fn = null,或移除事件监听,避免长期占用内存。

小结:把 GC 放回执行过程里理解 ​

标记清除的核心是“从根可达则存活,不可达则回收”;执行上下文和执行栈帮助我们理解变量在何时属于谁;闭包则说明为什么外层函数结束后,某些变量仍然可达。把这三件事连起来,就能解释大多数“为什么这个对象没被回收”的问题。

执行上下文对象:变量到底挂在哪里 ​

为什么需要:全局变量是 window 的属性,这点容易理解;但局部变量不能是“函数对象本身”的属性。因为函数对象只有一个,而函数可以调用很多次,如果局部变量挂在函数对象上,多次调用就会互相覆盖。
怎么想:每次函数调用都应该有一块独立空间,用来存放本次调用的参数、局部变量、this 等信息。这块空间就是“函数执行上下文对象”。
怎么做:全局代码有全局执行上下文对象,就是 window;函数代码有函数执行上下文对象。局部变量是该执行上下文对象的属性。这个对象真实存在,但 JS 没给开发者直接读取它的接口。
易错点:不要把“函数对象”和“函数执行上下文对象”混为一谈;前者是函数本身,后者是每次调用时创建的一次性环境。

全局执行上下文对象 ​

为什么需要:JS 代码正式执行前,要先准备好变量声明、函数声明和 this,否则执行到使用时才处理就来不及。
怎么想:打开页面、执行全局 JS 代码之前,先创建 window,它就是全局执行上下文对象,然后做预处理。
怎么做:预处理时,找到 var 变量声明,给 window 添加属性但不赋值;找到 function 函数声明,添加属性并赋值为函数;最后把 this 赋值为全局执行上下文对象,也就是 window。页面关闭时,全局执行上下文对象销毁。
易错点:变量提升只“添加属性、不赋值”;函数声明提升是带函数值的;全局下 this 就是 window。

函数执行上下文对象 ​

为什么需要:函数每调用一次,都要有独立环境,否则递归、嵌套调用时数据会乱。
怎么想:调用函数时、函数体代码执行之前,创建一个只属于本次调用的执行上下文对象;调用结束就销毁。每调用一次创建一个,彼此独立。
怎么做:预处理时,形参作为属性并赋值;arguments 作为属性,值是参数集合;函数内 var 声明添加属性但不赋值;函数内 function 声明添加属性并赋值为函数;this 赋值为调用该函数的对象。
易错点:this 不是执行上下文对象本身;函数每调用一次就创建一个新上下文,不是复用同一个。

执行栈与断点调试 ​

为什么需要:程序可能有全局代码、函数调用、递归调用,必须知道当前执行到哪一个上下文。
怎么想:执行上下文对象创建后放入执行栈,栈是“先进后出”。全局上下文一直在栈底,直到页面关闭;函数调用结束就出栈销毁。普通顺序调用是“进一个、出一个”;递归才会出现一个没结束又压入下一个。
怎么做:可在浏览器源代码面板打断点,刷新后看“调用栈”。匿名项通常是全局执行上下文,函数名项是函数执行上下文。按转写稿整理的递归示意代码:

js
function func(n) {
  console.log(n);
  if (n >= 1) {
    func(n - 1);
  }
  console.log(n);
}
func(3); // 输出 32100123

调试时会依次看到 n=3、2、1、0 的上下文压栈,再依次出栈。
易错点:调用栈里的行号表示“当前执行到哪一行”,不是“在哪一行创建”;所谓进栈可理解为创建,出栈可理解为销毁。

作用域与执行上下文的关系 ​

为什么需要:很多初学者把作用域和执行上下文混在一起,导致解释不了同名变量为什么互不影响。
怎么想:作用域是静态规则,函数声明时就确定了;执行上下文是动态对象,函数调用时才创建。两个函数里的同名变量,作用域描述可能一样,但分属不同执行上下文,所以不是一个变量。
怎么做:描述关系时记住:执行上下文从属于所在作用域;全局执行上下文对应全局作用域,函数执行上下文对应所在函数作用域。
易错点:作用域不是执行上下文对象;同名变量作用域相同,不代表变量相同。

闭包是执行上下文留下的现象 ​

为什么需要:函数 A 调用结束后,如果内部数据还被外部引用,就不能立即销毁。
怎么想:函数 B 嵌套在 A 中,B 访问 A 的数据,再把 B 对外引用,就形成闭包现象。常见方式:作为返回值、赋给全局变量、作为事件回调。
怎么做:按转写稿整理的示意代码:

js
function A() {
  var number01 = 100;
  var number02 = 200;
  function B() {
    console.log(number01 + number02);
  }
  return B;
}
var fn = A();
fn(); // 300

调试时在 B 中可看到 Closure,里面保存着 number01、number02。
易错点:只嵌套、只访问但不把 B 对外引用,不能形成可用的闭包;return B 不要写成 return B();闭包不是执行上下文对象本身,而是内部数据被外部函数继续引用的现象。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

1. 标记清除:循环引用与断链 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>标记清除示例</title>
</head>
<body>
  <script>
    // var 声明在全局,会成为 window 的属性
    var user01 = { name: '小乐' };
    var user02 = { name: '老乐' };

    // 互相引用:引用计数算法下容易造成内存泄漏
    user01.child = user02;
    user02.child = user01;

    console.log('可从 window 到达:', window.user01, window.user02);

    // 断开从 window 出发的可达路径
    window.user01 = null;
    window.user02 = null;

    console.log('断链后:', window.user01, window.user02);
  </script>
</body>
</html>

要点解释:

  • var user01、var user02 在全局声明后,可以通过 window.user01、window.user02 访问。
  • user01.child = user02、user02.child = user01 形成循环引用。
  • 标记清除算法从 window 等根对象出发标记可达对象;window.user01 = null、window.user02 = null 后,两个对象从根上不可达,即使互相引用也可被回收。
  • 易错点:垃圾回收是自动的,回收时机不确定,不能通过 console.log 断言对象“立刻被回收”。

2. 执行栈:递归函数的先进后出 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>执行栈与递归</title>
</head>
<body>
  <script>
    function func(n) {
      console.log(n);

      if (n >= 1) {
        func(n - 1);
      }

      console.log(n);
    }

    func(3);
  </script>
</body>
</html>

要点解释:

  • 每次调用 func 都会创建该函数的执行上下文对象,并放入执行栈。
  • func(3) 递归过程中依次输出 3、2、1、0;n = 0 时不再递归,但仍会执行后面的 console.log(n)。
  • 递归返回时按“后进先出”依次输出 0、1、2、3,最终控制台结果为 3 2 1 0 0 1 2 3。
  • 易错点:条件写成 n >= 1 会多调用一次 func(0);如果写成 n > 0,就不会出现 0 这一层。

3. 闭包:函数 A 中嵌套函数 B,并让 B 对外可用 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>产生闭包的三种方式</title>
</head>
<body>
  <button id="btn">点我触发闭包</button>

  <script>
    // 方式一:将函数 B 作为返回值
    function A() {
      var number01 = 100;
      var number02 = 200;

      function B() {
        console.log('方式一:', number01 + number02);
      }

      return B; // 注意:不加小括号
    }

    var fn = A();
    fn(); // 300

    // 方式二:将函数 B 赋值给全局对象属性
    function A2() {
      var number01 = 100;
      var number02 = 200;

      function B2() {
        console.log('方式二:', number01 + number02);
      }

      window.func = B2; // 注意:不加小括号
    }

    A2();
    window.func(); // 300

    // 方式三:将函数 B 作为事件回调
    function A3() {
      var number01 = 100;
      var number02 = 200;

      function B3() {
        console.log('方式三:', number01 + number02);
      }

      document.getElementById('btn').addEventListener('click', B3);
    }

    A3();
  </script>
</body>
</html>

要点解释:

  • 函数 B 嵌套在函数 A 内部,并且访问了 A 中的 number01、number02,满足闭包的基本条件。
  • 必须让 B 从 A 外部可用:可以 return B、赋值给 window.func,或作为事件回调。
  • return B 不能写成 return B();window.func = B 不能写成 window.func = B();事件回调传 B 也不能传 B(),否则会立即执行。
  • A 调用结束后,由于 B 仍引用 number01、number02,这两个数据不会立即销毁,从而形成闭包。
  • 易错点:只在 A 内部调用 B,但没有把 B 对外引用,通常不能形成可观察的闭包效果。

常见错误 ​

  • 现象:用“互相引用”直接判定内存泄漏。→ 原因:忽略标记清除按根可达性判断,循环引用未必不可回收。→ 修法:从 window/根出发画可达路径,确认断链后是否不可达,再用断点或描述验证。
  • 现象:认为局部变量属于函数对象,多次调用共享同一个 n。→ 原因:混淆函数对象与执行上下文对象。→ 修法:明确每次调用创建独立执行上下文对象;用两次调用断点对比局部变量与 this。
  • 现象:试图在函数外通过 window 读取函数执行上下文对象。→ 原因:误以为所有执行上下文对象都像全局上下文对象一样可访问。→ 修法:函数执行上下文对象不可直接读取;需要对外数据就显式返回、挂到外部对象或用事件回调暴露。
  • 现象:认为顺序调用完的函数执行上下文仍留在调用栈里。→ 原因:把“创建过”误当成“仍在栈中”。→ 修法:普通调用结束即出栈/销毁;只有未结束调用(如递归)才堆叠,用调用栈断点观察栈顶变化。
  • 现象:把作用域当成调用时才确定,或认为两个函数里的同名变量是同一个。→ 原因:混淆静态作用域与动态执行上下文。→ 修法:作用域看函数声明位置;执行上下文看调用次数;同名变量按所属上下文隔离。
  • 现象:只要函数嵌套就认为闭包已完整形成。→ 原因:漏掉“内部函数访问外层变量并被外部引用”的闭环。→ 修法:检查嵌套、访问外层变量、对外暴露三条件;用返回值、全局属性、事件回调三种方式逐一验证。

练习 ​

  1. 给出一段含循环引用的代码,画出从 window 出发的可达路径,标出哪条语句使对象变为不可达,并说明页面关闭时的结果。验收:能指出断链语句和回收时机。
  2. 写一个含形参、arguments、var、function 声明的函数并调用两次,列出每次执行上下文对象上的属性及 this。验收:能说明两次调用互不影响。
  3. 对递归函数 func(3) 写出输出顺序,并画出执行栈进出顺序(含全局上下文),标出哪次调用先结束。验收:输出与栈顶变化一致。
  4. 用返回值、全局属性、事件回调三种方式让内部函数从外部可调用;再写一个“仅在内部调用”的反例,说明为何不形成闭包闭环。验收:三种方式均可触发,并能指出被保留的变量。
  5. 给出 func1、func2 中同名局部变量 n,判断二者是否同一变量、作用域描述是否相同、生命周期是否相同。验收:能区分静态作用域与动态执行上下文。

原视频对照 ​

#视频时长
1垃圾回收-标记清除算法07:46
2执行上下文对象14:22
3执行栈22:48
4执行上下文对象和作用域05:46
5什么是闭包03:53
6如何产生闭包16:14

小结 ​

  • 垃圾回收看“从根是否可达”,循环引用不是泄漏的充分条件;标记清除不保证即时回收。
  • 全局执行上下文对象就是 window;函数执行上下文对象每次调用创建一次,且不能直接读取。
  • 预处理发生在代码执行前:var 提升为属性不赋值,函数声明提升并赋值;函数内还要处理形参、arguments、局部声明与 this。
  • 执行栈先进后出;顺序调用通常进一个出一个,递归会堆叠;全局上下文压底到页面关闭。
  • 作用域是静态规则,函数声明时确定;执行上下文是动态的,调用时创建、结束销毁。
  • 闭包是内部函数访问外层作用域变量并被外部引用后,外层变量不随外层调用结束立即销毁的现象。
  • 断点调试适合观察调用栈与闭包,但应针对复杂逻辑使用,不要代替代码设计。