主题
对象高级与单线程和事件轮询机制(Day18 2/3)
本页对应 Day18_Senior,共 5 段视频,约 1 小时 48 分。
学习目标
- 能够使用构造函数和原型手动建立子类与父类的继承关系,并说明子类实例原型、父类实例及
constructor属性的设置依据。 - 能够通过代码实现子类继承父类属性与方法,并验证子类实例可调用父类原型上的方法。
- 能够使用
call在子类构造函数中借用父类构造函数初始化实例属性,并准确解释其中this的指向及执行结果。 - 能够根据给定继承代码绘制完整原型链图例,标注实例属性、原型方法、
constructor指向及各层原型关系。 - 能够说明进程与线程的区别、内存资源分配单位及线程共享关系,并据此解释 JavaScript 单线程运行模型的基本前提。
知识点
回顾定位:先厘清本节边界
本节笔记是“1 回顾”,核心只有两块:垃圾回收与函数高级。需要说明的是,本节笔记未附图片与代码,因此不虚构图片或代码;转写稿中大量“父类/子类、原型链、call、this”的继承推导属于后续内容,本节只借用其中“不能死记,要按调用过程逐步分析”的调试习惯。转写稿里的“负类/子类”应纠正为“父类/子类”,“圆形”应为“原型”,“靠”应为“call”,“瑞斯”应为“this”,“县程”应为“线程”,“继成”应为“继承”。为什么需要先回顾:因为这些概念是后面理解闭包、内存和继承的地基。怎么想:把“运行时环境”和“静态作用域”分开。怎么做:先记概念,再用小例子验证。易错点:不要把执行上下文、作用域、闭包混成一个东西。
垃圾回收:引用计数与标记清除
为什么需要:JS 自动管理内存,开发者要知道变量何时可能被回收,才能解释闭包导致的内存占用。怎么想:垃圾回收的本质是判断“还有没有可达引用”。怎么做:引用计数看对象被引用次数,归零可回收;标记清除从根对象出发,标记可达对象,清除未标记对象。易错点:引用计数遇到循环引用会回收不了;标记清除不是“数引用次数”,而是看可达性。转写稿强调分析问题要一步步来,这里同样要按算法过程推,不要只背名词。
执行上下文对象:全局与函数内
为什么需要:函数执行时,变量、this、作用域链需要有个运行环境来承载。怎么想:全局代码有全局执行上下文;函数每次调用都会创建函数执行上下文。怎么做:先看代码处于全局还是函数调用中,再确定当前上下文里的变量环境和 this。易错点:函数执行上下文是“调用时”创建,不是定义时创建;同一个函数调用多次,会产生多个不同的函数执行上下文。转写稿中分析 this 时反复强调“要看它怎么被调用”,这正是执行上下文思路:不能死记结果,要看运行过程。
执行栈:后进先出的调用轨迹
为什么需要:函数可以嵌套调用,引擎必须知道当前执行到哪、返回后回到哪。怎么想:把执行栈想成一摞盘子,后调用的函数先执行完。怎么做:函数调用时压栈,函数返回时弹栈,栈顶就是当前执行上下文。易错点:执行栈不是作用域链;栈反映调用顺序,作用域反映变量查找范围。调试时可在断点里观察调用栈,从栈顶往上看调用关系。
执行上下文与作用域:运行时环境不等于词法范围
为什么需要:很多闭包题错在把“作用域”当成“执行上下文”。怎么想:作用域在函数定义时由代码位置决定,是静态的词法范围;执行上下文是运行时创建的。怎么做:找变量时沿作用域链向外层词法环境找,而不是沿调用栈找。易错点:函数在哪里调用,不决定它能访问哪里的变量;函数在哪里定义,才决定词法作用域。转写稿里“this 是动态的、call 可固定 this”提醒我们:运行时值和静态结构要分开分析。
闭包:概念、实现与作用域
为什么需要:闭包让函数记住定义时的环境,是实现私有变量、回调和模块的基础。怎么想:闭包不是“函数里面再写函数”这么简单,而是内层函数引用了外层变量,并且内层函数被外部持有。怎么做:外层函数定义变量,内层函数访问它,再把内层函数返回或传出;外部调用内层函数时仍能读到外层变量。易错点:只嵌套不返回、不传出,通常不形成可观察闭包;闭包保存的是词法作用域,不是调用时作用域。
闭包与垃圾回收:引用延长与泄漏
为什么需要:闭包会让外层变量被引用,导致本可回收的变量继续存活。怎么想:看闭包函数是否还被外部引用,以及它引用了哪些变量。怎么做:不需要闭包时解除引用,如把保存闭包的变量置为 null;避免闭包无意中持有大对象。易错点:闭包不一定泄漏,关键是引用是否长期存在;引用计数和标记清除对闭包中变量的判断都取决于可达性。
闭包应用与复习方法
为什么需要:闭包在事件回调、防抖节流、私有变量、模块模式中常见。怎么想:先判断“谁引用谁”,再判断变量生命周期。怎么做:用最小例子验证:外层计数变量 + 返回内层函数,连续调用看计数是否保留;再置空引用观察回收语义。易错点:循环中闭包捕获同一变量时,要用 let 或立即执行函数隔离;不要把闭包当成语法糖,它本质是函数与词法环境的组合。复习时按“为什么需要→怎么想→怎么做→易错点”过一遍,比背结论稳。
为什么需要构造函数之间的继承
在 JavaScript 里,我们常用构造函数描述一类对象。比如已有 A、B 两个互不相关的构造函数,现在希望 B 的实例也能使用 A 规定的属性和方法,也就是让 A 当父类、B 当子类。系统本身就有类似关系:数组实例的原型是 Object 的一个实例,Array.prototype.constructor 又指回 Array。我们手工建立继承,就是模仿这套规则。
怎么想:子类实例的原型,应当是父类的一个实例。这样查找属性时,会先找子类实例自身,再沿原型链找到父类实例、父类原型。
怎么做:
js
function A() {}
function B() {}
// B 实例的原型 = A 的一个实例
B.prototype = new A();
// 让以 B.prototype 为原型的对象能正确拿到构造函数
B.prototype.constructor = B;
const b = new B();
console.log(b.constructor === B); // true易错点:constructor 的设置不影响继承是否成立,它主要是为了和系统规则统一。它也不是给 B.prototype 自己准备的,而是给“以 B.prototype 为原型的对象”准备的。不要写成 B = new A(),也不要把 B.prototype 直接等于 A.prototype,否则会丢失子类自己的原型层。
用商品与汽车落地继承
先写父类 Product,把价格、库存数量放在实例上,把打折、购买方法放在原型上:
js
function Product(price, numbers) {
this.price = price;
this.numbers = numbers;
}
Product.prototype.discount = function (rate) {
this.price *= rate;
};
Product.prototype.buy = function () {
this.numbers -= 1;
};再写子类 CarProduct。汽车也有价格、数量,也能打折、被购买,同时还有速度、驾驶方法:
js
function CarProduct(price, numbers, speed) {
// 借用父类构造函数,把 price、numbers 加到当前子类实例上
Product.call(this, price, numbers);
this.speed = speed;
}
// 建立继承:CarProduct 实例的原型是 Product 的一个实例
CarProduct.prototype = new Product();
CarProduct.prototype.constructor = CarProduct;
// 方法放在原型上
CarProduct.prototype.driver = function () {
console.log('这辆车可以被驾驶');
};
const c1 = new CarProduct(19.8, 45, 100);
const c2 = new CarProduct(27.8, 178, 250);
c1.discount(0.7); // 价格会按折扣修改
console.log(c1.price);为什么属性要放实例上:如果只在 Product.prototype 上写 price、numbers,所有子类实例会共用同一份原型属性,值容易相同或没有实际值。调试时会看到 c1.price、c2.price 不对。正确做法是用 Product.call(this, price, numbers),让每个实例拥有自己的属性。
怎么想 call:Product.call(this, ...) 中,call 用来指定 Product 内部 this 的指向。这里的 this 是 new CarProduct(...) 产生的实例,所以父类里 this.price = price 实际加到了子类实例上。
易错点:先改 CarProduct.prototype,再往原型上挂 driver 等方法;顺序反了可能白设。原型链大致是:c1 -> CarProduct.prototype(Product 实例) -> Product.prototype -> Object.prototype -> null。c1 自身没有 constructor,会沿原型链找到 CarProduct.prototype.constructor,所以设置为 CarProduct 后才能正确得到构造函数。
进程与线程:为 JS 单线程做铺垫
进程可以理解为程序的一次执行,占有独立内存空间;线程是 CPU 调度的基本单位,一个进程里可以有多个线程,同一进程内的线程共享内存。不同进程之间内存独立,数据不能像同进程线程那样直接共享,通常要做进程间通信。
怎么想:可以把 CPU 比作工厂,进程比作车间,线程比作车间里的工人。工厂分配资源以车间为单位,同一车间工人共用资源;不同车间资源分开。
易错点:不要把进程和线程混为一谈;一个进程一般至少有一个线程;同一进程内线程可共享数据,不同进程不直接共享。理解这些概念,是为了后续理解 JavaScript 单线程运行机制。
先明确本节要解决什么问题
本节标题是“单线程和事件轮询机制”,但转写稿实际先补了计算机基础概念:进程与线程。为什么需要先讲这个?因为如果不理解“进程分内存、线程是 CPU 调度单位”,就很难理解 JavaScript 为什么常被称为单线程语言,以及单线程下为什么还要有任务排队、轮询等机制。怎么想?不要把“单线程”当成一句口号,先建立操作系统层面的模型:程序运行起来会变成进程,进程里可以包含多个线程,线程才是 CPU 调度的基本单位。怎么做?先掌握两个定义:进程是程序的一次执行,占有一片独有的内存空间;线程是 CPU 的基本调度单位,是程序执行的一个完整流程。易错点:不要把进程和线程混为一谈,也不要把“单线程”误解成“整个浏览器只有一个进程”。转写稿本节没有展开事件轮询的具体步骤,因此这里只讲它为后续理解 JS 单线程所做的铺垫,不编造轮询细节。
进程:程序的一次执行与独立内存
为什么需要进程?因为计算机要同时运行很多程序,必须有一种单位来分配和管理内存资源。怎么想?把“打开一个程序”理解成操作系统为它创建一个进程,并给它划出一块独立内存。怎么做?在 Windows 任务管理器里观察:Chrome、VS Code、资源管理器、投屏工具等都会显示为进程;转写稿中还现场打开 Chrome 新窗口,看到进程数量增加。易错点:一个程序不一定只对应一个进程,例如 Chrome 可能启动多个进程;不同进程之间的数据不能像同一个进程内的线程那样直接共享,跨进程传递数据要通信,比较麻烦。
线程:进程里的执行流程
为什么需要线程?因为一个进程里往往要同时干多件事,例如浏览器既要渲染 HTML/CSS,又要执行 JavaScript,还要处理其他任务。怎么想?线程是 CPU 的基本调度单位,是程序执行的一个完整流程;一个进程中一般至少有一个线程,也可以同时运行多个线程,这时我们说程序是多线程运行的。怎么做?把线程理解成“进程内部的工作线”:同一进程内的多个线程共用该进程的内存资源,所以数据可以直接共享。易错点:不要以为同一进程内线程各自有一块完全独立内存;共享的是进程分到的内存,线程之间才可以直接读写同一份数据。
工厂与车间的经典比喻
为什么需要比喻?因为进程、线程、内存分配这些术语太抽象。怎么想?转写稿用“CPU 是工厂,进程是车间,线程是车间里的工人”来类比:工厂分配资源时以车间为单位,也就是操作系统以进程为单位分配内存;同一个车间里的工人共用厂房和材料,也就是同一进程内的线程共享内存。怎么做?遇到“谁共享谁”的问题时,先问:它们是不是在同一个“车间”里?是,就共享;不是,就分属不同进程,不能直接共享。易错点:比喻只是帮助理解,不能把“车间”当成 CPU 核心;线程才是调度单位,进程是资源分配单位。
对 JavaScript 单线程的意义
为什么本节要讲进程和线程?因为后面理解 JavaScript 单线程运行机制,必须先知道“线程”是什么。怎么想?JavaScript 常说的单线程,是指执行 JavaScript 代码的主线程通常只有一条;但这不等于浏览器只有一个线程,也不等于浏览器只有一个进程。怎么做?先记住三个层次:浏览器可以有多进程;一个进程里可以有多线程;JS 代码执行的主线程是单线程。易错点:不要把“JS 单线程”扩大成“浏览器单线程”;也不要在本节提前套用事件轮询的细节,转写稿只讲到进程和线程,并为单线程运行做准备。
本节易错点小结
第一,进程是程序的一次执行,拥有独立内存;线程是 CPU 调度单位,同一进程内线程共享内存。第二,任务管理器里看到的 Chrome 多个进程,说明一个程序可以对应多个进程。第三,同一进程内线程数据可直接共享,不同进程之间不能直接共享,需要通信。第四,JS 单线程说的是执行 JS 代码的主线程,不是整个浏览器只有一个线程或一个进程。把这几条分清,再进入事件轮询机制,才不会把“单线程”“多线程”“多进程”混在一起。
课堂画面





代码示例
示例一:构造函数实现继承的原理(设置原型 + 修正 constructor)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>构造函数实现继承-原理</title>
</head>
<body>
<script>
// ---------- 父类:商品类,描述所有商品的总体特征 ----------
function Product() {
this.price = 100; // 价格
this.numbers = 100; // 库存数量
}
Product.prototype.discount = function (num) { // 打折
this.price *= num;
};
Product.prototype.buy = function () { // 被买走一件
this.numbers -= 1;
};
// ---------- 子类:汽车类商品 ----------
function CarProduct() {
this.speed = 200; // 汽车特有:最高时速
}
// 关键①:子类实例的原型 = 父类的一个实例
CarProduct.prototype = new Product();
// 关键②:修正 constructor,让它指回子类自己
CarProduct.prototype.constructor = CarProduct;
// 关键③:改完原型之后再挂子类自己的方法(顺序不能反!)
CarProduct.prototype.drive = function () {
console.log('这辆车可以被驾驶');
};
// ---------- 验证 ----------
var c1 = new CarProduct();
console.log(c1); // speed 在实例上,price/numbers 在原型上
console.log(c1.price, c1.numbers, c1.speed); // 100 100 200
c1.discount(0.7);
console.log(c1.price); // 70(改的是 c1 自己的 price)
console.log(c1.constructor === CarProduct); // true
console.log(c1 instanceof CarProduct); // true
console.log(c1 instanceof Product); // true
</script>
</body>
</html>要点解释 / 易错点
CarProduct.prototype = new Product()这一行是继承的核心:子类的实例的原型,是父类的一个实例。类比系统自带的Array.prototype是Object的一个实例。- 这句
CarProduct.prototype.constructor = CarProduct不影响继承关系,纯粹是为了和系统的规则统一起来。不写的话c1.constructor会顺着原型链找到Product,得到错误答案。 - 顺序坑:
CarProduct.prototype = new Product()是整体赋值,会把之前挂在CarProduct.prototype上的东西全部覆盖掉。所以drive方法必须写在赋值之后,否则"白设了"。 - 这段代码的缺陷:
price和numbers是写死在父类构造函数里的,多个CarProduct实例会共用原型上同一份值,属性值全都一样。
示例二:用 call 借用父类构造函数,让每个实例拥有自己的属性
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>call 借用父类构造函数</title>
</head>
<body>
<script>
// ---------- 父类:只"规定标准",具体值由子类传 ----------
function Product(price, numbers) {
this.price = price;
this.numbers = numbers;
}
Product.prototype.discount = function (num) {
this.price *= num;
};
Product.prototype.buy = function () {
this.numbers -= 1;
};
// ---------- 子类:属性全放实例,方法全放原型 ----------
function CarProduct(price, numbers, speed) {
Product.call(this, price, numbers); // 关键:把父类里给 this 加属性的代码借过来执行
this.speed = speed; // 子类自己的属性
}
// 只为了拿到父类原型上的方法,所以不传参数
CarProduct.prototype = new Product();
CarProduct.prototype.constructor = CarProduct;
CarProduct.prototype.drive = function () {
console.log('这辆车可以被驾驶');
};
// ---------- 验证:属性各自独立,方法共用 ----------
var c1 = new CarProduct(19.8, 45, 100);
var c2 = new CarProduct(27.8, 178, 250);
console.log(c1.price, c2.price); // 19.8 27.8 —— 值不一样了
console.log(c1.speed, c2.speed); // 100 250
console.log(c1.hasOwnProperty('price')); // true 属性在实例自己身上
console.log(c1.hasOwnProperty('discount')); // false 方法在原型上
c1.buy();
console.log(c1.numbers, c2.numbers); // 44 178 —— 互不影响
</script>
</body>
</html>要点解释 / 易错点
Product.call(this, price, numbers)干的事:调用父类函数,并把父类里的this钉死成当前子类的实例。于是this.price = price这些属性就加到了CarProduct的实例身上,而不是原型上。- 为什么属性必须放实例?因为同一个构造函数的不同实例共用同一个原型,属性放原型上,所有实例的值必然一模一样(就像
length只能放数组实例上)。 new Product()不传参,所以原型上price、numbers是undefined——这没关系,它只是"定一个标准",实际值由子类实例自己提供。- 易错点:
call里第一个参数必须是this,写成Product(price, numbers)会把属性挂到全局对象上;另外call必须写在this.speed = speed之前或之后都行,但一定要在CarProduct构造函数内部执行。 - 易错点:
this的值是动态的,不能死记"这里就是 CarProduct 的实例",只有new CarProduct()执行的那一次才成立。
示例三:最终形态 + 原型链逐层验证
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>继承最终形态与原型链</title>
</head>
<body>
<script>
function Product(price, numbers) {
this.price = price;
this.numbers = numbers;
}
Product.prototype.discount = function (num) { this.price *= num; };
Product.prototype.buy = function () { this.numbers -= 1; };
function CarProduct(price, numbers, speed) {
Product.call(this, price, numbers);
this.speed = speed;
}
CarProduct.prototype = new Product();
CarProduct.prototype.constructor = CarProduct;
CarProduct.prototype.drive = function () { console.log('可以被驾驶'); };
var c1 = new CarProduct(19.8, 45, 100);
// 从实例开始,一层一层往上找原型
var level = 0;
var obj = c1;
while (obj) {
console.log('第 ' + (++level) + ' 层:', obj);
console.log(' constructor =', obj.constructor && obj.constructor.name);
obj = Object.getPrototypeOf(obj);
}
console.log('c1 实例自有属性:', Object.keys(c1)); // price numbers speed
console.log('原型链上的方法可用吗:');
c1.drive(); // 来自 CarProduct.prototype
c1.discount(0.5); // 来自 Product.prototype
c1.buy(); // 来自 Product.prototype
console.log(c1.price, c1.numbers); // 9.9 44
</script>
</body>
</html>要点解释 / 易错点
- 这条链是 4 层:
c1→CarProduct.prototype(就是new Product()那个对象)→Product.prototype→Object.prototype。因为手动改了继承关系,所以比普通对象多一层。 constructor只应该出现在"给别人当原型"的对象上,值指向以它为原型的那个对象的构造函数:CarProduct.prototype.constructor === CarProduct,Product.prototype.constructor === Product。c1自己身上是没有constructor的,它是顺着原型链读到的。- 属性查找规则:先在实例自己身上找,找不到再一层层往原型上找。所以方法放原型、属性放实例,是最符合系统习惯的写法。
- 易错点:
Object.getPrototypeOf(obj)和obj.__proto__等价,但后者不是标准属性,正式代码里用前者。 - 记住这套写法只是 ES5 的原型操作原理,ES6 的
class/extends只是语法糖,底层规则没有变。
常见错误
现象:子类实例的
constructor指向父类。
原因:重设子类.prototype后没有修正constructor。
修法:建立原型链后补子类.prototype.constructor = 子类。现象:改子类原型方法,父类实例也受影响。
原因:直接写子类.prototype = 父类.prototype,父子共用同一原型对象。
修法:改为子类.prototype = Object.create(父类.prototype)或课程写法new 父类(),再挂子类方法。现象:子类实例没有父类构造器里设置的属性,或属性值互相串。
原因:忘记在子类构造器里调用父类.call(this, ...),或this传错。
修法:在子类构造器内先执行父类.call(this, 所需参数),再设置子类自有属性。现象:所有实例的
price/stock等值相同,改一个全变。
原因:把应属于实例的属性写到了prototype上。
修法:会随实例变化的属性放构造函数内用this设置;方法放原型。现象:子类方法丢失,或
constructor又变回父类。
原因:先给子类.prototype挂方法,后又整体重新赋值覆盖原型。
修法:顺序固定为:建立原型链 → 修正constructor→ 再往原型上挂方法。现象:用
new 父类()建原型链时,父类构造器副作用被意外执行一次。
原因:把“建立原型关系”和“初始化实例”混在一起。
修法:优先用Object.create(父类.prototype);若用new 父类(),确保父类构造器无危险副作用。
练习
写
Product与CarProduct:实例属性price/stock/speed必须各自独立,方法discount/buy/drive不重复创建,并验证car.constructor === CarProduct。给出错误代码:
CarProduct.prototype = Product.prototype; CarProduct.prototype.constructor = CarProduct;,说明会产生什么后果,并改成正确写法。创建两个
CarProduct实例,分别输出并标注:实例自身属性、第一层原型上的属性/方法、第二层原型上的属性/方法。用
父类.call(this, ...)改造一个忘记调用父类构造器的子类,验证两个子类实例的同名属性互不影响。判断并说明:浏览器整体是不是单线程?JS 主执行线程单线程时,异步任务为什么不会阻塞同步代码继续执行?
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 实现js中的父类子类-原理 | 19:28 |
| 2 | 实现js中的父类子类继承01 | 10:44 |
| 3 | 实现js中的父类子类继承02 | 39:15 |
| 4 | 实现js中的父类子类继承-原型链图例 | 31:34 |
| 5 | 进程和线程 | 07:41 |
小结
- 构造函数继承落地时,重点检查两处:原型链是否建立、
constructor是否修正。 - 实例属性优先放构造函数内,方法优先放原型上。
constructor不是继承开关,只影响对象识别和调试判断。- 建立子类原型后,不要再次整体覆盖
子类.prototype。 - 调试继承问题,按“实例自身 → 原型 → 原型链上层”逐层排查。
- 父类构造器有副作用时,避免用
new 父类()只为建原型链。 - “JS 单线程”指主执行线程,不等于浏览器只有一个线程或一个进程。
- 异步代码的阅读顺序要结合同步执行、任务入队和事件轮询一起判断。