原型链与继承
为什么原型链是面试必考题
原型链不只是一道"画关系图"的题目。它是 JavaScript 对象系统的根基——理解原型链,才能说清楚继承如何实现、new 做了什么、instanceof 怎么判断类型、属性查找为什么有性能代价。
面试中常见的翻车场景:
- 能背出
__proto__指向构造函数的prototype,但画不出完整的三角关系图。 - 知道 new 会创建对象,但说不清第三步为什么要执行构造函数。
- 各种继承方式只能说出名字,讲不清每种方式解决了前一种的什么问题。
这篇文章的思路:先把原型链的基础概念和查找机制讲透,再拆解 new 操作符,最后逐步推演各种继承方式的演进逻辑。
prototype 与 __proto__ 的关系
先明确三个角色:构造函数、实例、原型对象。
function Person(name) {
this.name = name;
}
const p = new Person('张三');
它们之间的指向关系:
Person.prototype— 构造函数的 prototype 属性,指向原型对象。p.__proto__— 实例的隐式原型,指向构造函数的 prototype。Person.prototype.constructor— 原型对象的 constructor 属性,指回构造函数。
用等式表达:
p.__proto__ === Person.prototype // true
Person.prototype.constructor === Person // true
Object.getPrototypeOf(p) === Person.prototype // true
注意:__proto__ 是非标准属性(虽然主流引擎都实现了),规范推荐使用 Object.getPrototypeOf() 获取对象的原型。
构造函数 / 实例 / 原型的三角关系
构造函数 Person
|
| .prototype
v
原型对象 Person.prototype <----.
| |
| .constructor |
'---------> Person |
|
实例 p |
| |
| .__proto__ |
'--------------------------'
每个函数在创建时,引擎自动为其生成一个 prototype 对象,并在该对象上挂一个 constructor 属性指回函数自身。通过 new 创建的实例,其 __proto__ 会被链接到构造函数的 prototype 上。
原型链的查找机制
当访问一个对象的属性时,引擎按以下路径查找:
- 先在对象自身的属性中查找。
- 找不到,沿
__proto__到原型对象上查找。 - 还找不到,继续沿原型对象的
__proto__向上查找。 - 直到
Object.prototype.__proto__(即null),查找结束,返回undefined。
function Animal(type) {
this.type = type;
}
Animal.prototype.eat = function() {
return `${this.type} is eating`;
};
function Dog(name) {
Animal.call(this, 'dog');
this.name = name;
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.bark = function() {
return `${this.name} says woof`;
};
const d = new Dog('旺财');
// 查找 d.name → 自身属性,找到
// 查找 d.bark → 自身没有 → Dog.prototype 上,找到
// 查找 d.eat → 自身没有 → Dog.prototype 没有 → Animal.prototype 上,找到
// 查找 d.toString → 一路到 Object.prototype 上,找到
// 查找 d.foo → 一路到 null,返回 undefined
原型链的终点
Object.prototype.__proto__ === null // true
所有原型链最终都会到达 Object.prototype,再往上就是 null。这就是为什么任意对象都能访问 toString、valueOf 等方法——它们定义在 Object.prototype 上。
性能注意点
原型链越长,属性查找需要遍历的层级越多。实际开发中很少需要超过 2-3 层的原型链。如果某个属性需要被频繁访问且存在于链的深层,可以考虑在实例上缓存。
new 操作符执行的 4 个步骤
new Foo() 在引擎内部做了这四件事:
- 创建一个空对象,将其
__proto__指向Foo.prototype。 - 将这个空对象作为 this,执行构造函数
Foo。 - 执行构造函数体内的代码,给 this 添加属性。
- 判断返回值:如果构造函数显式返回了一个对象,则使用该对象作为 new 的结果;否则返回第一步创建的对象。
手写 new
function myNew(Constructor, ...args) {
// 1. 创建空对象,链接原型
const obj = Object.create(Constructor.prototype);
// 2 & 3. 执行构造函数,绑定 this
const result = Constructor.apply(obj, args);
// 4. 判断返回值
return result instanceof Object ? result : obj;
}
// 验证
function Car(brand) {
this.brand = brand;
}
Car.prototype.drive = function() {
return `${this.brand} is running`;
};
const car = myNew(Car, 'Toyota');
console.log(car.brand); // 'Toyota'
console.log(car.drive()); // 'Toyota is running'
console.log(car instanceof Car); // true
面试追问:构造函数返回值的影响
function Foo() {
this.a = 1;
return { b: 2 }; // 显式返回一个对象
}
const f = new Foo();
console.log(f.a); // undefined
console.log(f.b); // 2
如果构造函数返回一个非对象类型(number、string、boolean、undefined、null),返回值会被忽略,new 仍然返回 this 对象。只有返回引用类型时,才会覆盖默认行为。
instanceof 原理
a instanceof B 的判断逻辑:沿着 a.__proto__ 链向上查找,看是否能找到 B.prototype。
function myInstanceof(left, right) {
let proto = Object.getPrototypeOf(left);
const target = right.prototype;
while (proto !== null) {
if (proto === target) return true;
proto = Object.getPrototypeOf(proto);
}
return false;
}
这解释了为什么修改 prototype 后,之前创建的实例 instanceof 结果会变:
function Foo() {}
const f = new Foo();
console.log(f instanceof Foo); // true
Foo.prototype = {}; // 替换了 prototype
console.log(f instanceof Foo); // false — f.__proto__ 还指向旧的原型对象
继承方式对比
JavaScript 的继承方式经历了逐步演进的过程,每种方式都是为了解决前一种的缺陷。
1. 原型链继承
核心思路:让子类的 prototype 指向父类的实例。
function Parent() {
this.colors = ['red', 'blue'];
}
Parent.prototype.getColors = function() {
return this.colors;
};
function Child() {}
Child.prototype = new Parent();
const c1 = new Child();
const c2 = new Child();
c1.colors.push('green');
console.log(c2.colors); // ['red', 'blue', 'green'] — 引用类型被共享了
问题:
- 父类实例属性变成子类原型属性,所有子类实例共享引用类型数据。
- 创建子类实例时无法向父类构造函数传参。
2. 构造函数继承(借用构造函数)
核心思路:在子类构造函数中调用父类构造函数。
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.getName = function() {
return this.name;
};
function Child(name, age) {
Parent.call(this, name); // 借用父类构造函数
this.age = age;
}
const c1 = new Child('张三', 18);
const c2 = new Child('李四', 20);
c1.colors.push('green');
console.log(c2.colors); // ['red', 'blue'] — 每个实例独立
console.log(c1.getName); // undefined — 拿不到原型上的方法
解决了:引用类型共享问题、可以传参。
新问题:父类原型上的方法对子类不可见,方法只能写在构造函数里(无法复用)。
3. 组合继承
核心思路:原型链继承 + 构造函数继承,取两者之长。
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.getName = function() {
return this.name;
};
function Child(name, age) {
Parent.call(this, name); // 第二次调用 Parent
this.age = age;
}
Child.prototype = new Parent(); // 第一次调用 Parent
Child.prototype.constructor = Child;
const c = new Child('张三', 18);
console.log(c.getName()); // '张三'
console.log(c.colors); // ['red', 'blue'] — 实例独立
解决了:既能继承原型方法,又不会共享引用类型。
遗留问题:父类构造函数被调用了两次。Child.prototype 上会存在一份多余的父类实例属性(被实例自身属性覆盖,但确实存在)。
4. 寄生组合继承(最优方案)
核心思路:用 Object.create() 替代 new Parent() 来创建子类原型,避免多余的父类构造函数调用。
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.getName = function() {
return this.name;
};
function Child(name, age) {
Parent.call(this, name);
this.age = age;
}
// 关键:用 Object.create 创建一个以 Parent.prototype 为原型的对象
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
Child.prototype.getAge = function() {
return this.age;
};
const c = new Child('张三', 18);
console.log(c.getName()); // '张三'
console.log(c.getAge()); // 18
console.log(c instanceof Parent); // true
console.log(c instanceof Child); // true
优势:
- 父类构造函数只调用一次。
- 原型链完整,instanceof 正常工作。
- 子类原型上没有多余的父类实例属性。
这是 ES6 class 出现之前最被推荐的继承方式。
5. ES6 class 继承
class 本质上是寄生组合继承的语法糖,底层机制相同,但写法更清晰:
class Parent {
constructor(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
getName() {
return this.name;
}
}
class Child extends Parent {
constructor(name, age) {
super(name); // 必须先调用 super
this.age = age;
}
getAge() {
return this.age;
}
}
const c = new Child('张三', 18);
console.log(c.getName()); // '张三'
console.log(c.getAge()); // 18
与 ES5 继承的区别:
- ES5 先创建子类实例 this,再通过
Parent.call(this)增强。 - ES6 先通过
super()创建父类实例(this 由父类构造),子类构造函数再修改 this。 - 这就是为什么
super()必须在访问 this 之前调用。
继承方式总结对比
| 方式 | 核心实现 | 优点 | 缺点 |
|---|---|---|---|
| 原型链继承 | Child.prototype = new Parent() | 简单直观 | 引用类型共享、无法传参 |
| 构造函数继承 | Parent.call(this) | 独立属性、可传参 | 无法继承原型方法 |
| 组合继承 | 原型链 + 构造函数 | 功能完整 | 父类构造函数执行两次 |
| 寄生组合继承 | Object.create(Parent.prototype) + Parent.call(this) | 只调一次、无冗余属性 | 写法稍复杂 |
| ES6 class | extends + super() | 语义清晰、标准写法 | 本质是语法糖 |
Object.create() 的作用
Object.create(proto) 创建一个新对象,将其 __proto__ 指向传入的 proto 参数。
const parent = {
greet() {
return `Hello, I'm ${this.name}`;
}
};
const child = Object.create(parent);
child.name = 'Tom';
console.log(child.greet()); // "Hello, I'm Tom"
console.log(child.__proto__ === parent); // true
手写简化版:
function objectCreate(proto) {
function F() {}
F.prototype = proto;
return new F();
}
Object.create(null) 会创建一个没有原型的纯净对象,常用于做字典/映射,避免 toString、hasOwnProperty 等继承属性的干扰。
hasOwnProperty vs in
这两个用来判断属性是否存在,区别在于是否查找原型链:
function Person(name) {
this.name = name;
}
Person.prototype.sayHi = function() {};
const p = new Person('张三');
// hasOwnProperty:只查自身属性
p.hasOwnProperty('name'); // true
p.hasOwnProperty('sayHi'); // false
// in:查自身 + 原型链
'name' in p; // true
'sayHi' in p; // true
'toString' in p; // true — 来自 Object.prototype
使用场景:
- 遍历对象自身属性时,
for...in会遍历原型链上的可枚举属性,通常需要配合hasOwnProperty过滤。 - 现代代码更推荐使用
Object.keys()、Object.entries(),它们只返回自身可枚举属性。
// 传统写法
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
// ...
}
}
// 现代写法
for (const key of Object.keys(obj)) {
// ...
}
注意:如果对象是通过 Object.create(null) 创建的,它没有 hasOwnProperty 方法。安全的做法是用 Object.prototype.hasOwnProperty.call(obj, key) 或 ES2022 的 Object.hasOwn(obj, key)。
高频面试追问
Q:所有函数都有 prototype 吗?
箭头函数没有 prototype 属性,因此不能用作构造函数,不能被 new 调用。
const Fn = () => {};
console.log(Fn.prototype); // undefined
new Fn(); // TypeError: Fn is not a constructor
Q:Function.__proto__ 指向谁?
Function.__proto__ === Function.prototype // true
Function 是自身的构造函数,这是语言规范层面的设定,属于"鸡生蛋"的问题。
Q:修改 prototype 和修改 prototype 上的属性有什么区别?
function Foo() {}
const f = new Foo();
// 修改 prototype 上的属性 — 已有实例能感知
Foo.prototype.bar = 'hello';
console.log(f.bar); // 'hello'
// 替换整个 prototype — 已有实例不受影响
Foo.prototype = { baz: 'world' };
console.log(f.bar); // 'hello' — f.__proto__ 仍指向旧对象
console.log(f.baz); // undefined
Q:如何实现一个不能被继承的类?
class Final {
constructor() {
if (new.target !== Final) {
throw new Error('Final class cannot be extended');
}
}
}
Q:原型链污染是什么?
当攻击者能通过 __proto__ 或 constructor.prototype 向 Object.prototype 注入属性时,所有对象都会受到影响。常见于不安全的深拷贝、对象合并操作中。防范手段包括:冻结 Object.prototype、使用 Object.create(null) 创建字典、对输入做 key 校验过滤 __proto__ 和 constructor。
总结
- prototype 是函数的属性,指向原型对象;__proto__ 是对象的属性,指向其构造函数的 prototype。
- 原型链查找沿
__proto__逐级向上,终点是null。 - new 做了四件事:创建对象、链接原型、执行构造函数、处理返回值。
- instanceof 本质是沿原型链查找
prototype。 - 继承方式从原型链继承一路演进到寄生组合继承,ES6 class 是其语法糖。
- 判断属性归属用
hasOwnProperty或Object.hasOwn,遍历自身属性用Object.keys()。