原型链(Prototype Chain)与继承:理解`__proto__`和`prototype`的关系,并实现多种基于原型链的继承模式。

JavaScript 原型链与继承:深入理解与实践

大家好,今天我们来深入探讨 JavaScript 中原型链与继承这一核心概念。理解原型链是掌握 JavaScript 的关键,也是理解许多框架和库底层机制的基础。我们将从 __proto__prototype 的关系入手,逐步剖析原型链的工作原理,并探讨几种常见的基于原型链的继承模式,通过代码示例加深理解。

1. __proto__prototype 的关系:基石

要理解原型链,首先必须区分并理解 __proto__prototype 这两个属性。

  • prototype: 这是一个函数才拥有的属性。当你创建一个函数时,JavaScript 会自动为这个函数分配一个 prototype 属性,这个 prototype 本身也是一个对象。prototype 对象默认包含一个 constructor 属性,指向该函数本身。prototype 的作用是,当这个函数作为构造函数被 new 调用时,通过 new 创建出来的实例对象,其内部会隐式地指向构造函数的 prototype 对象。

  • __proto__: 这是一个对象才拥有的属性。任何对象(除了 null)都有 __proto__ 属性。这个属性指向创建该对象的构造函数的 prototype 对象。 准确的说, __proto__ 属性在ECMAScript标准中并没有被标准化,属于浏览器实现。但是现代浏览器都支持这个属性。 Object.getPrototypeOf()Object.setPrototypeOf() 方法才是获取和设置对象原型更推荐的方法。

可以用一张表来概括:

属性 所属类型 指向 作用
prototype 函数 一个对象 (默认情况下,该对象包含一个 constructor 属性,指向该函数) 定义了使用 new 关键字创建的实例对象的原型。实例对象可以访问 prototype 对象上的属性和方法。
__proto__ 对象 创建该对象的构造函数的 prototype 对象 构成原型链的关键。允许对象访问其原型对象的属性和方法,并向上查找,直到找到属性或到达原型链的顶端 (null)。本质上,是一个内部链接,指向对象的原型。

一个简单的例子说明:

function Person(name) {
  this.name = name;
}

Person.prototype.sayHello = function() {
  console.log("Hello, my name is " + this.name);
};

let person1 = new Person("Alice");

console.log(person1.__proto__ === Person.prototype); // true
person1.sayHello(); // "Hello, my name is Alice"

在这个例子中,person1Person 函数创建的实例对象。person1.__proto__ 指向 Person.prototype。因此,person1 可以访问 Person.prototype 上定义的 sayHello 方法。

2. 原型链:查找属性的机制

原型链是 JavaScript 实现继承的核心机制。当试图访问一个对象的属性时,JavaScript 引擎会按照以下步骤进行查找:

  1. 首先,检查对象自身是否拥有该属性。
  2. 如果对象自身没有该属性,则查找对象的 __proto__ 指向的原型对象(也就是构造函数的 prototype 对象)。
  3. 如果在原型对象上找到了该属性,则返回该属性的值。
  4. 如果原型对象上仍然没有该属性,则继续查找原型对象的 __proto__ 指向的原型对象,依此类推,直到找到该属性或到达原型链的顶端 (null)。
  5. 如果到达原型链的顶端仍然没有找到该属性,则返回 undefined

这个链式查找的过程就构成了原型链。原型链的顶端是 Object.prototype,而 Object.prototype.__proto__ 指向 null

继续上面的例子,如果我们尝试访问 person1 对象上不存在的属性 age

console.log(person1.age); // undefined

因为 person1 自身没有 age 属性,Person.prototype 也没有 age 属性,而 Person.prototype.__proto__ 指向 Object.prototypeObject.prototype 也没有 age 属性,最后 Object.prototype.__proto__ 指向 null,查找结束,所以返回 undefined

3. 基于原型链的继承模式:多种实现

JavaScript 提供了多种基于原型链的继承模式,每种模式都有其优缺点。下面我们将详细介绍几种常见的继承模式,并提供代码示例。

3.1 原型链继承

这是最基本的继承模式,通过将子类型的 prototype 设置为父类型的实例来实现继承。

function Animal(name) {
  this.name = name;
  this.species = "Animal";
}

Animal.prototype.sayName = function() {
  console.log("My name is " + this.name);
};

function Dog(name, breed) {
    this.breed = breed;
}

// 关键步骤:将 Dog 的 prototype 设置为 Animal 的实例
Dog.prototype = new Animal();

Dog.prototype.bark = function() {
  console.log("Woof!");
};

// 修复 constructor 指向
Dog.prototype.constructor = Dog;

let dog1 = new Dog("Buddy", "Golden Retriever");

dog1.sayName(); // "My name is Buddy" (继承自 Animal)
dog1.bark(); // "Woof!"
console.log(dog1.species); // "Animal" (继承自 Animal)
console.log(dog1 instanceof Dog); // true
console.log(dog1 instanceof Animal); // true
console.log(dog1.constructor === Dog); // true

let animal1 = new Animal("Generic Animal");
//animal1.bark(); //animal1.bark is not a function

优点:

  • 简单易于实现。
  • 父类的方法可以被子类复用。

缺点:

  • 子类型的所有实例共享父类型原型对象的属性和方法。这意味着如果父类型原型对象包含引用类型的属性,那么子类型的所有实例都会共享这个属性,修改一个实例的该属性会影响其他所有实例。
  • 在创建子类型的实例时,无法向父类型的构造函数传递参数。因为 Dog.prototype = new Animal() 已经在 Dog 定义时执行,无法动态传递参数。

3.2 借用构造函数 (Constructor Stealing)

这种模式通过在子类型的构造函数中调用父类型的构造函数来继承父类型的属性。

function Animal(name) {
  this.name = name;
  this.species = "Animal";
}

Animal.prototype.sayName = function() {
  console.log("My name is " + this.name);
};

function Dog(name, breed) {
    // 关键步骤:在 Dog 的构造函数中调用 Animal 的构造函数
  Animal.call(this, name); // 使用 call 或 apply 改变 this 指向
  this.breed = breed;
}

Dog.prototype.bark = function() {
  console.log("Woof!");
};

let dog1 = new Dog("Buddy", "Golden Retriever");

//dog1.sayName(); //dog1.sayName is not a function
console.log(dog1.name); // "Buddy" (继承自 Animal)
console.log(dog1.species); // "Animal" (继承自 Animal)
dog1.bark(); // "Woof!"
console.log(dog1 instanceof Dog); // true
console.log(dog1 instanceof Animal); // false (原型链没有建立)
console.log(dog1.constructor === Dog); // true

优点:

  • 可以在子类型的构造函数中向父类型的构造函数传递参数。
  • 避免了子类型实例共享父类型原型对象的属性。

缺点:

  • 父类型的方法不能被子类型复用。每个子类实例都会有父类的属性副本,造成内存浪费。
  • 无法实现真正的原型链继承,dog1 instanceof Animalfalse

3.3 组合继承 (Combination Inheritance)

组合继承结合了原型链继承和借用构造函数,是 JavaScript 中最常用的继承模式。

function Animal(name) {
  this.name = name;
  this.species = "Animal";
}

Animal.prototype.sayName = function() {
  console.log("My name is " + this.name);
};

function Dog(name, breed) {
  // 借用构造函数,继承属性
  Animal.call(this, name);
  this.breed = breed;
}

// 原型链继承,继承方法
Dog.prototype = new Animal();

// 修复 constructor 指向
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
  console.log("Woof!");
};

let dog1 = new Dog("Buddy", "Golden Retriever");

dog1.sayName(); // "My name is Buddy" (继承自 Animal)
dog1.bark(); // "Woof!"
console.log(dog1.species); // "Animal" (继承自 Animal)
console.log(dog1 instanceof Dog); // true
console.log(dog1 instanceof Animal); // true
console.log(dog1.constructor === Dog); // true

优点:

  • 可以在子类型的构造函数中向父类型的构造函数传递参数。
  • 父类型的方法可以被子类型复用。
  • 避免了子类型实例共享父类型原型对象的属性。

缺点:

  • 父类型的构造函数会被调用两次:一次是在设置子类型原型时,一次是在创建子类型实例时。这会导致子类型实例中包含两份父类型的属性,虽然其中一份隐藏在原型对象中,但仍然造成了资源浪费。

3.4 原型式继承 (Prototypal Inheritance)

这种模式不使用构造函数,而是基于一个已有的对象来创建新的对象。Douglas Crockford 提出的 object() 函数就是原型式继承的一种实现。

function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

let animal = {
  name: "Generic Animal",
  species: "Animal",
  sayName: function() {
    console.log("My name is " + this.name);
  }
};

let dog1 = object(animal);
dog1.name = "Buddy";
dog1.breed = "Golden Retriever";
dog1.bark = function() {
  console.log("Woof!");
};

dog1.sayName(); // "My name is Buddy" (继承自 animal)
dog1.bark(); // "Woof!"
console.log(dog1.species); // "Animal" (继承自 animal)
console.log(dog1.__proto__ === animal); // true

优点:

  • 简单易于实现。
  • 不需要自定义构造函数。

缺点:

  • 所有通过原型式继承创建的对象共享原型对象的属性。如果原型对象包含引用类型的属性,那么修改一个对象的该属性会影响其他所有对象。
  • object() 函数本质上是对传入的对象进行了一次浅复制。

3.5 寄生式继承 (Parasitic Inheritance)

寄生式继承是在原型式继承的基础上,通过创建一个封装对象来增强对象的行为。

function createDog(original) {
  let clone = object(original); // 使用原型式继承创建新对象
  clone.bark = function() { // 增强对象
    console.log("Woof!");
  };
  return clone;
}

let animal = {
  name: "Generic Animal",
  species: "Animal",
  sayName: function() {
    console.log("My name is " + this.name);
  }
};

let dog1 = createDog(animal);
dog1.name = "Buddy";
dog1.breed = "Golden Retriever";

dog1.sayName(); // "My name is Buddy" (继承自 animal)
dog1.bark(); // "Woof!"
console.log(dog1.species); // "Animal" (继承自 animal)
console.log(dog1.__proto__ === animal); // true

优点:

  • 简单易于实现。
  • 可以在不修改原始对象的情况下增强对象。

缺点:

  • 所有通过寄生式继承创建的对象都包含相同的方法,造成内存浪费。
  • 与借用构造函数类似,无法实现函数复用,每次创建对象都需要重新创建一遍方法。

3.6 寄生组合式继承 (Parasitic Combination Inheritance)

寄生组合式继承是目前最理想的继承模式。它避免了组合继承中父类型构造函数被调用两次的问题,同时保留了组合继承的优点。

function inheritPrototype(subType, superType) {
  let prototype = Object.create(superType.prototype); // 创建父类型原型对象的副本
  prototype.constructor = subType; // 修复 constructor 指向
  subType.prototype = prototype; // 将子类型原型对象设置为父类型原型对象的副本
}

function Animal(name) {
  this.name = name;
  this.species = "Animal";
}

Animal.prototype.sayName = function() {
  console.log("My name is " + this.name);
};

function Dog(name, breed) {
  Animal.call(this, name); // 借用构造函数,继承属性
  this.breed = breed;
}

// 关键步骤:使用寄生式继承来避免调用两次父类型构造函数
inheritPrototype(Dog, Animal);

Dog.prototype.bark = function() {
  console.log("Woof!");
};

let dog1 = new Dog("Buddy", "Golden Retriever");

dog1.sayName(); // "My name is Buddy" (继承自 Animal)
dog1.bark(); // "Woof!"
console.log(dog1.species); // "Animal" (继承自 Animal)
console.log(dog1 instanceof Dog); // true
console.log(dog1 instanceof Animal); // true
console.log(dog1.constructor === Dog); // true

优点:

  • 避免了组合继承中父类型构造函数被调用两次的问题。
  • 可以在子类型的构造函数中向父类型的构造函数传递参数。
  • 父类型的方法可以被子类型复用。
  • 避免了子类型实例共享父类型原型对象的属性。

缺点:

  • 相对其他继承模式,实现起来稍微复杂一些。

4. ES6 的 Class 继承:语法糖

ES6 引入了 class 关键字,提供了一种更简洁的语法来定义类和实现继承。但需要注意的是,class 实际上只是一个语法糖,其底层仍然是基于原型链的继承。

class Animal {
  constructor(name) {
    this.name = name;
    this.species = "Animal";
  }

  sayName() {
    console.log("My name is " + this.name);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name); // 调用父类的构造函数
    this.breed = breed;
  }

  bark() {
    console.log("Woof!");
  }
}

let dog1 = new Dog("Buddy", "Golden Retriever");

dog1.sayName(); // "My name is Buddy" (继承自 Animal)
dog1.bark(); // "Woof!"
console.log(dog1.species); // "Animal" (继承自 Animal)
console.log(dog1 instanceof Dog); // true
console.log(dog1 instanceof Animal); // true
console.log(dog1.constructor === Dog); // true

extends 关键字用于指定子类继承的父类,super() 函数用于调用父类的构造函数。class 语法使得继承更加清晰和易于理解,但其本质仍然是基于原型链的寄生组合式继承。

5. 总结:关键点的回顾

  • __proto__ 指向创建对象的构造函数的 prototype 对象,prototype 是函数才有的属性,用 new 产生的实例的 __proto__ 指向构造函数的 prototype
  • 原型链是查找对象属性的机制,沿着 __proto__ 向上查找,直到找到属性或到达 null
  • 寄生组合式继承是目前最理想的继承模式,ES6 的 class 语法糖底层也是寄生组合式继承。

通过理解 __proto__prototype 的关系,以及各种基于原型链的继承模式,我们可以更好地掌握 JavaScript 的面向对象编程,编写出更加灵活和可维护的代码。希望今天的讲解对大家有所帮助!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注