Skip to content

原型链回顾与原型链图例(Day18 1/3) ​

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

学习目标 ​

  • 能够复述垃圾回收的两种常见算法,并说明“垃圾”的判定与回收含义。
  • 能够区分全局执行上下文对象与函数执行上下文对象的创建时机、预处理内容及 this 指向。
  • 能够用自己的话说明闭包的两层核心含义,并列举至少两种闭包实现方式及一个典型应用场景。
  • 能够准确区分构造函数、实例、原型、__proto__、prototype 与 constructor 的指向关系,避免混淆函数专属属性与其他对象属性。
  • 能够根据给定对象或代码画出完整原型链,并据此判断其构造函数、原型及父子类关系。
  • 能够说明 JavaScript 继承依赖原型实现的特点,并解释“子类实例以父类实例为原型”与“父类可有多个子类、子类只能有一个父类”的对应关系。

知识点 ​

回顾:垃圾回收与函数高级 ​

为什么需要先回顾?因为本节后面讲原型链、继承,都会反复用到“对象、函数、原型、执行上下文”这些基础概念。转写稿里的“引用技术”应纠正为引用计数,“标记清除”是另一种垃圾回收算法;所谓垃圾,就是不再使用的数据,应从内存中回收销毁。怎么想:先分清两条线——垃圾回收讲“数据何时被销毁”,函数高级讲“代码如何执行”。怎么做:回顾执行上下文对象分全局和函数内;全局执行上下文对象就是 window,代码执行前创建并预处理,找 var 变量声明给 window 添加属性但不赋值,找 function 声明给 window 添加方法且有值,并把全局 this 赋为 window。函数执行上下文对象在调用函数时创建,调用一次创建一个;函数体代码执行前也会预处理,局部变量成为该对象属性,var 不赋值,function 有值,形参和 arguments 也会赋值,this 指向调用该函数的对象。执行栈用来存储执行上下文对象,特点是先进后出。易错点:函数内执行上下文对象确实存在,但作者没有提供直接访问 API,知道存在即可;执行上下文是动态的,作用域是静态的,局部变量每次调用互不影响。

闭包回顾:概念、实现与应用 ​

为什么需要闭包?因为要让函数外部访问函数作用域里的数据,并延长数据生命周期。怎么想:描述闭包抓住两点——一个函数,以及它能访问外部作用域数据。怎么做:实现三步,函数 A 套函数 B;函数 B 访问函数 A 的局部数据;函数 B 对外引用。对外引用常见三种:作为函数 A 的返回值;赋给全局对象属性;作为事件回调。易错点:闭包不是“神秘结构”,本质是作用域现象;它与垃圾回收的关系是延长数据生命周期,一直有人引用就不变成垃圾。应用上,遍历元素绑定事件时,循环变量 i 直接取不到预期索引,可用 forEach(回调天生带闭包)或立即执行函数包一层。

原型链回顾:prototype、__proto__ 与 constructor ​

为什么需要分清?因为给一个对象找原型链时,最容易把“原型”和“构造函数”混成一条线。怎么想:函数类型的对象两个属性都有:__proto__ 获取自己的原型;prototype 获取该构造函数实例的原型。其他类型对象只有 __proto__,没有 prototype,因为 prototype 是函数专属。怎么做:构造函数相同,原型也相同;构造函数.prototype 可获取该构造函数实例的原型。调试时用数组验证:arr 自身在 F12 里只有 length,没有 constructor;点 arr.constructor 得到 Array,是因为沿原型找到 Array.prototype.constructor。易错点:constructor 只有第一层较“准”,对象自身若有 constructor,往往不是给自己的,而是给以它为原型的对象用。例如 FOO.prototype.constructor 指向 FOO,但 FOO.prototype 这个对象本身的构造函数是 Object;Object.prototype.constructor 指向 Object。不要用 constructor 简单判断“自己的构造函数”。

原型链图例:从 F1、F2 到顶层 ​

为什么画图?因为原型链关系多,文字容易混。怎么想:先画实例,再画构造函数,再画原型,最后补指向。怎么做:F1、F2 由 new FOO() 得到,构造函数是 FOO,原型是 FOO.prototype;实例的 __proto__ 指向 FOO.prototype;FOO.prototype.constructor 指向 FOO,但它是给 F1、F2 用的。继续往上,FOO.prototype.__proto__ 指向 Object.prototype;Object.prototype.constructor 指向 Object。O1、O2 是 Object 实例,原型直接到 Object.prototype。函数 FOO、Object、Function 的原型都是 Function.prototype,再往上是 Object.prototype。特殊点:Object 这个函数对象的原型是 Function.prototype,而 Function.prototype 的构造函数是 Object,所以看起来“我是你的原型,你是我的构造函数”;Function 的构造函数还是 Function。易错点:把 Object.prototype 和 Object.__proto__ 混掉;把 Function.prototype 的 constructor 指向与“它的构造函数是谁”混掉。

JS 继承特点:用原型描述父类子类 ​

为什么讲继承?因为 JS 没有传统面向对象语言那样的 class 继承体系,但对象确实能继承原型上的属性和方法。怎么想:其他语言在类与类之间继承;JS 用原型链实现继承。怎么做:若对象 A 的原型是对象 B,则 A 的构造函数可视为子类,B 的构造函数可视为父类;也可反过来说:子类的实例以父类的实例为原型。例如 Array 实例的原型链到 Object.prototype,可说 Array 是子类、Object 是父类;字符串同理。div 元素的原型链很长,会经过 HTMLDivElement、HTMLElement、Element、Node、EventTarget,最后到 Object.prototype,因此 Object 像“超级父类”。易错点:一个对象只能有一个原型,所以一个子类只能有一个父类;一个原型可作为多个对象的原型,所以一个父类可有多个子类。不要把“构造函数之间的父子描述”当成 JS 原生关键字继承;ES6 的 class 本质仍是构造函数体系。

本节回顾的两条主线 ​

本节回顾两部分:垃圾回收与函数高级。前者解释“不用了的数据如何从内存中销毁”,后者围绕执行上下文对象、执行栈、作用域和闭包展开。转写稿里概念较多,复习时要能用自己的话描述,而不是只背名词。

一、垃圾回收:先判定垃圾,再选择回收算法 ​

为什么需要:内存空间有限,不再使用的数据如果一直留着,就会造成浪费,所以要把判定为“垃圾”的数据从内存中销毁。转写稿明确说,垃圾就是“不用了的数据”,回收其实就是销毁。

怎么想:先判定哪些数据不再使用,再选择回收算法。本节回顾了两种算法:引用计数、标记清除。

怎么做:复习时把“垃圾判定”和“回收算法”分开记:引用计数关注引用关系;标记清除通过标记和清除完成回收。术语要写准,转写稿里的“引用技术”应纠正为“引用计数”。

易错点:不要把“回收”理解成删除业务数据;不要混淆两种算法名称;要能说清“垃圾”的判定前提。

二、全局执行上下文对象:代码执行前的预处理 ​

为什么需要:理解变量提升、this 和 window 的关系,必须知道全局代码执行前发生了什么。

怎么想:全局执行上下文对象就是 window,它在代码执行之前创建,并进行预处理。预处理主要做提升:从全局代码中找 var 变量创建语句,给 window 添加属性,但不赋值;找 function 函数声明,给 window 添加方法,属性名是方法名,值是函数,所以函数声明有值;最后给 this 赋值,全局下 this 就是 window。

怎么做:分析全局代码时,先看 var 和 function 声明,再想执行阶段才赋值。

易错点:var 只登记不赋值;函数声明有值;this 的值不是执行上下文对象本身,而是全局的 window。

三、函数执行上下文对象:调用一次创建一个 ​

为什么需要:解释函数内的变量提升、形参、arguments 和 this。

怎么想:函数执行上下文对象在调用函数时创建,调用一次创建一个;同一个函数调用多次,就会创建多次。函数体代码也不是立即执行,而是先预处理再执行。

怎么做:预处理时,局部 var 变量变成函数执行上下文对象的属性,仍不赋值;function 声明有值;形参添加成对象属性并赋值;arguments 也会赋值;this 赋值为调用该函数的对象。这个对象确实存在,但没有 API 能直接访问,知道它存在即可。

易错点:同一函数多次调用,局部变量各自独立,“各玩各的”;this 不是执行上下文对象;不要试图找 API 访问函数执行上下文对象。

四、执行栈:上下文入栈后才执行 ​

为什么需要:管理函数嵌套调用的顺序。

怎么想:执行栈是内存中的栈结构,专门存储执行上下文对象,也叫调用栈。执行上下文对象放入执行栈后才开始执行,特点是先进后出。

怎么做:调用函数时创建上下文并放入执行栈,执行完再出栈。分析嵌套调用时,按入栈、执行、出栈顺序看。

易错点:执行上下文对象是动态的,调用一次创建一个;作用域是静态的,只看函数生成/声明的地方。函数内局部变量每调用一次创建一次,同一函数多次调用互不影响。

五、闭包概念与实现:函数加访问作用域数据 ​

为什么需要:从函数外部访问函数作用域里的数据,并理解数据为什么能长期存在。

怎么想:闭包概念主要描述两点:一是函数;二是能够从函数外部访问它作用域里的数据。转写稿说,把“函数”和“访问不容易访问的数据”说清楚,再用自己的语言组织即可。

怎么做:实现闭包三步:第一,函数 A 套函数 B;第二,函数 B 能访问函数 A 里的数据,这依赖作用域链访问上层作用域数据;第三,函数 B 要对外引用。对外引用有三种方式:在函数 A 里把函数 B 作为返回值;把函数 B 赋给一个对象的属性;让函数 B 作为事件回调函数。

易错点:闭包不是某种特殊语法;只有内部函数引用了外层数据,并且被外部持有,才形成可观察的闭包;不要只背“闭包”两个字。

六、闭包与作用域、垃圾回收 ​

为什么需要:理解闭包本质和内存影响。

怎么想:闭包原理用作用域来解释,闭包本身就是对一种作用域现象的描述。即使不知道闭包概念,单靠作用域知识也能理解这种现象。闭包和垃圾回收的关系在于:闭包延长了数据的生命周期,数据一直有人引用,就不会变成垃圾。

怎么做:分析时先找函数定义位置,再看内部函数是否引用外层变量,最后看内部函数是否被外部持有。调试内存问题时,先判断是否有闭包持续引用数据。

易错点:闭包不等于垃圾回收失效;不是所有函数都是闭包;要能描述“为什么数据没有变成垃圾”。

七、闭包应用:循环绑定事件取索引 ​

为什么需要:遍历一组元素,给每个元素监听事件,事件回调中想拿到当前元素的索引。

怎么想:转写稿里的调试过程是:for 循环直接显示拿不到索引,原因不是事件本身,而是循环变量 i 没有被闭包包起来,回调执行时拿到的是共享变量。解决核心是让每个回调持有各自的 i 副本。

怎么做:可以用 forEach,它的参数是回调函数,天生自带闭包;也可以自己写一个立即执行函数,把 i 包进去。调试时先确认回调执行时机,再看 i 的作用域,确认是共享变量问题后再改写法。

易错点:不要把“拿不到索引”归因于选择器或事件绑定;解决关键是闭包保存当次循环的值;术语要写准:闭包、作用域、回调函数、立即执行函数。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例一:__proto__、prototype、constructor 三者的区别 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>原型链与 constructor</title>
</head>
<body>
  <script>
    // 1. 一个数组
    const arr = [];

    // 自身只有 length,没有 constructor
    console.log(Object.getOwnPropertyNames(arr));               // ['length']

    // __proto__ 拿到的是「自己的原型」
    console.log(arr.__proto__ === Array.prototype);             // true
    // constructor 是「从原型上借来的」,所以第一层是准的
    console.log(arr.constructor === Array);                     // true

    // 2. 顺着原型链往上走
    console.log(arr.__proto__.__proto__ === Object.prototype);  // true
    console.log(arr.__proto__.__proto__.__proto__);             // null(原型链的“上帝”)

    // 3. 只有函数才有 prototype 属性
    console.log(typeof Array.prototype);                        // object
    console.log(typeof Array.prototype.__proto__);              // object
    console.log(Array.prototype.__proto__.hasOwnProperty('prototype')); // false

    // 4. constructor 只有第一层准,再往上就“不准”了(易错点)
    console.log(Array.prototype.constructor === Array);         // true
    console.log(Array.prototype.__proto__.constructor === Object); // true
    console.log(arr.__proto__.__proto__.constructor === Array); // false!
  </script>
</body>
</html>

关键行与易错点

  • arr.__proto__ 取的是自己的原型;Array.prototype 取的是该构造函数实例的原型,两句不能混。
  • arr 本身只有 length,arr.constructor 能用是因为它在原型上,不是自己身上。
  • prototype 是函数专属属性,数组、字符串、普通对象身上没有,别去找。
  • 最经典的坑:Array.prototype.__proto__.constructor 是 Object 而不是 Array——因为这个 constructor 是 Object.prototype 给“以它为原型的对象”用的,不是给自己用的。

示例二:原型链图例(Foo 与 {} 的关系) ​

js
// 可直接放进 <script> 或浏览器控制台运行
function Foo() {}

const f1 = new Foo();
const f2 = new Foo();
const o1 = {};
const o2 = {};

/* 1. 实例 → 构造函数 */
console.log(f1.constructor === Foo);        // true
console.log(o1.constructor === Object);     // true

/* 2. 实例 → 原型 */
console.log(f1.__proto__ === Foo.prototype);   // true
console.log(f2.__proto__ === Foo.prototype);   // true
console.log(f1.__proto__ === f2.__proto__);    // true:构造函数相同 → 原型也相同

/* 3. 原型 → 构造函数(constructor 是给“以我为原型的对象”用的) */
console.log(Foo.prototype.constructor === Foo);     // true
console.log(Foo.prototype.constructor === Object);  // false

/* 4. 原型链继续往上 */
console.log(Foo.prototype.__proto__ === Object.prototype); // true
console.log(o1.__proto__ === Object.prototype);            // true
console.log(Object.prototype.__proto__);                   // null

/* 5. 函数这条特殊支线 */
console.log(Foo.__proto__ === Function.prototype);          // true
console.log(Object.__proto__ === Function.prototype);       // true
console.log(Function.__proto__ === Function.prototype);     // true:自己指向自己
console.log(Function.prototype.__proto__ === Object.prototype); // true
console.log(Function.prototype.constructor === Function);   // true

/* 6. 用 instanceof 反查整条链 */
console.log(f1 instanceof Foo);      // true
console.log(f1 instanceof Object);   // true
console.log(Foo.prototype instanceof Object); // true

关键行与易错点

  • Foo.prototype 上的 constructor 值指向 Foo,但它的“历史使命”是给 f1、f2 用的,所以画图时要画到实例那一侧。
  • Foo.prototype.__proto__ 是 Object.prototype,因为 Foo.prototype 本质上是个普通对象 {}。
  • Function.__proto__ === Function.prototype 是全场唯一的特例:它的 __proto__ 和 prototype 指向同一个地方。
  • Object 和 Function 的关系是“你是我的原型,我是你的构造函数”的互指现象,只有这一对会这样,其他构造函数不会。

示例三:JS 中的“父类 / 子类”(数组、字符串、DOM 元素) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>父类与子类</title></head>
<body>
  <div id="book">书</div>
  <script>
    /* 规则:对象A的原型 是 对象B
       => A的构造函数 是 子类,B的构造函数 是 父类 */

    // 1. 数组 vs Object
    const arr = [];
    console.log(arr.__proto__ === Array.prototype);            // true
    console.log(arr.__proto__.__proto__ === Object.prototype); // true
    console.log(arr instanceof Array);      // true
    console.log(arr instanceof Object);     // true:子类实例也能用父类的东西
    console.log(arr.hasOwnProperty('length')); // true:hasOwnProperty 来自 Object.prototype

    // 2. 字符串 vs Object
    const msg = 'hello';
    console.log(msg.__proto__ === String.prototype);            // true
    console.log(msg.__proto__.__proto__ === Object.prototype);  // true
    console.log(msg.constructor === String);                    // true
    console.log(msg instanceof String);                         // false!原始值不是实例

    // 3. DOM 元素:一条很长的原型链
    const book = document.querySelector('#book');
    let p = book;
    while (p) {
      console.log(p.constructor.name);
      p = Object.getPrototypeOf(p);   // 等价于 p.__proto__
    }
    // 输出:HTMLDivElement → Element → Node → EventTarget → Object
  </script>
</body>
</html>

关键行与易错点

  • 判断“谁是父类”不是看名字,而是看原型链:子类实例.__proto__ === 父类.prototype 就说明是父子关系。
  • 一句话表述:“子类的实例以父类的实例为原型”,对应到代码就是 arr.__proto__.__proto__ === Object.prototype。
  • 一个对象只能有一个原型,所以一个子类只能有一个父类;但一个原型可以被多个对象引用,所以一个父类可以有多个子类。
  • Object.prototype 是万物之祖,它的 __proto__ 是 null,循环遍历原型链时用它作为结束条件。
  • msg instanceof String 为 false 是易错点:'hello' 是原始值,访问 .constructor 时是临时装箱,并不代表它是 String 的实例;只有 new String('hello') 才是。

常见错误 ​

1. 把 prototype 当成人人都有

  • 现象:对数组、字符串、普通对象写 arr.prototype、str.prototype 去取原型,拿到 undefined 后以为原型链断了。
  • 原因:把"取自己的原型"和"取实例的原型"混成一件事,忘了 prototype 是函数专属。
  • 修法:动手前先问一句"我手上这个值是函数吗";取自己的原型一律用 __proto__。

2. 认为 __proto__ 指向构造函数

  • 现象:写出 f1.__proto__ === Foo 并判为 true。
  • 原因:把构造函数 Foo 和原型对象 Foo.prototype 当成同一个东西。
  • 修法:比较时右边先敲 .prototype 再比对;画图时构造函数画一边、原型对象画另一边,不许挨着放。

3. 拿 constructor 判断类型

  • 现象:写 if (obj.constructor === Array) 做类型判断,遇到原型对象、跨 iframe 对象就出错。
  • 原因:原型对象上的 constructor 是留给"以它为原型的实例"用的,不描述自己,而且可被改写。
  • 修法:类型判断用 instanceof 或 Object.prototype.toString.call(x);constructor 只用于"顺着原型找回来"这类场景。

4. Function.prototype 与 Object.prototype 互相搞混

  • 现象:被问"Function.prototype 是谁的原型"时答成 Object,或认为 Object 的原型是 Object.prototype。
  • 原因:函数链和对象链两条线混着记,没有分开画。
  • 修法:把两个交叉点单独抄在一张纸上——Object.__proto__ === Function.prototype、Function.prototype.constructor === Object,每次画图后对照检查。

5. 闭包里循环变量取到同一个值

  • 现象:for 循环给一组元素绑事件,点哪个元素输出的索引都是最后一个。
  • 原因:var 没有块级作用域,所有回调共享同一个循环变量,闭包保存的是变量本身而非当次的值。
  • 修法:用 let 声明循环变量、改用 forEach,或用立即执行函数给每次迭代造一个独立作用域。

6. 把执行上下文当作用域用

  • 现象:认为"函数调用完作用域就没了",或认为同一函数第二次调用能看到第一次的局部变量。
  • 原因:执行上下文是动态的(调一次建一个),作用域是静态的(定义位置决定),两者混为一谈。
  • 修法:分析代码时按顺序问两句——"这个变量的作用域在哪定"、"这次调用新建了哪些东西"。

7. 误以为局部变量必然被回收

  • 现象:以为函数执行完局部变量立刻销毁,于是写出依赖该假设的代码。
  • 原因:闭包让内部函数持有对外层变量的引用,引用计数不为零,数据不会被回收。
  • 修法:在设计阶段就标出哪些数据被闭包"续命",不用时主动把引用置为 null。

练习 ​

1. 画图题 给出代码:

js
function Foo() {}
var f1 = new Foo();
var arr = [];

画出 f1、Foo、Foo.prototype、Object.prototype、Function.prototype 之间的全部指向关系,并单独标出每一处 constructor 的指向。画完后用一句话解释:为什么 Foo.prototype.constructor 指向 Foo,而 Object.prototype.constructor 指向 Object 却不能说明"它自己的构造函数是 Object"。

2. 判断题(逐条判对错并说明理由)

  • ① Foo.prototype.constructor === Foo
  • ② f1.constructor === Foo
  • ③ Function.prototype.constructor === Function
  • ④ arr.prototype 有值
  • ⑤ arr.__proto__.constructor === Array
  • ⑥ Object.__proto__ === Object.prototype

3. 实现继承 定义构造函数 Person(name) 和 Student(name, sid),要求 Student 的实例能访问 Person.prototype 上的 sayHi 方法,同时保证:

  • new Student('a', 1).constructor === Student
  • new Student('a', 1) instanceof Person 为 true

写出完整代码,并说明你为什么要额外修一次 constructor。

4. 改错题

js
var items = document.querySelectorAll('li');
for (var i = 0; i < items.length; i++) {
  items[i].onclick = function () {
    console.log(i);
  };
}

指出问题根因,并用三种不同方式修复(分别用 let、forEach、立即执行函数)。

5. 输出预测

js
function counter() {
  var n = 0;
  return function () { return ++n; };
}
var a = counter();
var b = counter();
console.log(a(), a(), b());

写出输出结果,并分别从"执行上下文"和"闭包"两个角度各用一句话解释为什么是这个结果。

原视频对照 ​

#视频时长
1回顾08:24
2原型链回顾15:38
3原型链图例18:29
4原型链总结03:26
5JS继承的特点10:12
6js中的父类子类17:55

小结 ​

  1. prototype 是函数专属属性,描述的是"该构造函数造出来的实例的原型",不是自己的原型;取自己的原型只能用 __proto__。
  2. constructor 不可靠:原型对象上那个 constructor 是为实例准备的,不是自我描述,类型判断请交给 instanceof 和 Object.prototype.toString.call。
  3. 整条原型体系里只有两处交叉会让人"看着别扭"——Object.__proto__ === Function.prototype 与 Function.prototype.constructor === Object,其余位置都不该出现这种互相指认。
  4. 执行上下文是动态的,一次调用建一个;作用域是静态的,写在哪就定在哪。判断变量归属先看作用域,判断创建时机才看执行上下文。
  5. 闭包的本质是内部函数持有外层作用域的引用,因此它同时决定了"数据能活多久"和"回调能拿到什么值"。
  6. 循环里闭包取错值,根因永远是"多个回调共享同一个变量",let / forEach / IIFE 三种方案本质上都是在给每次迭代造独立作用域。
  7. 用原型链去对照其他语言的继承时,把"子类的实例以父类的实例为原型"当作唯一的判断依据,其余类比都是方便交流的说法,不是 JS 的语法事实。
  8. 判断一个构造函数是不是另一个的"子类",看的是它 prototype 那一层是否落在对方的原型链上,而不是看名字、看代码里有没有继承的写法。