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

JavaScript对象原型与继承关系解析

2021-08-032.3k 阅读

JavaScript对象原型与继承关系解析

在JavaScript中,对象的原型与继承关系是理解其面向对象编程特性的核心。JavaScript的继承机制与传统的基于类的面向对象语言(如Java、C++)有所不同,它是基于原型链的继承。深入理解这一机制,对于编写高效、可维护的JavaScript代码至关重要。

1. 理解原型(Prototype)

在JavaScript中,每个对象都有一个 [[Prototype]] 内部属性(在ES6之前没有直接访问它的标准方法,ES6引入了 Object.getPrototypeOf()Object.setPrototypeOf() 方法,在Chrome和Firefox等浏览器中也可以通过 __proto__ 属性访问,但 __proto__ 并非标准属性,不建议在生产环境中使用)。这个 [[Prototype]] 指向另一个对象,这个对象就是当前对象的原型。

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

// 创建一个对象
let person = {
    name: 'John',
    age: 30
};

// 获取person对象的原型
let proto = Object.getPrototypeOf(person);
console.log(proto); // 输出Object.prototype

// 访问person对象没有的属性
console.log(person.toString()); // 虽然person对象没有toString属性,但通过原型链能找到Object.prototype上的toString方法

在上述代码中,person 对象本身没有 toString 方法,但由于它的原型是 Object.prototype,而 Object.prototype 上有 toString 方法,所以可以通过 person.toString() 调用到该方法。

每个函数都有一个 prototype 属性(注意与对象的 [[Prototype]] 区分),当函数作为构造函数使用(通过 new 关键字调用)时,新创建的对象的 [[Prototype]] 会指向构造函数的 prototype 属性。例如:

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

// Animal函数的prototype属性是一个对象,这个对象有一个constructor属性指向Animal函数本身
Animal.prototype.speak = function() {
    console.log(this.name +'makes a sound.');
};

let dog = new Animal('Buddy');
dog.speak(); // 输出 "Buddy makes a sound."

在这个例子中,dog 对象是通过 new Animal() 创建的,dog[[Prototype]] 指向 Animal.prototype。所以 dog 可以访问 Animal.prototype 上定义的 speak 方法。

2. 原型链(Prototype Chain)

原型链是JavaScript实现继承的基础。当一个对象的属性或方法在自身找不到时,JavaScript会沿着它的原型链向上查找。原型链的顶端是 Object.prototype,而 Object.prototype[[Prototype]]null

例如,我们创建一个更复杂的继承结构:

function Shape() {
    this.x = 0;
    this.y = 0;
}

Shape.prototype.move = function(x, y) {
    this.x += x;
    this.y += y;
    console.log('Shape moved to (' + this.x + ','+ this.y + ')');
};

function Rectangle(width, height) {
    Shape.call(this); // 借用Shape构造函数初始化x和y属性
    this.width = width;
    this.height = height;
}

// 设置Rectangle的原型为Shape的实例,这样Rectangle就继承了Shape的属性和方法
Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle; // 修正constructor属性

Rectangle.prototype.getArea = function() {
    return this.width * this.height;
};

let rect = new Rectangle(5, 10);
rect.move(1, 1); // 输出 "Shape moved to (1, 1)"
console.log(rect.getArea()); // 输出50

在这个例子中,Rectangle 继承自 ShapeRectangle.prototypeShape.prototype 的一个实例,所以 Rectangle 的实例(如 rect)可以访问 Shape.prototype 上的 move 方法,同时也有自己定义的 getArea 方法。当访问 rect.move 时,首先在 rect 对象自身找不到 move 方法,然后会沿着原型链找到 Rectangle.prototype,仍然找不到,再向上到 Shape.prototype,最终找到 move 方法并执行。

3. 构造函数、原型与实例的关系

构造函数、原型和实例之间存在着紧密的关系。构造函数用于创建实例对象,每个构造函数都有一个 prototype 属性,该属性指向一个对象,这个对象就是实例对象的原型。实例对象通过 [[Prototype]] 与原型对象相连。

例如:

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

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

let john = new Person('John');

// 构造函数与实例的关系
console.log(john.constructor === Person); // true,实例的constructor属性指向构造函数

// 实例与原型的关系
console.log(Object.getPrototypeOf(john) === Person.prototype); // true

在上述代码中,johnPerson 的实例,john.constructor 指向 Person 构造函数,john[[Prototype]] 指向 Person.prototype

4. 继承的实现方式

在JavaScript中,有多种实现继承的方式,除了前面提到的通过原型链实现继承外,还有以下几种常见方式:

4.1 借用构造函数(Constructor Stealing)

借用构造函数是一种简单的继承方式,通过在子构造函数内部调用父构造函数,来继承父构造函数的属性。例如:

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

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

let buddy = new Dog('Buddy', 'Golden Retriever');
console.log(buddy.name); // 输出 "Buddy"
console.log(buddy.breed); // 输出 "Golden Retriever"

在这个例子中,Dog 构造函数通过 Animal.call(this, name) 调用了 Animal 构造函数,从而继承了 Animalname 属性。但这种方式的缺点是无法继承父构造函数原型上的方法,因为每个实例都有自己独立的属性,没有共享原型。

4.2 组合继承(Combination Inheritance)

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

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

Animal.prototype.speak = function() {
    console.log(this.name +'makes a sound.');
};

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

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
    console.log(this.name +'barks.');
};

let buddy = new Dog('Buddy', 'Golden Retriever');
buddy.speak(); // 输出 "Buddy makes a sound."
buddy.bark(); // 输出 "Buddy barks."

在这个例子中,Dog 既通过 Animal.call(this, name) 继承了 Animal 的属性,又通过 Dog.prototype = Object.create(Animal.prototype) 继承了 Animal.prototype 上的方法,同时 Dog 也有自己定义的 bark 方法。

4.3 原型式继承(Prototypal Inheritance)

原型式继承是基于已有对象创建新对象,新对象的原型就是传入的对象。例如:

let person = {
    name: 'John',
    friends: ['Jane', 'Bob']
};

let anotherPerson = Object.create(person);
anotherPerson.name = 'Alice';
anotherPerson.friends.push('Eve');

console.log(person.friends); // 输出 ["Jane", "Bob", "Eve"],因为原型式继承共享引用类型属性

在这个例子中,anotherPerson 是通过 Object.create(person) 创建的,它的原型是 person。所以 anotherPerson 可以访问 person 的属性,并且由于共享引用类型属性,anotherPersonfriends 数组的修改会影响到 personfriends 数组。

4.4 寄生式继承(Parasitic Inheritance)

寄生式继承是在原型式继承的基础上,为新对象添加额外的属性或方法。例如:

function createAnother(original) {
    let clone = Object.create(original);
    clone.sayHi = function() {
        console.log('Hi!');
    };
    return clone;
}

let person = {
    name: 'John'
};

let anotherPerson = createAnother(person);
anotherPerson.sayHi(); // 输出 "Hi!"

在这个例子中,createAnother 函数通过 Object.create(original) 创建了一个基于 original 的新对象 clone,然后为 clone 添加了 sayHi 方法并返回。

4.5 寄生组合式继承(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;
}

Animal.prototype.speak = function() {
    console.log(this.name +'makes a sound.');
};

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

inheritPrototype(Dog, Animal);

Dog.prototype.bark = function() {
    console.log(this.name +'barks.');
};

let buddy = new Dog('Buddy', 'Golden Retriever');
buddy.speak(); // 输出 "Buddy makes a sound."
buddy.bark(); // 输出 "Buddy barks."

在这个例子中,inheritPrototype 函数通过 Object.create(superType.prototype) 创建了一个新的原型对象,避免了像组合继承中直接将 subType.prototype = new superType() 那样导致的属性重复。这种方式既继承了父类型的属性和方法,又保证了原型链的正确性和高效性。

5. ES6类与继承

ES6引入了 class 关键字,使得JavaScript的面向对象编程更加直观和符合传统面向对象语言的习惯。但实际上,ES6的类是基于原型链继承的语法糖。

例如:

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

    speak() {
        console.log(this.name +'makes a sound.');
    }
}

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

    bark() {
        console.log(this.name +'barks.');
    }
}

let buddy = new Dog('Buddy', 'Golden Retriever');
buddy.speak(); // 输出 "Buddy makes a sound."
buddy.bark(); // 输出 "Buddy barks."

在这个例子中,Dog 类通过 extends 关键字继承自 Animal 类。super 关键字用于调用父类的方法,在 Dogconstructor 中通过 super(name) 调用了 Animalconstructor 来初始化 name 属性。虽然使用 class 语法看起来与传统面向对象语言类似,但本质上还是基于原型链的继承。

6. 原型与继承的应用场景

6.1 代码复用

通过继承,可以将一些通用的属性和方法提取到父类中,子类只需要继承父类并根据需要进行扩展,从而减少代码重复。例如,在一个游戏开发中,可能有 Character 类作为所有游戏角色的基类,包含一些通用的属性(如生命值、攻击力)和方法(如移动、攻击),然后 WarriorMage 等子类继承自 Character 类,并根据自身特点扩展或重写部分方法。

6.2 面向对象设计模式

许多面向对象设计模式依赖于继承机制。例如,策略模式中,可以定义一个抽象的策略类,然后通过继承创建不同的具体策略类。在JavaScript中,可以利用原型链继承来实现类似的设计模式,提高代码的可维护性和可扩展性。

6.3 库和框架开发

在JavaScript库和框架的开发中,继承也是常用的手段。例如,在一些UI框架中,可能有一个基础的 Component 类,所有具体的UI组件(如按钮、文本框等)都继承自 Component 类,并根据自身需求进行定制和扩展。

7. 原型与继承的常见问题与注意事项

7.1 原型链过长导致性能问题

当原型链过长时,查找属性的性能会下降。因为每次查找属性都需要沿着原型链向上查找,直到找到该属性或者到达原型链顶端。所以在设计继承结构时,应尽量避免创建过长的原型链。

7.2 共享引用类型属性的问题

在原型式继承和一些继承方式中,如果原型对象上有引用类型的属性,那么所有继承自该原型的实例都会共享这个引用类型属性。这可能导致一个实例对该属性的修改影响到其他实例。例如:

function Animal() {
    this.friends = ['Buddy'];
}

function Dog() {}

Dog.prototype = new Animal();

let dog1 = new Dog();
let dog2 = new Dog();

dog1.friends.push('Max');
console.log(dog2.friends); // 输出 ["Buddy", "Max"]

在这个例子中,dog1dog2 共享 friends 数组,dog1friends 数组的修改影响到了 dog2。为了避免这种问题,可以在构造函数中初始化引用类型属性,而不是在原型上定义。

7.3 constructor属性的修正

在使用原型链继承时,需要注意修正 constructor 属性。当通过 Object.create 等方式创建新的原型对象时,新对象的 constructor 属性会指向 Object,而不是我们期望的构造函数。所以需要手动修正 constructor 属性,以保证对象的 constructor 指向正确的构造函数。例如:

function Shape() {}

function Rectangle() {}

Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle; // 修正constructor属性

7.4 理解 this 在继承中的作用

在继承中,this 的指向需要特别注意。在构造函数中,this 指向新创建的实例对象。而在原型方法中,this 指向调用该方法的实例对象。例如:

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

Animal.prototype.speak = function() {
    console.log(this.name +'makes a sound.');
};

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

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
    console.log(this.name +'barks.');
};

let buddy = new Dog('Buddy', 'Golden Retriever');
buddy.speak(); // 输出 "Buddy makes a sound.",这里的this指向buddy
buddy.bark(); // 输出 "Buddy barks.",这里的this也指向buddy

speakbark 方法中,this 都指向调用它们的 buddy 实例对象。

综上所述,JavaScript的原型与继承机制虽然与传统面向对象语言有所不同,但通过深入理解原型链、构造函数、实例之间的关系,以及各种继承实现方式,我们能够灵活运用这一机制,编写出高质量、可维护的JavaScript代码。无论是在小型项目还是大型的企业级应用开发中,掌握原型与继承都是JavaScript开发者必备的技能。