主题
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(); // 300A() 调用结束后,按理其执行上下文应销毁,但 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. 标记清除:循环引用与断链
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读取函数执行上下文对象。→ 原因:误以为所有执行上下文对象都像全局上下文对象一样可访问。→ 修法:函数执行上下文对象不可直接读取;需要对外数据就显式返回、挂到外部对象或用事件回调暴露。 - 现象:认为顺序调用完的函数执行上下文仍留在调用栈里。→ 原因:把“创建过”误当成“仍在栈中”。→ 修法:普通调用结束即出栈/销毁;只有未结束调用(如递归)才堆叠,用调用栈断点观察栈顶变化。
- 现象:把作用域当成调用时才确定,或认为两个函数里的同名变量是同一个。→ 原因:混淆静态作用域与动态执行上下文。→ 修法:作用域看函数声明位置;执行上下文看调用次数;同名变量按所属上下文隔离。
- 现象:只要函数嵌套就认为闭包已完整形成。→ 原因:漏掉“内部函数访问外层变量并被外部引用”的闭环。→ 修法:检查嵌套、访问外层变量、对外暴露三条件;用返回值、全局属性、事件回调三种方式逐一验证。
练习
- 给出一段含循环引用的代码,画出从
window出发的可达路径,标出哪条语句使对象变为不可达,并说明页面关闭时的结果。验收:能指出断链语句和回收时机。 - 写一个含形参、
arguments、var、function声明的函数并调用两次,列出每次执行上下文对象上的属性及this。验收:能说明两次调用互不影响。 - 对递归函数
func(3)写出输出顺序,并画出执行栈进出顺序(含全局上下文),标出哪次调用先结束。验收:输出与栈顶变化一致。 - 用返回值、全局属性、事件回调三种方式让内部函数从外部可调用;再写一个“仅在内部调用”的反例,说明为何不形成闭包闭环。验收:三种方式均可触发,并能指出被保留的变量。
- 给出
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。 - 执行栈先进后出;顺序调用通常进一个出一个,递归会堆叠;全局上下文压底到页面关闭。
- 作用域是静态规则,函数声明时确定;执行上下文是动态的,调用时创建、结束销毁。
- 闭包是内部函数访问外层作用域变量并被外部引用后,外层变量不随外层调用结束立即销毁的现象。
- 断点调试适合观察调用栈与闭包,但应针对复杂逻辑使用,不要代替代码设计。