本节摘要:JavaScript 的继承是一条「原型链」——每个对象内部持有一个指向另一个对象的引用(原型),属性查不到就沿它上溯,直到命中或抵达 null。本节把
student.fullName的一次访问拆成逐帧回放,理清 new 的四步动作、prototype 与__proto__的分工、class 语法的糖衣成分,以及 instanceof 的判定原理,最后给出继承方案的工程取舍。
代码与实测:
function Student(name, grade) { this.name = name; this.grade = grade; } Student.prototype.introduce = function () { return `我是${this.grade}的${this.name}`; }; const student = new Student('小明', '三年级'); console.log(student.name); // 小明(自身属性) console.log(student.introduce()); // 我是三年级的小明(原型上的方法) console.log(student.toString()); // [object Object](Object 原型上的方法) console.log(student.nope); // undefined(爬到 null 也没找到)
四个输出对应四种典型落点。引擎处理 student.introduce 的完整路线是:
第一帧:翻本体。 student 自己的属性表里有 name、grade,没有 introduce。未命中。
第二帧:沿内部原型引用上爬一层。 到 Student.prototype,这里有 introduce。命中,取出。若是方法调用,this 绑定给 student(第 2 章的隐式绑定规则)。
第三帧(如果第二帧未命中):继续上爬。 Student.prototype 自己也是一个普通对象,它的原型是 Object.prototype。toString 就住在这里。
第四帧:抵达链顶。 Object.prototype 的原型是 null。查值返回 undefined;当方法调用则抛 TypeError: student.nope is not a function——报错文案里的「is not a function」正说明引擎已经确认链上没有这个名字,才在你调用时定性为类型错误。

new Student(...) 那一刻引擎做四件事:造空对象;把它内部的原型引用接到 Student.prototype;以它为 this 执行函数体;函数体没返回对象就默认返回它。第 2 章 this 判定的「new 绑定」正是第三步。
两个名词各管一头:prototype 是函数对象身上的属性,只有作为构造器使用时才有意义,它是「未来实例的原型」;__proto__(规范名 [[Prototype]])是每个对象身上的内部引用,指「我自己的原型」。关系一句话:student.__proto__ === Student.prototype 为 true。__proto__ 这个写法是历史遗留的访问器,正式代码用 Object.getPrototypeOf 与 Object.setPrototypeOf。
实测验证整条链:
console.log(Object.getPrototypeOf(student) === Student.prototype); // true console.log(Object.getPrototypeOf(Student.prototype) === Object.prototype); // true console.log(Object.getPrototypeOf(Object.prototype)); // null console.log(student.constructor === Student); // true
最后一行的 constructor 也不是什么特殊连接——它只是 Student.prototype 上的一个默认属性,指回 Student 函数。手工改原型会把这个属性弄丢:
Student.prototype = { introduce() { return '改版'; } }; console.log(new Student('小红','一年级').constructor === Student); // false(新原型上没有 constructor)
class StudentC { constructor(name, grade) { this.name = name; this.grade = grade; } introduce() { return `我是${this.grade}的${this.name}`; } } const sc = new StudentC('小明', '三年级'); console.log(Object.getPrototypeOf(sc) === StudentC.prototype); // true console.log(sc.introduce()); // 我是三年级的小明
输出与函数构造器版完全一致——class 没有引入新的继承机制,方法仍然挂在 prototype 上(introduce 是 StudentC.prototype 的属性)。但糖衣里有几粒真药:class 内部默认严格模式;不写 new 直接调用立刻报错;方法不可枚举(for...in 干净);extends 与 super 处理原型接线比手工 setPrototypeOf 少踩坑。所以新代码一律 class,读旧代码要能翻译回原型语言。
继承的接线用 extends 完成后,链长这样:
class Person { hello() { return '你好'; } } class Pupil extends Person { bye() { return '再见'; } } const p = new Pupil(); console.log(p.hello()); // 你好(Pupil.prototype 未命中 → Person.prototype) console.log(Object.getPrototypeOf(Pupil.prototype) === Person.prototype); // true
注意这行断言:extends 接的是「原型对象之间的链」(Pupil.prototype 的原型是 Person.prototype),而不是构造器之间的链——虽然构造器之间也另有一条(Pupil 的原型是 Person,用于静态方法继承),两条链别混。
instanceof 的原理现在可以一句话讲清:沿 p 的原型链逐级上爬,途中任何一站等于右侧构造器的 prototype 就返回 true,爬到 null 返回 false。所以 p instanceof Person 与 p instanceof Object 都为 true。自定义的 Symbol.hasInstance 可以改写这个行为,框架里「鸭子类型检查」偶尔用它。
链上同名属性,低层遮蔽高层——引擎在第一帧命中就停:
const base = { greet() { return 'base'; } }; const derived = Object.create(base); derived.greet = function () { return `derived + ${base.greet.call(this)}`; }; console.log(derived.greet()); // derived + base
这就是「方法重写 + super 调用」的原型语言版。日常要警惕的遮蔽事故是原型污染:往 Object.prototype 上塞属性,所有对象的 for...in 都会多出一项,且可能遮蔽业务字段——这是安全与稳定双输的操作,任何场景都别做。
继承方案的取舍按场景定:组合优于继承仍是第一原则,能用水组合(对象里放别的对象的引用)就别拉长链;需要 instanceof 语义或大量共享方法时用 class extends;运行时动态改原型(setPrototypeOf)会让引擎的隐藏类机制完全失效(下一节),只用于极特殊的元编程场景。Object.create(null) 造出的「无原型对象」适合当纯净字典用——没有 toString 等继承成员,被任意键碰撞的风险最低。
__proto__ 归实例,constructor 只是默认回指属性,改原型会弄丢;把两个语言机制翻译成普通代码,是对「链」的理解最硬的检验。
instanceof 的手工版——沿链逐站比对:
function myInstanceof(obj, Ctor) { if (obj === null || typeof obj !== 'object' && typeof obj !== 'function') { return false; // 原始值没有原型链 } let proto = Object.getPrototypeOf(obj); const target = Ctor.prototype; while (proto !== null) { if (proto === target) return true; // 途中任何一站命中 proto = Object.getPrototypeOf(proto); } return false; // 爬到 null 未命中 } class A {} class B extends A {} console.log(myInstanceof(new B(), B)); // true console.log(myInstanceof(new B(), A)); // true console.log(myInstanceof({}, A)); // false console.log(myInstanceof('str', A)); // false(原始值直接出局)
注意原始值的短路处理——字符串、数字这些没有内部原型引用(它们的「原型」访问是装箱的语法便利),比对无从谈起。这也解释了一个经典冷知识:Object.getPrototypeOf('str') 能拿到 String.prototype,但 'str' instanceof String 是 false——装箱发生在属性访问那一刻,instanceof 检查的是对象本体。
Object.create 的极简版——造一个指定原型的空对象:
function myCreate(proto) { function F() {} // 临时构造器 F.prototype = proto; // 原型接到目标 return new F(); // new 的第二步自动完成接线 } const base = { hello() { return '你好'; } }; const child = myCreate(base); console.log(child.hello()); // 你好 console.log(Object.getPrototypeOf(child) === base); // true
四行的 F 函数就是 2011 年前社区的标准 polyfill,也顺便演示了「new 的第二步」如何被单独借用。两个实现合计二十行,却把第 2 章(new 绑定)与本章的机制全部串动了一遍。
怎么判断一个对象是不是数组?
Array.isArray,唯一可靠答案。instanceof Array 在跨 iframe 或跨领域(比如另一个全局环境传来的数组)会失手——每个 iframe 有自己的 Array 构造器,链对不上。同理,判断普通对象别用 instanceof Object(几乎万物皆真),用 typeof 加构造器组合判断。
原型链能有多长?性能会差吗?
常态两三层(实例 → 构造器原型 → Object 原型),深度继承再叠几层。每深一层,未命中的查找多爬一站,但引擎的内联缓存(下一节)把命中路径压缩到近似常数。要担心的不是深度,是「运行时改原型」——它让缓存与优化全部作废。设计上的建议仍然是组合优于继承:链表达「是」的关系,超过三四层的「是」通常是错把「用」当成了「是」。
constructor 属性能用来判断类型或克隆对象吗?
都不能。constructor 只是原型上的默认回指,改写原型、跨领域传值、Object.create 手工接线都会让它失真甚至丢失。类型判断用 isArray、typeof、Symbol.toStringTag;「按构造器再造一个」的需求显式传构造器引用,别走这个侧门。
原型语言里的继承方案演进了一代又一代,每代都在修上一代的缺陷。对照着看,能同时理解「为什么 class 长这样」与老代码里的各路写法:
| 方案 | 核心动作 | 优点 | 缺陷 |
|---|---|---|---|
| 原型链继承 | Child.prototype = new Parent() | 一行接线 | 引用类型的属性被所有实例共享;无法向父构造器传参 |
| 构造器借用 | Parent.call(this, args) | 实例属性独立、可传参 | 原型上的方法拿不到,函数无法复用 |
| 组合继承 | 两者都用 | 互相补位 | 父构造器被调用两次,原型上留冗余属性 |
| 寄生组合 | Object.create 接原型加 call 借构造器 | 语义最接近 class | 手工接线冗长,容易写错 |
| class extends | 语法声明 | 接线自动化、super 明确、静态方法继承 | 本质同上,仍是原型链 |
用最小代码看「共享陷阱」——原型链继承的经典伤口:
function Parent() { this.hobbies = ['阅读']; } function Child() {} Child.prototype = new Parent(); const c1 = new Child(), c2 = new Child(); c1.hobbies.push('游泳'); console.log(c2.hobbies); // [ '阅读', '游泳' ] —— c2 被 c1 连带改了
c1 与 c2 沿链找到的是同一个数组(Parent 实例上的属性),push 改的是共享本体。构造器借用(在 Child 里 Parent.call(this))让每个实例自己持有 hobbies,又引入方法不可复用的缺陷——于是组合、寄生组合逐代打补丁,直到 class 把「正确接线」固化成语法。读老代码见到这四种手工方案,对照表的缺陷列就是它们的翻译注释。
Object.create(null) 的纯净字典用法也值得记一笔:无原型的对象没有继承成员,for...in 干净、键碰撞风险最低,适合当数据字典与查找表;代价是没有任何方法(连 toString 都没有),调试打印难看,别拿它当普通对象用。
原型链和作用域链会不会互相干扰?
不会,两套链查的东西不同:作用域链管「变量名去哪找」(第 2 章,定义时焊死);原型链管「对象的属性去哪找」(本节,沿对象间引用攀爬)。一次 obj.name 的完整查找是先走作用域链找到 obj 这个变量,再沿 obj 的原型链找 name——两棒接力,各跑各的。混淆两者的人常犯的错是「在原型上放一个变量希望作用域查到」或反之,记住接力顺序就不会绕进去。
操作链的内置接口不多,但每个都有「正用」与「滥用」的边界,一张清单管住它们:
Object.getPrototypeOf 与 Reflect.getPrototypeOf:读原型的正规姿势,取代 __proto__ 访问器。正用于检测与调试(本章多处断言用的就是它);滥用是拿它做「类型判断循环」(有 isArray 与 instanceof 就别手搓)。
Object.setPrototypeOf:运行时改原型。语义上等价于「给活体做手术」——隐藏类、内联缓存全部作废(第 3.2 节),非万不得已不用。同样的结构需求,用 Object.create 在出生时就接好,代价为零。
Object.create(proto, descriptors):以指定原型造新对象,第二个参数还能顺带定义属性描述符。它既是「原型链继承的干净积木」,也是「无原型对象」的开关(传 null)。
Object.defineProperty 与 Object.getOwnPropertyDescriptors:精确定义属性(可写、可枚举、取值器)。框架与库的底层工具;业务代码里出现取值器时优先考虑「方法调用更显式」的可读性取舍——getter 把成本藏进了属性访问,热路径里可能被无感放大。
Object.hasOwn 与 hasOwnProperty:判断「自身属性」而非「链上属性」。新方法 hasOwn 不怕对象没原型(create(null) 造的东西调不了继承来的 hasOwnProperty),新代码一律用它;for...in 配它过滤自身键是老代码的标准姿势,新代码直接 Object.keys 或 Object.entries 更清爽。
清单的排序本身就是使用频率的排序:前三个日常够用,后两个写库时再深入。共同原则一句话:在对象出生时把链接好,而不是出生后动手术。
原型知识点在面试里的高频组合题是什么?
「手写一个 new」常年霸榜,四步动作直译成函数即可:造空对象、接原型、以它为 this 执行构造器、构造器返回对象则用之否则用新对象。第二高频是「instanceof 的原理与手写」(本节演练版现成);第三是「继承的多种实现与缺陷」(本节对比表全覆盖)。准备这些题的要点不是背代码,而是把「接原型」这个动作在不同姿势里的位置说清楚——所有手写题考的都是同一件事:你知不知道链是怎么接上去的。
下一节我们把镜头从「查得对」推进到「查得快」:引擎为这条攀爬路线配了两件加速器——隐藏类与内联缓存,它们也是全书性能话题的第一块基石。理解它们不需要任何新概念,仍然是「引擎在替你做什么」的老问题——只是这次的对象从查找路径变成了查找速度,而你要学的不过是别亲手拆掉这两件加速器。