MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript原型链详解与继承模式

2023-06-147.7k 阅读

JavaScript 原型链详解

在 JavaScript 中,原型链是理解对象继承和属性查找机制的核心概念。每一个 JavaScript 对象(null 除外)都和另一个对象关联,这个关联的对象就是我们所说的原型(prototype)。对象以其原型为模板,从中继承属性和方法。

原型(prototype)

当我们创建一个函数时,JavaScript 会自动为这个函数添加一个 prototype 属性。这个 prototype 属性是一个对象,它包含一个 constructor 属性,该属性指向函数本身。例如:

function Person() {}
console.log(Person.prototype.constructor === Person); // true

这里,Person 函数有一个 prototype 对象,并且这个 prototype 对象的 constructor 属性指向 Person 函数。

当我们使用 new 关键字调用一个函数(构造函数)来创建一个新对象时,新创建的对象会有一个内部属性 [[Prototype]],它指向构造函数的 prototype 对象。在现代 JavaScript 中,我们可以通过 __proto__ 属性(这是一个非标准但被广泛支持的属性)来访问这个内部属性。例如:

function Person() {}
let person = new Person();
console.log(person.__proto__ === Person.prototype); // true

属性查找机制

当我们访问一个对象的属性时,JavaScript 会首先在对象本身查找该属性。如果没有找到,它会沿着原型链向上查找,直到找到该属性或者到达原型链的顶端(Object.prototype,其 __proto__null)。例如:

function Animal() {
    this.species = 'Animal';
}
Animal.prototype.getSpecies = function() {
    return this.species;
};

function Dog() {
    this.name = 'Buddy';
    this.species = 'Dog';
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

let myDog = new Dog();
console.log(myDog.name); // 'Buddy',在对象自身找到
console.log(myDog.getSpecies()); // 'Dog',在原型链上找到

在上面的代码中,myDog 对象本身有 name 属性,所以直接返回其值。而 getSpecies 方法在 myDog 对象本身没有找到,于是沿着原型链在 Dog.prototype 中也没有找到,继续向上在 Animal.prototype 中找到了该方法。

原型链的顶端

原型链的顶端是 Object.prototype。所有的 JavaScript 对象(除了 null)最终都继承自 Object.prototype。这意味着所有对象都可以使用 Object.prototype 上定义的方法,比如 toStringhasOwnProperty 等。例如:

let obj = {};
console.log(obj.toString()); // "[object Object]"
console.log(obj.hasOwnProperty('toString')); // false,说明该方法来自原型链

JavaScript 继承模式

继承是面向对象编程中的一个重要概念,它允许我们基于现有的类创建新的类,新类可以继承现有类的属性和方法。在 JavaScript 中,由于它是基于原型的语言,实现继承的方式和传统的类式语言有所不同。下面我们来详细探讨几种常见的 JavaScript 继承模式。

原型链继承

原型链继承是 JavaScript 中最基本的继承方式,它利用了原型链的机制。我们通过将一个构造函数的原型对象设置为另一个构造函数的实例,从而实现继承。例如:

function Animal() {
    this.species = 'Animal';
}
Animal.prototype.getSpecies = function() {
    return this.species;
};

function Dog() {
    // Dog 的实例会继承 Animal 的属性和方法
}
Dog.prototype = new Animal();
Dog.prototype.constructor = Dog;

let myDog = new Dog();
console.log(myDog.getSpecies()); // 'Animal'

在这个例子中,Dog.prototype 被设置为 Animal 的一个实例,所以 Dog 的实例(如 myDog)可以访问 Animal.prototype 上的方法。

优点

  • 简单,易于理解,利用了原型链的基本特性。
  • 实例与原型之间的关系清晰,符合 JavaScript 的原型机制。

缺点

  • 所有实例会共享原型上的属性,如果原型上的属性是引用类型,一个实例对其修改会影响其他实例。例如:
function Animal() {
    this.friends = ['cat'];
}
function Dog() {}
Dog.prototype = new Animal();
Dog.prototype.constructor = Dog;

let dog1 = new Dog();
let dog2 = new Dog();
dog1.friends.push('mouse');
console.log(dog2.friends); // ['cat','mouse']
  • 无法向父构造函数传递参数,因为在设置 Dog.prototype 时,Animal 构造函数会被调用一次,但无法传递特定的参数。

构造函数继承(经典继承)

构造函数继承通过在子构造函数内部调用父构造函数,并使用 callapply 方法来改变 this 的指向,从而实现继承。例如:

function Animal(name) {
    this.name = name;
    this.species = 'Animal';
}
function Dog(name) {
    Animal.call(this, name);
    this.breed = 'Golden Retriever';
}

let myDog = new Dog('Buddy');
console.log(myDog.name); // 'Buddy'
console.log(myDog.species); // 'Animal'
console.log(myDog.breed); // 'Golden Retriever'

在上述代码中,Dog 构造函数内部通过 Animal.call(this, name) 调用了 Animal 构造函数,并将 this 指向 Dog 的实例,这样 Dog 的实例就拥有了 Animal 构造函数定义的属性。

优点

  • 可以向父构造函数传递参数,灵活性较高。
  • 每个实例都有自己独立的属性,不会共享,解决了原型链继承中引用类型属性共享的问题。

缺点

  • 只能继承父构造函数的属性和方法,无法继承父原型对象上的方法。例如:
function Animal() {
    this.species = 'Animal';
}
Animal.prototype.getSpecies = function() {
    return this.species;
};
function Dog() {
    Animal.call(this);
}
let myDog = new Dog();
console.log(myDog.getSpecies()); // TypeError: myDog.getSpecies is not a function

组合继承(伪经典继承)

组合继承结合了原型链继承和构造函数继承的优点。它通过原型链继承父原型对象上的方法,通过构造函数继承父构造函数的属性。例如:

function Animal(name) {
    this.name = name;
    this.species = 'Animal';
}
Animal.prototype.getSpecies = function() {
    return this.species;
};

function Dog(name) {
    Animal.call(this, name);
    this.breed = 'Golden Retriever';
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

let myDog = new Dog('Buddy');
console.log(myDog.name); // 'Buddy'
console.log(myDog.species); // 'Animal'
console.log(myDog.breed); // 'Golden Retriever'
console.log(myDog.getSpecies()); // 'Animal'

在这个例子中,Dog 构造函数内部调用 Animal.call(this, name) 继承了 Animal 构造函数的属性,同时通过 Dog.prototype = Object.create(Animal.prototype) 设置原型链,使得 Dog 的实例可以访问 Animal.prototype 上的方法。

优点

  • 融合了原型链继承和构造函数继承的优点,既能继承父构造函数的属性,又能继承父原型对象上的方法。
  • 每个实例有自己独立的属性,同时可以共享原型上的方法。

缺点

  • 父构造函数会被调用两次。一次是在设置 Dog.prototype = Object.create(Animal.prototype) 时,Animal 构造函数会被调用创建原型对象;另一次是在 Dog 构造函数内部调用 Animal.call(this, name)。这可能会导致一些性能问题,尤其是当父构造函数有复杂逻辑或执行开销较大时。

寄生组合式继承

寄生组合式继承是对组合继承的优化,它避免了父构造函数的两次调用。其核心思想是通过创建一个临时构造函数,让它的原型指向父原型对象,然后创建这个临时构造函数的实例,并将其作为子构造函数的原型。例如:

function Animal(name) {
    this.name = name;
    this.species = 'Animal';
}
Animal.prototype.getSpecies = function() {
    return this.species;
};

function Dog(name) {
    Animal.call(this, name);
    this.breed = 'Golden Retriever';
}

function inheritPrototype(subType, superType) {
    let prototype = Object.create(superType.prototype);
    prototype.constructor = subType;
    subType.prototype = prototype;
}

inheritPrototype(Dog, Animal);

let myDog = new Dog('Buddy');
console.log(myDog.name); // 'Buddy'
console.log(myDog.species); // 'Animal'
console.log(myDog.breed); // 'Golden Retriever'
console.log(myDog.getSpecies()); // 'Animal'

在上述代码中,inheritPrototype 函数实现了寄生组合式继承的核心逻辑。它首先通过 Object.create(superType.prototype) 创建一个新的对象,其原型指向父原型对象,然后设置这个新对象的 constructor 为子类型,最后将这个新对象赋值给子类型的 prototype

优点

  • 避免了父构造函数的多次调用,提高了性能。
  • 保持了组合继承的优点,既能继承父构造函数的属性,又能继承父原型对象上的方法。

缺点

  • 代码相对复杂,需要额外的函数来处理继承逻辑,对于初学者理解起来可能有一定难度。

ES6 类继承

ES6 引入了 class 关键字,为 JavaScript 提供了更接近传统面向对象语言的类式继承语法。实际上,class 语法在底层仍然是基于原型链的。例如:

class Animal {
    constructor(name) {
        this.name = name;
        this.species = 'Animal';
    }
    getSpecies() {
        return this.species;
    }
}

class Dog extends Animal {
    constructor(name) {
        super(name);
        this.breed = 'Golden Retriever';
    }
}

let myDog = new Dog('Buddy');
console.log(myDog.name); // 'Buddy'
console.log(myDog.species); // 'Animal'
console.log(myDog.breed); // 'Golden Retriever'
console.log(myDog.getSpecies()); // 'Animal'

在这个例子中,Dog 类通过 extends 关键字继承自 Animal 类。super 关键字用于调用父类的构造函数。

优点

  • 语法更加简洁、直观,符合传统面向对象编程的习惯,易于理解和维护。
  • 仍然基于原型链,与 JavaScript 的底层机制兼容。

缺点

  • 虽然语法简洁,但在某些复杂场景下,可能隐藏了原型链的底层细节,对于深入理解 JavaScript 的继承机制可能会有一定障碍。同时,对于不支持 ES6 的环境,需要进行编译转换(如使用 Babel)。

总结各种继承模式的应用场景

  1. 原型链继承:适用于简单场景,且属性不涉及引用类型,只需要简单地继承原型上的方法。例如,一些简单的工具函数类的继承,不需要传递参数,也不存在属性共享问题。
  2. 构造函数继承:当需要向父构造函数传递参数,并且希望每个实例都有自己独立的属性时适用。比如创建用户对象,每个用户有不同的姓名、年龄等属性,通过构造函数继承可以方便地实现。
  3. 组合继承:在大多数需要继承父构造函数属性和父原型对象方法的场景下都适用,是一种比较通用的继承方式。例如创建一个游戏角色类,角色有自身的属性(如生命值、攻击力等通过构造函数继承),同时有一些通用的方法(如攻击方法、防御方法等通过原型链继承)。
  4. 寄生组合式继承:当性能要求较高,且需要避免父构造函数多次调用时适用。特别是在大型应用中,对性能敏感的部分可以采用这种继承方式。
  5. ES6 类继承:在现代 JavaScript 开发中,推荐使用 ES6 类继承语法,因为它语法简洁,易于理解和维护。在支持 ES6 的环境中,无论是小型项目还是大型项目,都可以优先考虑使用这种方式来实现继承。

通过深入理解 JavaScript 的原型链和各种继承模式,开发者可以根据不同的需求选择最合适的继承方式,编写出更高效、易维护的代码。在实际开发中,往往需要根据具体的业务场景和性能要求来灵活运用这些知识。同时,随着 JavaScript 的不断发展,新的特性和语法可能会进一步优化继承机制的使用体验,但原型链作为 JavaScript 继承的核心概念,始终是理解和掌握继承的关键。