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

JavaScript对象继承与原型链的实现

2021-04-222.6k 阅读

JavaScript对象继承与原型链的基本概念

在JavaScript中,对象继承是一种机制,它允许一个对象获取另一个对象的属性和方法。原型链则是实现对象继承的核心概念。

每个JavaScript对象都有一个[[Prototype]]内部属性(在ES6之前没有直接访问的方法,ES6引入了Object.getPrototypeOf()Object.setPrototypeOf()方法来操作),这个内部属性指向另一个对象,这个对象就是原型对象。当我们访问一个对象的属性或方法时,如果该对象本身没有定义这个属性或方法,JavaScript引擎就会沿着原型链向上查找,直到找到该属性或方法,或者到达原型链的顶端(null)。

例如,考虑以下简单代码:

function Animal() {
    this.species = '动物';
}

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

function Dog() {
    this.name = '小狗';
}

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

const myDog = new Dog();
console.log(myDog.getSpecies()); 

在上述代码中,Dog对象通过Object.create(Animal.prototype)继承了Animal对象的属性和方法。myDog对象在查找getSpecies方法时,自身没有该方法,就会沿着原型链找到Animal.prototype上的getSpecies方法。

原型链的深入理解

原型对象的作用

原型对象为对象提供了共享属性和方法的机制。通过将属性和方法定义在原型对象上,所有基于该原型创建的对象实例都可以共享这些属性和方法,而不需要在每个实例上重复创建。

例如:

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

Person.prototype.sayHello = function() {
    console.log('你好,我是'+ this.name);
};

const person1 = new Person('张三');
const person2 = new Person('李四');

person1.sayHello(); 
person2.sayHello(); 

这里sayHello方法定义在Person.prototype上,person1person2实例都可以调用该方法,而不需要在每个实例上重新定义。

原型链的顶端

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

const obj = {};
console.log(obj.toString()); 
console.log(obj.hasOwnProperty('name')); 

原型链与属性查找

当访问一个对象的属性时,JavaScript引擎首先在对象自身查找该属性。如果找不到,就会沿着原型链向上查找,直到找到该属性或到达原型链顶端(null)。如果到达顶端仍未找到,就返回undefined

function Shape() {
    this.color = '红色';
}

function Rectangle() {
    this.width = 10;
    this.height = 5;
}

Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle;

const rect = new Rectangle();
console.log(rect.color); 
console.log(rect.area); 

在上述代码中,rect对象自身没有color属性,所以沿着原型链在Shape.prototype上找到了color属性。而area属性在rect自身和原型链上都未找到,所以返回undefined

JavaScript对象继承的实现方式

原型链继承

原型链继承是JavaScript中最基本的继承方式,通过将子类型的原型设置为父类型的实例来实现继承。

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

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

function Child() {
    // 这里没有调用Parent的构造函数,所以Child实例没有自己的value属性
}

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

const child = new Child();
console.log(child.getValue()); 

原型链继承的优点是实现简单,并且可以共享原型上的属性和方法。缺点是在创建子类型实例时,不能向父类型构造函数传递参数,而且所有子类型实例会共享父类型实例的属性,当这些属性是引用类型时可能会出现问题。

构造函数继承

构造函数继承通过在子类型构造函数内部调用父类型构造函数来实现继承。

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

function Child(value) {
    Parent.call(this, value);
}

const child = new Child(20);
console.log(child.value); 

构造函数继承的优点是可以向父类型构造函数传递参数,并且每个子类型实例都有自己独立的属性。缺点是不能共享父类型原型上的方法,每个子类型实例都需要重新创建相同的方法,浪费内存。

组合继承

组合继承结合了原型链继承和构造函数继承的优点,通过在子类型构造函数中调用父类型构造函数来继承属性,通过设置子类型原型为父类型原型的实例来继承方法。

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

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

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

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

const child = new Child(30, '子实例');
console.log(child.getValue()); 
console.log(child.name); 

组合继承既可以向父类型构造函数传递参数,又可以共享父类型原型上的方法,是一种比较常用的继承方式。

寄生组合继承

寄生组合继承是对组合继承的优化。在组合继承中,子类型的原型会包含一个不必要的父类型实例属性,而寄生组合继承通过创建一个空函数,并将其原型设置为父类型原型,然后再将子类型原型设置为该空函数的实例,避免了这个问题。

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

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

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

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

inheritPrototype(Child, Parent);

const child = new Child(40, '优化子实例');
console.log(child.getValue()); 
console.log(child.name); 

寄生组合继承是目前实现JavaScript对象继承的最优方式,既避免了原型链继承和构造函数继承的缺点,又优化了组合继承中存在的问题。

ES6类继承

ES6引入了class关键字,使得JavaScript的继承语法更加清晰和简洁,其底层仍然是基于原型链的继承。

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

    getSpecies() {
        return this.species;
    }
}

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

    bark() {
        console.log('汪汪汪');
    }
}

const myDog = new Dog('犬科', '旺财');
console.log(myDog.getSpecies()); 
myDog.bark(); 

在ES6类继承中,extends关键字用于指定继承的父类,super关键字用于调用父类的构造函数和方法。

原型链与性能优化

减少原型链查找次数

由于原型链查找需要遍历原型链,查找次数过多会影响性能。尽量将常用的属性和方法定义在对象自身,而不是依赖原型链查找。

例如:

function Person(name) {
    this.name = name;
    // 不推荐将常用方法定义在原型上
    // Person.prototype.sayHello = function() {
    //     console.log('你好,我是'+ this.name);
    // };
    this.sayHello = function() {
        console.log('你好,我是'+ this.name);
    };
}

const person = new Person('王五');
person.sayHello(); 

sayHello方法直接定义在Person构造函数内部,每次调用sayHello方法时就不需要进行原型链查找,提高了性能。

避免原型链过长

过长的原型链会导致属性查找时间变长。尽量保持原型链的简洁,避免多层继承。

例如,在设计对象继承体系时,合理规划继承层次,避免出现类似A -> B -> C -> D -> E这样过长的原型链。

原型链与内存管理

原型链与内存泄漏

如果在原型链上存在循环引用,可能会导致内存泄漏。例如:

function A() {
    this.b = new B();
}

function B() {
    this.a = new A();
}

在上述代码中,A实例引用B实例,B实例又引用A实例,形成了循环引用。如果这些对象没有被正确释放,就会导致内存泄漏。

为了避免这种情况,在对象不再使用时,要手动打破循环引用。例如:

let a = new A();
// 使用a和a.b
a = null;

通过将a赋值为null,使得A实例和B实例都可以被垃圾回收机制回收,避免了内存泄漏。

共享原型对象的内存占用

由于原型对象上的属性和方法是被所有基于该原型创建的对象实例共享的,合理利用这一点可以减少内存占用。

例如,对于一些不变的配置信息或工具方法,可以定义在原型对象上,而不是在每个实例上重复创建。

function Config() {
    // 这里不定义配置属性
}

Config.prototype.settings = {
    theme: 'default',
    language: 'zh - CN'
};

const config1 = new Config();
const config2 = new Config();
console.log(config1.settings.theme); 
console.log(config2.settings.language); 

通过将配置信息定义在原型对象上,config1config2实例共享这些配置信息,减少了内存占用。

原型链相关的常见问题与解决方案

instanceof操作符与原型链

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

function Animal() {}
function Dog() {}

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

const myDog = new Dog();
console.log(myDog instanceof Dog); 
console.log(myDog instanceof Animal); 

在上述代码中,myDogDog的实例,同时由于Dog继承自AnimalmyDog也是Animal的实例,所以两个instanceof操作都返回true

hasOwnProperty方法与原型链

hasOwnProperty方法用于判断一个对象是否拥有某个自身属性,而不考虑原型链。

function Person() {
    this.name = '赵六';
}

Person.prototype.age = 30;

const person = new Person();
console.log(person.hasOwnProperty('name')); 
console.log(person.hasOwnProperty('age')); 

person.hasOwnProperty('name')返回true,因为nameperson自身的属性;person.hasOwnProperty('age')返回false,因为age是从原型链上继承的属性。

原型对象的动态修改

可以动态修改原型对象,这会影响所有基于该原型创建的对象实例。

function Vehicle() {}

Vehicle.prototype.move = function() {
    console.log('移动');
};

const car = new Vehicle();
car.move(); 

Vehicle.prototype.move = function() {
    console.log('快速移动');
};

const bike = new Vehicle();
car.move(); 
bike.move(); 

在上述代码中,先创建了car实例并调用move方法,之后修改了Vehicle.prototype上的move方法。再创建bike实例,carbike实例调用move方法时都会执行修改后的代码,因为它们共享原型对象。

原型链在实际项目中的应用场景

代码复用

在开发中,很多对象可能具有一些共同的属性和方法,通过原型链继承可以将这些共同部分提取到原型对象上,实现代码复用。

例如,在一个游戏开发项目中,可能有CharacterEnemyPlayer等对象,它们都有一些共同的属性(如位置、生命值)和方法(如移动、攻击),可以通过原型链继承来实现这些代码的复用。

function Character(x, y, health) {
    this.x = x;
    this.y = y;
    this.health = health;
}

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

Character.prototype.attack = function(target) {
    target.health -= 10;
};

function Player(x, y, health, name) {
    Character.call(this, x, y, health);
    this.name = name;
}

Player.prototype = Object.create(Character.prototype);
Player.prototype.constructor = Player;

function Enemy(x, y, health, type) {
    Character.call(this, x, y, health);
    this.type = type;
}

Enemy.prototype = Object.create(Character.prototype);
Enemy.prototype.constructor = Enemy;

通过这种方式,PlayerEnemy对象可以复用Character对象的属性和方法,减少了代码冗余。

插件系统

在开发插件系统时,原型链继承可以用于实现插件的通用功能和特定功能。

例如,开发一个网页插件框架,有一个基础的Plugin类,定义了一些通用的方法(如初始化、销毁),然后各个具体的插件类(如ImagePluginVideoPlugin)继承自Plugin类,并添加各自的特定功能。

class Plugin {
    constructor() {
        this.isInitialized = false;
    }

    init() {
        this.isInitialized = true;
        console.log('插件初始化');
    }

    destroy() {
        this.isInitialized = false;
        console.log('插件销毁');
    }
}

class ImagePlugin extends Plugin {
    loadImage(src) {
        if (this.isInitialized) {
            console.log('加载图片:' + src);
        } else {
            console.log('请先初始化插件');
        }
    }
}

class VideoPlugin extends Plugin {
    playVideo(src) {
        if (this.isInitialized) {
            console.log('播放视频:' + src);
        } else {
            console.log('请先初始化插件');
        }
    }
}

这样,各个具体插件类可以复用Plugin类的通用功能,同时实现自己的特定功能。

面向对象设计模式

在实现面向对象设计模式时,原型链继承也经常被用到。例如,在实现原型模式时,通过克隆原型对象来创建新的对象实例。

function Shape() {
    this.color = '黑色';
}

Shape.prototype.clone = function() {
    const newShape = Object.create(Object.getPrototypeOf(this));
    for (let prop in this) {
        if (this.hasOwnProperty(prop)) {
            newShape[prop] = this[prop];
        }
    }
    return newShape;
};

function Rectangle() {
    Shape.call(this);
    this.width = 10;
    this.height = 5;
}

Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle;

const rect1 = new Rectangle();
const rect2 = rect1.clone();
console.log(rect2.color); 
console.log(rect2.width); 

在上述代码中,Rectangle对象继承自Shape对象,并实现了clone方法,通过克隆原型对象和复制自身属性来创建新的Rectangle实例。

通过深入理解JavaScript对象继承与原型链的实现原理、应用场景以及性能优化等方面,可以更好地利用这一特性进行高效的JavaScript编程,构建出更加健壮和可维护的代码。在实际项目中,根据具体需求选择合适的继承方式,并注意原型链相关的性能和内存管理问题,将有助于提升项目的质量和开发效率。同时,随着JavaScript语言的不断发展,对原型链和对象继承的理解也需要不断深入和更新,以适应新的特性和最佳实践。