原型链与继承

15 分钟

为什么原型链是面试必考题

原型链不只是一道"画关系图"的题目。它是 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 上。

原型链的查找机制

当访问一个对象的属性时,引擎按以下路径查找:

  1. 先在对象自身的属性中查找。
  2. 找不到,沿 __proto__ 到原型对象上查找。
  3. 还找不到,继续沿原型对象的 __proto__ 向上查找。
  4. 直到 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。这就是为什么任意对象都能访问 toStringvalueOf 等方法——它们定义在 Object.prototype 上。

性能注意点

原型链越长,属性查找需要遍历的层级越多。实际开发中很少需要超过 2-3 层的原型链。如果某个属性需要被频繁访问且存在于链的深层,可以考虑在实例上缓存。

new 操作符执行的 4 个步骤

new Foo() 在引擎内部做了这四件事:

  1. 创建一个空对象,将其 __proto__ 指向 Foo.prototype
  2. 将这个空对象作为 this,执行构造函数 Foo
  3. 执行构造函数体内的代码,给 this 添加属性。
  4. 判断返回值:如果构造函数显式返回了一个对象,则使用该对象作为 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 classextends + 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) 会创建一个没有原型的纯净对象,常用于做字典/映射,避免 toStringhasOwnProperty 等继承属性的干扰。

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.prototypeObject.prototype 注入属性时,所有对象都会受到影响。常见于不安全的深拷贝、对象合并操作中。防范手段包括:冻结 Object.prototype、使用 Object.create(null) 创建字典、对输入做 key 校验过滤 __proto__constructor

总结

  • prototype 是函数的属性,指向原型对象;__proto__ 是对象的属性,指向其构造函数的 prototype。
  • 原型链查找沿 __proto__ 逐级向上,终点是 null
  • new 做了四件事:创建对象、链接原型、执行构造函数、处理返回值。
  • instanceof 本质是沿原型链查找 prototype
  • 继承方式从原型链继承一路演进到寄生组合继承,ES6 class 是其语法糖。
  • 判断属性归属用 hasOwnPropertyObject.hasOwn,遍历自身属性用 Object.keys()