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

JavaScript prototype特性的继承奥秘

2022-03-015.2k 阅读

JavaScript 中的原型概念

在 JavaScript 里,每个函数都有一个 prototype 属性,这个属性指向一个对象,而这个对象就是通过该函数创建的实例的原型对象。例如:

function Person() {}
console.log(Person.prototype); 

上述代码定义了一个 Person 函数,通过 console.log(Person.prototype) 可以看到 Person 函数的原型对象。这个原型对象默认有一个 constructor 属性,它指向构造函数本身。

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

这里 Person.prototype.constructor === Person 会返回 true,表明 constructor 确实指向构造函数 Person

当我们使用 new 关键字创建一个实例时,实例会通过内部的 [[Prototype]] 链接到构造函数的原型对象。

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

这里 person.__proto__ === Person.prototype 会返回 true,说明实例 person[[Prototype]] 确实链接到了 Person 函数的原型对象。

原型链与继承

原型链是 JavaScript 实现继承的关键机制。当访问一个对象的属性或方法时,如果对象本身没有这个属性或方法,JavaScript 会沿着原型链向上查找,直到找到该属性或方法,或者到达原型链的顶端(null)。

比如我们定义一个简单的继承结构:

function Animal() {
    this.species = '动物';
}
Animal.prototype.getSpecies = function() {
    return this.species;
};
function Dog() {
    this.name = '小狗';
    Animal.call(this); 
}
Dog.prototype = Object.create(Animal.prototype); 
Dog.prototype.constructor = Dog; 
let myDog = new Dog();
console.log(myDog.getSpecies()); 
console.log(myDog.name); 

在上述代码中,Dog 继承自 Animal。首先,在 Dog 构造函数内部,通过 Animal.call(this) 调用 Animal 构造函数,这样 myDog 实例就会拥有 species 属性。然后,通过 Dog.prototype = Object.create(Animal.prototype) 创建了 Dog 的原型对象,并使其原型链继承自 Animal 的原型对象。最后,修正 Dog.prototype.constructor 使其指向 Dog 构造函数。

原型继承的本质

从本质上讲,JavaScript 的原型继承是基于对象之间的委托关系。当访问一个对象的属性时,实际上是在对象及其原型链上依次查找属性。这种委托机制使得代码可以复用原型对象上的属性和方法,从而实现类似传统面向对象语言中继承的效果。

例如,我们继续扩展上面的例子:

function Animal() {
    this.species = '动物';
}
Animal.prototype.getSpecies = function() {
    return this.species;
};
function Dog() {
    this.name = '小狗';
    Animal.call(this); 
}
Dog.prototype = Object.create(Animal.prototype); 
Dog.prototype.constructor = Dog; 
function GoldenRetriever() {
    this.color = '金色';
    Dog.call(this); 
}
GoldenRetriever.prototype = Object.create(Dog.prototype); 
GoldenRetriever.prototype.constructor = GoldenRetriever; 
let myGoldenRetriever = new GoldenRetriever();
console.log(myGoldenRetriever.getSpecies()); 
console.log(myGoldenRetriever.name); 
console.log(myGoldenRetriever.color); 

在这个更复杂的继承结构中,GoldenRetriever 继承自 DogDog 又继承自 Animal。当访问 myGoldenRetriever 的属性或方法时,JavaScript 会先在 myGoldenRetriever 自身查找,找不到就会沿着 GoldenRetriever.prototypeDog.prototype 一直到 Animal.prototype 查找,这就是原型继承本质的体现。

原型继承的优势与劣势

优势

  1. 代码复用:通过将属性和方法定义在原型对象上,所有实例可以共享这些代码,减少了内存开销。例如在前面的 AnimalDogGoldenRetriever 的例子中,getSpecies 方法定义在 Animal.prototype 上,所有 DogGoldenRetriever 的实例都可以使用这个方法,而不需要在每个实例上重复定义。
  2. 灵活的继承结构:JavaScript 的原型继承允许在运行时动态修改原型对象,从而改变所有实例的行为。比如:
function Person() {}
Person.prototype.sayHello = function() {
    console.log('Hello');
};
let person = new Person();
person.sayHello(); 
Person.prototype.sayHello = function() {
    console.log('New Hello');
};
person.sayHello(); 

这里在创建实例 person 后,修改了 Person.prototype 上的 sayHello 方法,person 实例再次调用 sayHello 方法时,就会执行新的逻辑。

劣势

  1. 原型链过长性能问题:当原型链过长时,查找属性或方法的性能会下降。因为每次查找都需要沿着原型链依次查找,直到找到目标属性或到达原型链顶端。例如,如果有多层继承关系:A -> B -> C -> D -> E,在 E 的实例上查找一个属性,可能需要经过多次查找才能找到,这会消耗更多的时间。
  2. 共享属性的意外修改:由于原型对象上的属性是所有实例共享的,如果不小心在某个实例上修改了共享的原型属性,可能会影响到其他实例。比如:
function Person() {}
Person.prototype.friends = [];
let person1 = new Person();
let person2 = new Person();
person1.friends.push('Alice');
console.log(person2.friends); 

这里 person1person2 共享 Person.prototype.friendsperson1 修改了 friends 数组,person2friends 数组也会受到影响。

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

在实际项目中,原型继承常用于创建对象的通用模板和行为。例如在一个游戏开发项目中,可能会有一个 Character 构造函数作为所有游戏角色的基类。

function Character() {
    this.health = 100;
    this.attack = 10;
}
Character.prototype.takeDamage = function(damage) {
    this.health -= damage;
    if (this.health <= 0) {
        console.log('角色死亡');
    }
};
function Warrior() {
    this.strength = 20;
    Character.call(this); 
}
Warrior.prototype = Object.create(Character.prototype); 
Warrior.prototype.constructor = Warrior; 
Warrior.prototype.swordAttack = function() {
    console.log('战士使用剑攻击,造成', this.attack * 2, '点伤害');
};
let myWarrior = new Warrior();
myWarrior.takeDamage(20);
console.log('战士剩余生命值:', myWarrior.health);
myWarrior.swordAttack(); 

在这个例子中,Warrior 继承自 Character,复用了 Character 的属性和方法,同时添加了自己特有的 swordAttack 方法。这样的继承结构使得代码具有良好的组织性和可维护性,不同类型的角色可以基于 Character 进行扩展。

深入理解原型对象的属性和方法

hasOwnProperty 方法

hasOwnProperty 方法用于判断一个对象是否拥有某个特定的自身属性,而不会检查原型链。例如:

function Person() {
    this.name = 'John';
}
Person.prototype.age = 30;
let person = new Person();
console.log(person.hasOwnProperty('name')); 
console.log(person.hasOwnProperty('age')); 

这里 person.hasOwnProperty('name') 会返回 true,因为 nameperson 的自身属性;而 person.hasOwnProperty('age') 会返回 false,因为 age 是从原型对象继承来的。

isPrototypeOf 方法

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

function Animal() {}
function Dog() {}
Dog.prototype = Object.create(Animal.prototype); 
let myDog = new Dog();
console.log(Animal.prototype.isPrototypeOf(myDog)); 
console.log(Dog.prototype.isPrototypeOf(myDog)); 

这里 Animal.prototype.isPrototypeOf(myDog)Dog.prototype.isPrototypeOf(myDog) 都会返回 true,因为 myDog 的原型链中包含 Animal.prototypeDog.prototype

Object.getPrototypeOf 方法

Object.getPrototypeOf 方法用于获取一个对象的原型对象。例如:

function Person() {}
let person = new Person();
let prototype = Object.getPrototypeOf(person);
console.log(prototype === Person.prototype); 

这里通过 Object.getPrototypeOf(person) 获取到 person 的原型对象,并与 Person.prototype 进行比较,结果为 true

现代 JavaScript 中继承的改进与替代方案

在 ES6 引入了 class 关键字,它提供了更接近传统面向对象语言的继承语法,但本质上还是基于原型继承。例如:

class Animal {
    constructor() {
        this.species = '动物';
    }
    getSpecies() {
        return this.species;
    }
}
class Dog extends Animal {
    constructor() {
        super();
        this.name = '小狗';
    }
}
let myDog = new Dog();
console.log(myDog.getSpecies()); 
console.log(myDog.name); 

这里 class 语法使得继承关系更加清晰易读,extends 关键字明确表示继承关系,super() 用于调用父类的构造函数。

除了 class 语法,还可以使用 Object.create 来更灵活地创建具有继承关系的对象。例如:

let animal = {
    species: '动物',
    getSpecies: function() {
        return this.species;
    }
};
let dog = Object.create(animal);
dog.name = '小狗';
console.log(dog.getSpecies()); 
console.log(dog.name); 

这种方式直接基于已有的对象创建新对象,并继承其属性和方法,在一些简单场景下非常实用。

原型继承在不同 JavaScript 运行环境中的差异

虽然 JavaScript 的原型继承机制在各个主流运行环境(如 Chrome V8、Firefox SpiderMonkey、Node.js 等)中基本一致,但还是存在一些细微的差异。

例如,在早期版本的 Internet Explorer 中,__proto__ 属性的支持并不完善,这可能导致一些依赖 __proto__ 的代码在 IE 中无法正常运行。而现代浏览器对 __proto__ 的支持已经比较统一,但在一些老旧项目的兼容处理中仍需注意。

另外,不同运行环境在原型链查找性能上可能会有一些差异。例如,Chrome V8 引擎针对原型链查找做了一些优化,在处理大型原型链时可能会比其他一些引擎表现更好。但这些差异通常在普通应用场景下并不明显,只有在处理极端复杂或大规模的原型继承结构时才会凸显出来。

如何优化基于原型继承的代码

  1. 合理设计原型链长度:避免创建过长的原型链,尽量将相关的属性和方法组织在合理的层次结构中。例如,在一个大型项目中,如果有过多层次的继承,可以考虑将一些通用的功能提取到更上层的原型对象中,减少不必要的中间层次。
  2. 避免意外修改共享属性:对于需要共享但又不希望被意外修改的属性,可以使用 Object.defineProperty 来定义为只读属性。例如:
function Person() {}
Object.defineProperty(Person.prototype, 'defaultCity', {
    value: '默认城市',
    writable: false
});
let person = new Person();
// 尝试修改会被忽略
person.defaultCity = '新城市';
console.log(person.defaultCity); 
  1. 缓存原型链上的属性和方法:如果在代码中频繁访问原型链上的某个属性或方法,可以在对象自身缓存该属性或方法的引用,以提高访问效率。例如:
function Animal() {}
Animal.prototype.getSpecies = function() {
    return this.species;
};
function Dog() {
    this.species = '狗';
    this._getSpecies = this.getSpecies; 
}
Dog.prototype = Object.create(Animal.prototype); 
Dog.prototype.constructor = Dog; 
let myDog = new Dog();
// 直接访问缓存的方法
console.log(myDog._getSpecies()); 

原型继承与其他继承方式的对比

与传统面向对象语言(如 Java、C++)的类继承相比,JavaScript 的原型继承具有以下特点:

  1. 基于对象而非类:传统类继承是基于类的定义,通过类来创建对象。而 JavaScript 是基于对象,通过原型对象来实现继承,更加灵活。
  2. 动态性:JavaScript 的原型继承允许在运行时动态修改原型对象,从而改变所有实例的行为。而传统类继承在类定义后,其结构相对固定。

与基于原型的其他语言(如 Self、Io)相比,JavaScript 的原型继承也有一些不同之处。例如,在 Self 语言中,对象没有明确的构造函数概念,所有对象都是通过克隆已有对象创建的。而 JavaScript 虽然也是基于原型,但保留了构造函数的概念,通过 new 关键字和构造函数来创建实例。

实际应用中如何选择合适的继承方式

在实际应用中,选择继承方式需要考虑项目的规模、需求和团队技术栈等因素。

对于小型项目或注重灵活性的项目,JavaScript 原生的原型继承或使用 Object.create 的方式可能更合适,因为它们简单直接,不需要过多的语法糖。

对于大型项目,特别是需要与传统面向对象语言开发的模块进行集成的项目,ES6 的 class 语法可能更受欢迎,因为它提供了更熟悉的面向对象编程结构,便于团队协作和代码维护。

例如,在一个前端网页开发项目中,如果只是简单地创建一些具有相似行为的对象,使用原型继承或 Object.create 就可以满足需求;而在一个大型的企业级 JavaScript 应用中,使用 class 语法来组织代码结构会使代码更清晰,更易于理解和维护。

总之,理解 JavaScript 原型继承的奥秘,并根据实际情况选择合适的继承方式,对于编写高效、可维护的 JavaScript 代码至关重要。无论是在前端开发、后端开发还是其他领域,掌握原型继承的特性都能让开发者更好地发挥 JavaScript 的优势。通过合理运用原型继承,开发者可以实现代码的复用、优化内存使用,并构建出灵活且健壮的软件系统。在实际开发过程中,不断实践和总结经验,结合不同的继承方式,能够更好地应对各种复杂的业务需求。同时,关注 JavaScript 语言的发展和新特性,如 ES6 之后的各种改进,也有助于提升开发效率和代码质量。随着项目的规模和复杂度不断变化,灵活选择和运用继承方式是每个 JavaScript 开发者需要掌握的重要技能。从简单的原型链构建到复杂的多层继承结构设计,都需要开发者深入理解原型继承的本质,以避免出现性能问题和逻辑错误。通过对原型继承在不同场景下的应用进行分析和实践,开发者可以更好地驾驭 JavaScript 这门强大的编程语言,创造出更优秀的软件产品。无论是面向对象编程风格还是函数式编程风格,原型继承都能在其中找到合适的位置,为开发者提供更多的编程思路和解决方案。在日常开发中,多思考不同继承方式的优缺点,结合实际项目需求进行选择,是提高代码质量和开发效率的关键。同时,不断学习和探索新的继承模式和优化方法,也能让开发者在 JavaScript 开发领域保持竞争力,适应不断变化的技术需求。