原型与继承
一、核心概念
JavaScript 是基于原型的语言,而不是基于类的语言(虽然 ES6 引入了 class,但本质上是语法糖)。理解原型是掌握 JS 面向对象编程的基础。
1. 三个关键属性
| 属性 | 宿主 | 指向 | 描述 |
|---|---|---|---|
prototype | 函数 | 原型对象 | 只有函数才有此属性。它指向该函数创建的实例的原型。 |
__proto__ | 对象 | 原型对象 | 所有对象(除 null)都有。指向创建该对象的构造函数的 prototype。 |
constructor | 原型对象 | 构造函数 | 原型对象通过此属性指回其构造函数。 |
2. 关系图解
function Foo() {}
const f1 = new Foo();
console.log(f1.__proto__ === Foo.prototype); // true
console.log(Foo.prototype.constructor === Foo); // true
// 实例本身没有 constructor,沿着原型链找到 Foo.prototype.constructor
console.log(f1.constructor === Foo); // true注意:
__proto__是非标准属性(虽然浏览器广泛支持)。推荐使用Object.getPrototypeOf(obj)和Object.setPrototypeOf(obj, proto)(后者有性能损耗,建议Object.create)。
二、原型链
1. 查找机制
当访问对象的一个属性时,JavaScript 引擎会按照以下顺序查找:
- 自身属性: 检查对象自身是否有该属性。
- 原型链: 如果没有,则沿着
__proto__指向的原型对象查找。 - 层层向上: 重复步骤 2,直到找到或到达原型链尽头。
- 尽头: 原型链的顶端是
Object.prototype。Object.prototype.__proto__为null。
2. 属性遮蔽 (Shadowing)
如果在实例上设置了与原型同名的属性,实例属性会遮蔽原型属性,而不会修改原型属性。
function Person() {}
Person.prototype.name = 'Origin';
const p1 = new Person();
console.log(p1.name); // 'Origin' (来自原型)
p1.name = 'Own'; // 在实例上添加属性
console.log(p1.name); // 'Own' (遮蔽原型)
console.log(p1.__proto__.name); // 'Origin' (原型未变)三、创建对象与原型
1. new 操作符原理
new 关键字背后做了 4 件事:
- 创建一个空的简单 JavaScript 对象(即
{})。 - 将这个新对象的
__proto__链接到构造函数的prototype。 - 将构造函数中的
this绑定到新对象,并执行构造函数。 - 如果构造函数返回非空对象,则返回该对象;否则返回新对象。
手写 new:
function myNew(Constructor, ...args) {
// 1. 创建新对象,关联原型
const obj = Object.create(Constructor.prototype);
// 2. 绑定 this 执行构造函数
const result = Constructor.apply(obj, args);
// 3. 处理返回值
return (result && typeof result === 'object') ? result : obj;
}四、继承方式详解
1. 原型链继承
将父类的实例作为子类的原型。
Child.prototype = new Parent();- 缺点1: (严重) 引用类型的属性被所有子类实例共享。
- 缺点2: 创建子类实例时,无法向父类构造函数传参。
2. 构造函数继承 (借助 call)
在子类构造函数中调用父类构造函数。
function Child() {
Parent.call(this, ...arguments);
}- 优点: 解决了引用属性共享问题;可以传参。
- 缺点: 只能继承父类实例属性,无法继承父类原型上的方法。每次都要在构造函数中定义方法,无法复用。
3. 组合继承 (常用)
结合上述两者。
function Child(name) {
Parent.call(this, name); // 第二次调用 Parent
}
Child.prototype = new Parent(); // 第一次调用 Parent
Child.prototype.constructor = Child;- 优点: 融合了前两者的优点。
- 缺点: 调用了两次父类构造函数,产生两份实例属性(一份在实例上,一份在原型上)。
4. 寄生组合式继承 (推荐 ES5)
只调用一次父类构造函数,通过 Object.create 继承原型。这是最成熟的 ES5 继承方案。
function inheritPrototype(subType, superType) {
// 创建父类原型的一个副本
const prototype = Object.create(superType.prototype);
// 修正 constructor 指向
prototype.constructor = subType;
// 将副本赋值给子类原型
subType.prototype = prototype;
}
function Child(name) {
Parent.call(this, name); // 只用这一种方式继承实例属性
}
inheritPrototype(Child, Parent);5. ES6 Class 继承 (现代标准)
语法糖,底层逻辑与寄生组合式继承类似。
class Parent {
constructor(name) {
this.name = name;
}
sayHello() {
console.log('Hello');
}
}
class Child extends Parent {
constructor(name, age) {
super(name); // 必须先调用 super()
this.age = age;
}
}- 区别: Class 内部定义的方法是不可枚举的;Class 必须使用
new调用;不存在变量提升。
五、类型检测
1. instanceof 原理
检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。
手写 instanceof:
function myInstanceof(left, right) {
let proto = Object.getPrototypeOf(left);
const prototype = right.prototype;
while (true) {
if (!proto) return false;
if (proto === prototype) return true;
proto = Object.getPrototypeOf(proto);
}
}2. isPrototypeOf
Object.prototype.isPrototypeOf() 用于测试一个对象是否存在于另一个对象的原型链上。
Parent.prototype.isPrototypeOf(child); // true六、面试高频题
Q1: 解释一下原型链?
参考答案: JavaScript 中每个对象都有一个 __proto__ 属性指向其原型对象,原型对象也有自己的 __proto__,层层向上直到 Object.prototype(其 __proto__ 为 null)。当访问对象的属性时,JS 引擎会先在这个对象自身上查找,如果没有找到,就会沿着 __proto__ 链向上查找,直到找到该属性或到达链尾。这条链就叫原型链。
Q2: 箭头函数有原型吗?
参考答案: 箭头函数没有 prototype 属性,也不能作为构造函数(使用 new 会报错)。它也没有自己的 this,而是捕获定义时上下文的 this。
Q3: Object.create(null) 创建了什么?
参考答案: 创建了一个没有原型的空对象。它不继承 Object.prototype 上的任何属性或方法(如 toString, hasOwnProperty)。常用于创建纯净的字典对象,防止原型污染。