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

JavaScript如何实现面向对象的继承

2023-09-072.6k 阅读

JavaScript中的面向对象编程基础

在深入探讨JavaScript的继承之前,我们先来回顾一下JavaScript面向对象编程的一些基础概念。JavaScript从本质上来说是一门基于原型(prototype - based)的语言,这与基于类(class - based)的语言(如Java、C++)有所不同。但随着ES6引入了 class 关键字,JavaScript也可以以类似基于类的方式进行面向对象编程。

对象与属性

在JavaScript中,对象是属性的集合。属性可以是数据属性(包含数据值)或访问器属性(包含getter和setter函数)。例如,我们创建一个简单的 person 对象:

let person = {
    name: 'John',
    age: 30,
    sayHello: function() {
        console.log(`Hello, my name is ${this.name}`);
    }
};

这里,nameage 是数据属性,而 sayHello 是一个函数属性(也常被称为方法)。this 关键字在函数内部指向调用该函数的对象。所以当我们调用 person.sayHello() 时,this.name 就会指向 person.name

构造函数

构造函数是用于创建对象的函数。传统上,构造函数的命名首字母大写,以与普通函数区分开来。例如:

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayHello = function() {
        console.log(`Hello, my name is ${this.name}`);
    };
}
let john = new Person('John', 30);

使用 new 关键字调用构造函数时,会发生以下几件事:

  1. 创建一个新的空对象。
  2. 这个新对象的 [[Prototype]] (内部属性,在ES6之前通过 __proto__ 访问,ES6之后推荐使用 Object.getPrototypeOf()Object.setPrototypeOf() )会被设置为构造函数的 prototype 属性。
  3. 构造函数内部的 this 指向这个新创建的对象。
  4. 执行构造函数中的代码,为新对象添加属性和方法。
  5. 如果构造函数没有显式返回一个对象,则返回这个新创建的对象。

原型(Prototype)

每个函数都有一个 prototype 属性,它是一个对象。当我们使用构造函数创建对象时,新对象的 [[Prototype]] 会指向构造函数的 prototype。这意味着新对象可以访问构造函数 prototype 对象上的属性和方法。例如:

function Animal(name) {
    this.name = name;
}
Animal.prototype.speak = function() {
    console.log(`${this.name} makes a sound.`);
};
let dog = new Animal('Buddy');
dog.speak(); // 输出: Buddy makes a sound.

在这里,dog 对象本身并没有 speak 方法,但它可以通过原型链访问到 Animal.prototype.speak。原型链是JavaScript实现继承的核心机制之一。

JavaScript实现继承的传统方式

原型链继承

原型链继承是JavaScript中最基本的继承方式。其核心思想是通过将一个构造函数的原型设置为另一个构造函数的实例,从而实现继承关系。例如,我们有一个 Animal 构造函数和一个 Dog 构造函数,Dog 继承自 Animal

function Animal(name) {
    this.name = name;
}
Animal.prototype.speak = function() {
    console.log(`${this.name} makes a sound.`);
};
function Dog(name, breed) {
    this.breed = breed;
}
// 将Dog的原型设置为Animal的实例
Dog.prototype = new Animal();
Dog.prototype.constructor = Dog; // 修正构造函数指向
let buddy = new Dog('Buddy', 'Golden Retriever');
buddy.speak(); // 输出: Buddy makes a sound.

在上述代码中,Dog.prototype = new Animal() 这一行使得 Dog 的原型链指向了 Animal。因此,Dog 的实例 buddy 可以访问 Animal.prototype 上的 speak 方法。不过,原型链继承存在一些问题:

  1. 共享属性问题:由于所有实例共享原型对象上的属性,对于引用类型的属性,一个实例对其修改会影响其他实例。例如,如果在 Animal.prototype 上添加一个数组属性:
Animal.prototype.favoriteFoods = [];
let dog1 = new Dog('Dog1', 'Breed1');
let dog2 = new Dog('Dog2', 'Breed2');
dog1.favoriteFoods.push('Bone');
console.log(dog2.favoriteFoods); // 输出: ['Bone']
  1. 无法向父类构造函数传参:在 Dog.prototype = new Animal() 中,无法为 Animal 构造函数传递参数,这在实际应用中会有很大限制。

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

为了解决原型链继承的一些问题,我们可以使用借用构造函数继承。这种方式通过在子类构造函数内部调用父类构造函数,并将 this 绑定到子类实例,从而实现继承。例如:

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

Dog 构造函数中,Animal.call(this, name) 调用了 Animal 构造函数,并将 this 指向 Dog 的实例。这样,Dog 的实例就拥有了 Animal 构造函数所定义的属性。这种方法的优点是:

  1. 解决了共享属性问题:每个实例都有自己独立的属性,不存在引用类型属性共享的问题。
  2. 可以向父类构造函数传参:可以像上述代码一样为 Animal 构造函数传递参数。

然而,借用构造函数继承也有缺点:

  1. 方法无法复用:每个实例都有自己的方法副本,而不是共享原型上的方法,这会浪费内存。例如,如果在 Animal 构造函数中定义一个方法:
function Animal(name) {
    this.name = name;
    this.speak = function() {
        console.log(`${this.name} makes a sound.`);
    };
}
function Dog(name, breed) {
    Animal.call(this, name);
    this.breed = breed;
}
let dog1 = new Dog('Dog1', 'Breed1');
let dog2 = new Dog('Dog2', 'Breed2');
console.log(dog1.speak === dog2.speak); // 输出: false
  1. 子类无法访问父类原型上的方法:因为没有建立原型链关系,子类实例无法访问父类原型上的属性和方法。

组合继承(伪经典继承)

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

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 = new Animal();
Dog.prototype.constructor = Dog;
let buddy = new Dog('Buddy', 'Golden Retriever');
buddy.speak(); // 输出: Buddy makes a sound.

这种方式既解决了共享属性问题,又能复用方法。每个实例都有自己的属性,而方法则共享自原型。不过,组合继承也有一个小问题:在创建子类实例时,父类构造函数会被调用两次。一次是在 Dog.prototype = new Animal() 时,另一次是在 Animal.call(this, name) 时。这会导致一些不必要的开销,尤其是当父类构造函数有复杂的初始化逻辑时。

寄生组合继承

寄生组合继承是为了解决组合继承中父类构造函数被调用两次的问题而提出的。它的核心思想是通过创建一个中间函数,让这个中间函数的原型指向父类原型,然后将子类原型设置为这个中间函数的实例。这样既建立了正确的原型链,又避免了父类构造函数的多余调用。例如:

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;
}
// 创建中间函数
function inheritPrototype(subType, superType) {
    let prototype = Object.create(superType.prototype); // 创建父类原型的副本
    prototype.constructor = subType; // 修正构造函数指向
    subType.prototype = prototype;
}
inheritPrototype(Dog, Animal);
let buddy = new Dog('Buddy', 'Golden Retriever');
buddy.speak(); // 输出: Buddy makes a sound.

在上述代码中,Object.create(superType.prototype) 创建了一个新对象,其原型指向 superType.prototype。然后将这个新对象设置为子类的原型,并修正构造函数指向。这种方式既高效又能正确实现继承,是一种非常推荐的传统继承方式。

ES6类继承

ES6引入了 class 关键字,使得JavaScript的面向对象编程更加接近基于类的语言。class 本质上是一个语法糖,它基于原型链继承,但提供了更简洁和直观的语法。

定义类和继承

定义一个类很简单,例如:

class Animal {
    constructor(name) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}
class Dog extends Animal {
    constructor(name, breed) {
        super(name); // 调用父类构造函数
        this.breed = breed;
    }
}
let buddy = new Dog('Buddy', 'Golden Retriever');
buddy.speak(); // 输出: Buddy makes a sound.

在上述代码中,class Dog extends Animal 表示 Dog 类继承自 Animal 类。在 Dog 类的构造函数中,必须先调用 super(name),这是为了初始化父类的属性。super 关键字在这里代表父类的构造函数。同时,Dog 类的实例可以访问 Animal 类的方法。

重写方法

子类可以重写父类的方法。例如,我们在 Dog 类中重写 speak 方法:

class Animal {
    constructor(name) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}
class Dog extends Animal {
    constructor(name, breed) {
        super(name);
        this.breed = breed;
    }
    speak() {
        console.log(`${this.name} barks.`);
    }
}
let buddy = new Dog('Buddy', 'Golden Retriever');
buddy.speak(); // 输出: Buddy barks.

这里 Dog 类的 speak 方法覆盖了 Animal 类的 speak 方法。如果在子类方法中需要调用父类的同名方法,可以使用 super.speak()。例如:

class Animal {
    constructor(name) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}
class Dog extends Animal {
    constructor(name, breed) {
        super(name);
        this.breed = breed;
    }
    speak() {
        super.speak();
        console.log(`${this.name} barks.`);
    }
}
let buddy = new Dog('Buddy', 'Golden Retriever');
buddy.speak(); 
// 输出: 
// Buddy makes a sound.
// Buddy barks.

访问器属性与静态成员

  1. 访问器属性:在ES6类中,可以使用 getset 关键字定义访问器属性。例如:
class Person {
    constructor(firstName, lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
    get fullName() {
        return `${this.firstName} ${this.lastName}`;
    }
    set fullName(name) {
        let parts = name.split(' ');
        this.firstName = parts[0];
        this.lastName = parts[1];
    }
}
let person = new Person('John', 'Doe');
console.log(person.fullName); // 输出: John Doe
person.fullName = 'Jane Smith';
console.log(person.firstName); // 输出: Jane
console.log(person.lastName); // 输出: Smith
  1. 静态成员:可以使用 static 关键字定义静态属性和方法。静态成员属于类本身,而不是类的实例。例如:
class MathUtils {
    static add(a, b) {
        return a + b;
    }
}
console.log(MathUtils.add(2, 3)); // 输出: 5

混入(Mix - in)模式与继承

混入模式是一种在JavaScript中实现类似多重继承的方式。由于JavaScript不支持传统的多重继承(一个类继承多个父类),混入模式通过将多个对象的属性和方法合并到一个目标对象中来模拟多重继承的效果。

简单混入示例

let canSwim = {
    swim: function() {
        console.log(`${this.name} is swimming.`);
    }
};
let canFly = {
    fly: function() {
        console.log(`${this.name} is flying.`);
    }
};
function mixin(target, ...sources) {
    sources.forEach(source => {
        Object.keys(source).forEach(key => {
            target[key] = source[key];
        });
    });
    return target;
}
function Animal(name) {
    this.name = name;
}
let Duck = function(name) {
    Animal.call(this, name);
};
mixin(Duck.prototype, canSwim, canFly);
let donald = new Duck('Donald');
donald.swim(); // 输出: Donald is swimming.
donald.fly(); // 输出: Donald is flying.

在上述代码中,mixin 函数将 canSwimcanFly 对象的属性和方法合并到了 Duck.prototype 上。这样,Duck 的实例就拥有了 swimfly 方法,仿佛 Duck 同时继承了多个对象的行为。

更复杂的混入实现

我们可以通过ES6的类和 Object.assign() 方法来实现更复杂的混入。例如:

class CanSwim {
    swim() {
        console.log(`${this.name} is swimming.`);
    }
}
class CanFly {
    fly() {
        console.log(`${this.name} is flying.`);
    }
}
function mixin(...mixins) {
    return function(Base) {
        return class extends Base {
            constructor(...args) {
                super(...args);
                mixins.forEach(mixin => {
                    Object.assign(this, new mixin());
                });
            }
        };
    };
}
class Animal {
    constructor(name) {
        this.name = name;
    }
}
let Duck = mixin(CanSwim, CanFly)(Animal);
let donald = new Duck('Donald');
donald.swim(); // 输出: Donald is swimming.
donald.fly(); // 输出: Donald is flying.

在这个示例中,mixin 函数返回一个高阶函数,这个高阶函数接受一个基类 Base 并返回一个新的类。新类继承自 Base,并在构造函数中通过 Object.assign() 将混入类的实例属性合并到自身。

继承中的原型链与作用域

原型链的深入理解

在JavaScript的继承中,原型链起着关键作用。当访问一个对象的属性时,JavaScript首先在对象自身查找该属性。如果找不到,则沿着原型链向上查找,直到找到该属性或到达原型链的顶端(null)。例如:

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 = new Animal();
Dog.prototype.constructor = Dog;
let buddy = new Dog('Buddy', 'Golden Retriever');
// 查找speak方法的过程
// 1. 在buddy对象自身查找,没有找到
// 2. 沿着原型链,在Dog.prototype中查找,没有找到
// 3. 继续沿着原型链,在Animal.prototype中找到speak方法并执行
buddy.speak(); 

理解原型链对于正确实现和调试继承关系非常重要。例如,如果在原型链上不小心覆盖了某个属性或方法,可能会导致意想不到的结果。

作用域与继承

在JavaScript中,作用域分为全局作用域、函数作用域(ES6之前)和块级作用域(ES6引入 letconst 后)。在继承关系中,作用域的理解也很关键。例如,在构造函数中定义的变量属于函数作用域,而原型上定义的方法中的 this 指向调用该方法的对象。

function Animal(name) {
    let localVar = 'I am local to Animal constructor';
    this.name = name;
}
Animal.prototype.speak = function() {
    console.log(`${this.name} says something.`);
    // 这里无法访问localVar,因为它在Animal构造函数的作用域内
};

在子类中,同样要注意作用域问题。当调用父类的方法时,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 = new Animal();
Dog.prototype.constructor = Dog;
Dog.prototype.bark = function() {
    this.speak(); // 这里调用父类的speak方法,this指向Dog的实例
    console.log(`${this.name} barks.`);
};
let buddy = new Dog('Buddy', 'Golden Retriever');
buddy.bark(); 
// 输出: 
// Buddy makes a sound.
// Buddy barks.

继承中的性能考量

传统继承方式的性能问题

  1. 原型链继承:如前面提到的,原型链继承存在共享属性问题,这在某些情况下可能导致性能问题。例如,如果原型上有一个大的对象或数组,多个实例共享可能会导致内存占用增加,并且对共享属性的频繁修改可能会影响其他实例。
  2. 借用构造函数继承:由于每个实例都有自己的方法副本,这会浪费内存,尤其是当方法体较大时。如果有大量实例,内存开销会显著增加。
  3. 组合继承:虽然组合继承解决了一些问题,但父类构造函数被调用两次的问题仍然存在。如果父类构造函数有复杂的初始化逻辑,这会增加不必要的性能开销。

ES6类继承的性能

ES6类继承本质上还是基于原型链继承,只是语法更简洁。在性能方面,它与传统的寄生组合继承类似,因为它也避免了父类构造函数的多余调用。但由于它是语法糖,在底层实现上可能会有一些轻微的性能差异。不过,现代JavaScript引擎已经对类继承进行了优化,所以在大多数情况下,性能差异可以忽略不计。

优化建议

  1. 合理使用原型:尽量将不变的方法定义在原型上,而不是在构造函数中定义,以避免每个实例都有方法副本。
  2. 避免不必要的继承层次:继承层次过深可能会导致原型链过长,查找属性和方法的时间增加。尽量保持继承结构简单。
  3. 使用对象池:对于频繁创建和销毁的对象,可以考虑使用对象池技术,减少对象创建的开销。例如,如果有大量的 Dog 实例,可以预先创建一定数量的 Dog 对象放在对象池中,需要时从池中获取,使用完后放回池中。

总结常见的继承陷阱与最佳实践

常见继承陷阱

  1. 构造函数调用顺序错误:在ES6类继承中,如果在子类构造函数中没有先调用 super() 就访问 this,会导致错误。例如:
class Animal {
    constructor(name) {
        this.name = name;
    }
}
class Dog extends Animal {
    constructor(name, breed) {
        // 错误,应该先调用super(name)
        this.breed = breed; 
        super(name); 
    }
}
// 会抛出TypeError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
  1. 原型链混乱:在传统继承中,如果不正确设置原型链,可能会导致属性和方法查找异常。例如,忘记修正构造函数指向,或者错误地修改了原型对象,可能会导致 instanceof 操作符返回不正确的结果。
function Animal(name) {
    this.name = name;
}
function Dog(name, breed) {
    Animal.call(this, name);
    this.breed = breed;
}
Dog.prototype = Animal.prototype; // 错误,应该是new Animal()
let buddy = new Dog('Buddy', 'Golden Retriever');
console.log(buddy instanceof Dog); // 可能输出false,因为原型链设置错误
  1. 共享属性的意外修改:在原型链继承中,共享的引用类型属性可能会被意外修改。例如:
function Animal() {
    this.friends = [];
}
Animal.prototype.addFriend = function(friend) {
    this.friends.push(friend);
};
function Dog() {
    Animal.call(this);
}
Dog.prototype = new Animal();
Dog.prototype.constructor = Dog;
let dog1 = new Dog();
let dog2 = new Dog();
dog1.addFriend('Dog3');
console.log(dog2.friends); // 输出: ['Dog3'],这可能不是预期的结果

最佳实践

  1. 使用ES6类继承:在ES6环境下,优先使用 class 关键字进行继承,因为它语法更简洁,且不容易出错。
  2. 谨慎使用原型:在传统继承中,要谨慎设置原型链,确保构造函数指向正确,并且注意共享属性的问题。如果可能,尽量避免在原型上使用引用类型的共享属性。
  3. 测试与调试:在实现继承关系后,要进行充分的测试,包括使用 instanceof 操作符检查继承关系,以及检查属性和方法的正确性。使用调试工具(如Chrome DevTools)来跟踪原型链和属性查找过程,有助于发现潜在的问题。
  4. 文档化:为继承关系编写清晰的文档,包括每个类的作用、继承关系、属性和方法的说明等。这有助于其他开发者理解和维护代码。

通过深入理解JavaScript的继承机制,避免常见陷阱,并遵循最佳实践,我们可以编写出健壮、高效且易于维护的面向对象JavaScript代码。无论是传统的继承方式还是ES6的类继承,都为我们提供了强大的工具来构建复杂的软件系统。