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

JavaScript面向对象编程中的原型与继承

2021-04-265.0k 阅读

原型(Prototype)的概念

在 JavaScript 中,每个函数都有一个 prototype 属性,这个属性是一个对象,它被称为原型对象。当使用构造函数创建实例时,实例会通过内部的 [[Prototype]] 链接(在现代 JavaScript 中可以通过 __proto__ 属性访问,虽然 __proto__ 是非标准的,但广泛支持)指向构造函数的原型对象。

原型对象的作用

原型对象的主要作用是为对象实例提供共享的属性和方法。当访问一个对象的属性或方法时,如果该对象本身没有这个属性或方法,JavaScript 会沿着 [[Prototype]] 链向上查找,直到找到该属性或方法,或者到达原型链的顶端(null)。

例如,我们定义一个简单的构造函数 Person

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

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

const person1 = new Person('Alice');
person1.sayHello(); // 输出: Hello, my name is Alice

在上述代码中,Person.prototype 上定义的 sayHello 方法,被所有通过 Person 构造函数创建的实例所共享。这意味着所有 Person 实例都可以调用 sayHello 方法,而不必在每个实例上重复定义。

原型链

原型链是 JavaScript 实现继承和属性查找的核心机制。当访问对象的属性时,JavaScript 首先在对象本身查找,如果找不到,则会沿着 [[Prototype]] 链向上查找,直到找到该属性或者到达原型链的顶端(null)。

例如,我们来看一个稍微复杂点的例子:

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

Animal.prototype.move = function(distance) {
    console.log(this.name +'moved'+ distance +'m.');
};

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.');
};

const myDog = new Dog('Buddy', 'Golden Retriever');
myDog.move(10); // 输出: Buddy moved 10 m.
myDog.bark();   // 输出: Buddy barks.

在这个例子中,Dog 构造函数继承自 Animal 构造函数。Dog.prototype 被设置为 Animal.prototype 的一个实例(通过 Object.create),这样 Dog 实例就可以访问 Animal.prototype 上的属性和方法。同时,Dog.prototype 上又添加了 bark 方法,使得 Dog 实例有了自己特有的行为。

继承的实现方式

在 JavaScript 中,实现继承有多种方式,每种方式都有其优缺点,下面我们详细探讨几种常见的继承方式。

原型链继承

原型链继承是 JavaScript 中最基本的继承方式,它利用了原型对象之间的关系来实现继承。

function Parent() {
    this.value = 42;
}

Parent.prototype.getValue = function() {
    return this.value;
};

function Child() {}

Child.prototype = new Parent();
Child.prototype.constructor = Child;

const child = new Child();
console.log(child.getValue()); // 输出: 42

在上述代码中,Child.prototype 被设置为 Parent 的一个实例,这样 Child 的实例就可以访问 Parent 的属性和方法。然而,这种继承方式存在一些问题,比如当原型链上的属性是引用类型时,所有实例会共享该引用,一个实例对其修改会影响其他实例。

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

借用构造函数继承通过在子构造函数内部调用父构造函数,将父构造函数的属性和方法复制到子构造函数的实例中。

function Parent(name) {
    this.name = name;
    this.hobbies = ['reading', 'writing'];
}

function Child(name, age) {
    Parent.call(this, name);
    this.age = age;
}

const child1 = new Child('Alice', 30);
const child2 = new Child('Bob', 25);

child1.hobbies.push('coding');
console.log(child1.hobbies); // 输出: ['reading', 'writing', 'coding']
console.log(child2.hobbies); // 输出: ['reading', 'writing']

在这个例子中,通过 Parent.call(this, name)Child 实例拥有了 Parent 构造函数定义的属性。这种方式解决了原型链继承中引用类型属性共享的问题,但它不能继承父构造函数原型上的方法,因为这些方法并没有被复制到子实例中。

组合继承

组合继承结合了原型链继承和借用构造函数继承的优点,既实现了属性的继承,又实现了方法的继承。

function Parent(name) {
    this.name = name;
    this.hobbies = ['reading', 'writing'];
}

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

function Child(name, age) {
    Parent.call(this, name);
    this.age = age;
}

Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

const child = new Child('Alice', 30);
child.sayName(); // 输出: My name is Alice
child.hobbies.push('coding');
console.log(child.hobbies); // 输出: ['reading', 'writing', 'coding']

在这个例子中,通过 Parent.call(this, name) 实现了属性的继承,通过 Child.prototype = Object.create(Parent.prototype) 实现了方法的继承。这种方式是比较常用的继承方式,但在调用父构造函数时会有一些性能开销。

寄生组合继承

寄生组合继承是对组合继承的优化,它避免了在创建子类型实例时不必要地调用父构造函数。

function Parent(name) {
    this.name = name;
    this.hobbies = ['reading', 'writing'];
}

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

function Child(name, age) {
    Parent.call(this, name);
    this.age = age;
}

function inheritPrototype(child, parent) {
    const prototype = Object.create(parent.prototype);
    prototype.constructor = child;
    child.prototype = prototype;
}

inheritPrototype(Child, Parent);

const child = new Child('Alice', 30);
child.sayName(); // 输出: My name is Alice
child.hobbies.push('coding');
console.log(child.hobbies); // 输出: ['reading', 'writing', 'coding']

在这个例子中,inheritPrototype 函数通过 Object.create 创建了一个新的原型对象,这个对象继承自父类型的原型,同时保持了正确的 constructor 指向。这样既避免了组合继承中不必要的父构造函数调用,又实现了属性和方法的继承。

ES6 类与继承

ES6 引入了 class 关键字,它为 JavaScript 提供了更接近传统面向对象编程语言的语法来实现继承。

类的定义

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

    move(distance) {
        console.log(this.name +'moved'+ distance +'m.');
    }
}

在上述代码中,使用 class 关键字定义了一个 Animal 类,constructor 方法是类的构造函数,用于初始化实例的属性。move 方法定义在类的原型上,所有 Animal 实例都可以调用。

类的继承

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

    move(distance) {
        console.log(this.name +'moved'+ distance +'m.');
    }
}

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

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

const myDog = new Dog('Buddy', 'Golden Retriever');
myDog.move(10); // 输出: Buddy moved 10 m.
myDog.bark();   // 输出: Buddy barks.

在这个例子中,Dog 类通过 extends 关键字继承自 Animal 类。在 Dog 类的构造函数中,通过 super(name) 调用父类的构造函数,以初始化从父类继承的属性。Dog 类还添加了自己特有的 bark 方法。

类继承的底层原理

实际上,ES6 类的继承在底层仍然是基于原型链和构造函数的。class 只是一种语法糖,它使得继承的代码更加简洁和易读。例如,上述 Dog 类的继承在底层的实现类似于寄生组合继承:

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

Animal.prototype.move = function(distance) {
    console.log(this.name +'moved'+ distance +'m.');
};

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

function inheritPrototype(child, parent) {
    const prototype = Object.create(parent.prototype);
    prototype.constructor = child;
    child.prototype = prototype;
}

inheritPrototype(Dog, Animal);

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

const myDog = new Dog('Buddy', 'Golden Retriever');
myDog.move(10); // 输出: Buddy moved 10 m.
myDog.bark();   // 输出: Buddy barks.

可以看到,虽然使用 class 语法更加简洁,但底层的继承机制仍然是基于原型链和构造函数的。

原型与继承中的一些重要概念

constructor 属性

每个原型对象都有一个 constructor 属性,它指向创建该原型对象的构造函数。例如:

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

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

console.log(Person.prototype.constructor === Person); // 输出: true

在修改原型对象时,需要注意保持 constructor 属性的正确性,否则可能会导致一些意外的行为。例如,当我们使用 Object.create 创建一个新的原型对象时,需要手动设置 constructor 属性:

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

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

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog; // 手动设置 constructor 属性

isPrototypeOf 方法

isPrototypeOf 方法用于判断一个对象是否是另一个对象的原型。例如:

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

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

const person1 = new Person('Alice');
console.log(Person.prototype.isPrototypeOf(person1)); // 输出: true

这个方法在调试和理解原型链关系时非常有用。

instanceof 操作符

instanceof 操作符用于判断一个对象是否是某个构造函数的实例,它实际上是通过检查原型链来实现的。例如:

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

const person1 = new Person('Alice');
console.log(person1 instanceof Person); // 输出: true

instanceof 操作符会检查对象的 [[Prototype]] 链,看是否能找到构造函数的原型对象。

原型与继承在实际项目中的应用

代码复用

在大型项目中,代码复用是非常重要的。通过继承,可以将一些通用的属性和方法提取到父类中,子类只需要继承父类并根据需要进行扩展,从而减少代码的重复。例如,在一个游戏开发项目中,可能有一个 GameObject 类,它定义了一些通用的属性和方法,如位置、大小、渲染等。然后,不同类型的游戏对象,如 PlayerEnemyItem 等,可以继承自 GameObject 类,并根据自身的特点进行扩展。

class GameObject {
    constructor(x, y, width, height) {
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }

    render(ctx) {
        ctx.fillRect(this.x, this.y, this.width, this.height);
    }
}

class Player extends GameObject {
    constructor(x, y, width, height, name) {
        super(x, y, width, height);
        this.name = name;
    }

    move(dx, dy) {
        this.x += dx;
        this.y += dy;
    }
}

class Enemy extends GameObject {
    constructor(x, y, width, height, type) {
        super(x, y, width, height);
        this.type = type;
    }

    attack() {
        console.log('Enemy attacks!');
    }
}

在这个例子中,PlayerEnemy 类继承自 GameObject 类,复用了 GameObject 类的位置、大小和渲染相关的代码,同时又各自添加了自己特有的行为。

实现多态

多态是面向对象编程的一个重要特性,它允许不同的对象对同一消息做出不同的响应。在 JavaScript 中,通过继承和重写方法可以实现多态。例如:

class Shape {
    constructor(color) {
        this.color = color;
    }

    draw(ctx) {
        console.log('Drawing a shape with color'+ this.color);
    }
}

class Circle extends Shape {
    constructor(color, radius) {
        super(color);
        this.radius = radius;
    }

    draw(ctx) {
        ctx.beginPath();
        ctx.arc(0, 0, this.radius, 0, 2 * Math.PI);
        ctx.fillStyle = this.color;
        ctx.fill();
    }
}

class Rectangle extends Shape {
    constructor(color, width, height) {
        super(color);
        this.width = width;
        this.height = height;
    }

    draw(ctx) {
        ctx.fillStyle = this.color;
        ctx.fillRect(0, 0, this.width, this.height);
    }
}

const shapes = [new Circle('red', 5), new Rectangle('blue', 10, 20)];
const ctx = document.createElement('canvas').getContext('2d');

shapes.forEach(shape => shape.draw(ctx));

在这个例子中,CircleRectangle 类继承自 Shape 类,并各自重写了 draw 方法。当遍历 shapes 数组并调用 draw 方法时,不同类型的对象会根据自身的实现来绘制,从而实现了多态。

插件与扩展机制

在一些框架或库的开发中,原型与继承可以用于实现插件和扩展机制。例如,一个绘图库可能提供了一些基本的绘图功能,开发者可以通过继承相关的类来扩展这些功能,添加自己的自定义绘图方法。

class DrawingLibrary {
    constructor() {
        this.shapes = [];
    }

    addShape(shape) {
        this.shapes.push(shape);
    }

    drawAll(ctx) {
        this.shapes.forEach(shape => shape.draw(ctx));
    }
}

class CustomShape extends Shape {
    constructor(color, customProp) {
        super(color);
        this.customProp = customProp;
    }

    customDraw(ctx) {
        // 自定义绘图逻辑
    }
}

const library = new DrawingLibrary();
const customShape = new CustomShape('green', 'Some custom value');
library.addShape(customShape);

在这个例子中,开发者可以通过继承 Shape 类来创建自定义的形状,并将其添加到绘图库中,实现对绘图库功能的扩展。

原型与继承的性能考量

原型链查找的性能

原型链查找是有一定性能开销的。当访问一个对象的属性时,JavaScript 需要沿着原型链向上查找,直到找到该属性或到达原型链的顶端。如果原型链过长,查找的性能会受到影响。因此,在设计原型链时,应该尽量避免创建过长的原型链。

例如,下面的代码展示了一个原型链比较长的情况:

function A() {}
function B() {}
function C() {}
function D() {}

B.prototype = new A();
C.prototype = new B();
D.prototype = new C();

const d = new D();
// 当访问 d 的属性时,可能需要沿着 A -> B -> C -> D 的原型链查找

在实际项目中,应该尽量保持原型链的简洁,避免不必要的中间层次。

构造函数调用的性能

在组合继承和借用构造函数继承中,构造函数的调用会有一定的性能开销。特别是在组合继承中,在创建子类型实例时,父构造函数会被调用两次(一次在设置原型时,一次在创建实例时)。虽然寄生组合继承优化了这一问题,但在某些性能敏感的场景下,仍然需要注意构造函数调用的次数和开销。

例如,在一个需要频繁创建对象的场景中,如果构造函数中有一些复杂的初始化操作,可能会导致性能问题。

function ComplexObject() {
    // 复杂的初始化操作,如读取文件、数据库查询等
}

function SubComplexObject() {
    ComplexObject.call(this);
    // 更多初始化操作
}

// 频繁创建 SubComplexObject 实例可能会导致性能问题

在这种情况下,可以考虑优化构造函数的初始化逻辑,或者采用其他更轻量级的对象创建方式。

缓存与优化

为了提高原型与继承相关操作的性能,可以使用缓存机制。例如,如果某个属性或方法在原型链上的查找频率较高,可以将其缓存到对象本身。

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

Person.prototype.getFullName = function() {
    return this.name +'Doe';
};

const person = new Person('John');
// 缓存 getFullName 方法的结果
person.fullName = person.getFullName();

这样,下次访问 person.fullName 时,就不需要再沿着原型链查找并调用 getFullName 方法,从而提高了性能。

另外,在 ES6 类的继承中,由于类的方法是定义在原型上的,并且是不可枚举的,这在一定程度上也有助于提高性能,因为在遍历对象属性时,不会遍历到这些方法,减少了不必要的计算。

原型与继承的常见问题与解决方案

原型污染

原型污染是一种安全漏洞,它发生在恶意代码能够修改对象的原型,从而影响整个应用程序的行为。例如:

const vulnerableObject = {};
const maliciousObject = {
    __proto__: {
        evilMethod: function() {
            // 恶意操作,如窃取数据、执行恶意代码等
        }
    }
};

Object.assign(vulnerableObject, maliciousObject);
// 现在 vulnerableObject 可以调用 evilMethod,可能导致安全问题

为了防止原型污染,可以使用 Object.create(null) 创建一个没有原型的对象,这样就无法通过 __proto__ 进行原型污染。

const safeObject = Object.create(null);
// 即使使用 Object.assign,也不会受到原型污染

继承关系混乱

在大型项目中,继承关系可能会变得复杂和混乱,导致代码难以维护和理解。为了避免这种情况,应该遵循良好的设计原则,如单一职责原则,确保每个类都有明确的职责,并且继承关系应该简单明了。

另外,可以使用 UML 类图等工具来可视化继承关系,帮助开发人员更好地理解和维护代码。

方法重写的意外行为

当重写父类的方法时,可能会出现意外行为。例如,在重写方法时没有正确调用 super 方法,导致父类的一些必要逻辑没有执行。

class Parent {
    constructor() {
        this.init();
    }

    init() {
        console.log('Parent init');
    }
}

class Child extends Parent {
    constructor() {
        super();
    }

    init() {
        // 忘记调用 super.init(),导致父类的 init 逻辑没有执行
        console.log('Child init');
    }
}

const child = new Child();
// 输出: Child init,缺少 Parent init

为了避免这种情况,在重写方法时,应该仔细检查是否需要调用 super 方法,并确保正确调用。

总结

JavaScript 中的原型与继承是实现面向对象编程的重要机制。通过原型链,对象可以共享属性和方法,实现代码的复用。同时,多种继承方式为开发者提供了灵活的选择,以满足不同的需求。ES6 类的引入使得继承的语法更加简洁和直观,但底层仍然基于原型链和构造函数。

在实际项目中,正确使用原型与继承可以提高代码的复用性、实现多态以及构建灵活的插件和扩展机制。然而,也需要注意性能问题、避免原型污染和继承关系混乱等常见问题。通过深入理解原型与继承的原理和机制,开发者能够编写出更高效、更健壮的 JavaScript 代码。

希望通过本文的介绍,你对 JavaScript 面向对象编程中的原型与继承有了更深入的理解,并能够在实际开发中灵活运用这些知识。