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

JavaScript中的原型链与函数继承解析

2021-09-051.9k 阅读

JavaScript中的原型链

原型(Prototype)的基本概念

在JavaScript中,每一个函数都有一个 prototype 属性。这个 prototype 属性是一个对象,它包含了可以被该函数创建的实例所共享的属性和方法。例如,我们定义一个简单的构造函数 Person

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

这里的 Person 函数有一个 prototype 属性,它是一个对象,默认情况下,这个对象有一个 constructor 属性,指向构造函数本身,即 Person.prototype.constructor === Person 会返回 true

当我们使用 new 关键字创建 Person 的实例时,实例会通过内部的 [[Prototype]] 链接到 Person.prototype。例如:

let person1 = new Person('John');

person1 这个实例内部有一个 [[Prototype]],它指向 Person.prototype。在现代JavaScript中,可以通过 Object.getPrototypeOf(person1) 来获取这个原型对象,或者使用非标准但更常用的 __proto__ 属性(person1.__proto__)。

原型链的形成

原型链是基于原型对象之间的链接形成的。当我们访问一个对象的属性或方法时,如果该对象本身没有这个属性或方法,JavaScript会沿着原型链向上查找。例如,继续上面的 Person 例子:

Person.prototype.sayHello = function() {
    console.log(`Hello, I'm ${this.name}`);
};
let person1 = new Person('John');
person1.sayHello(); // 输出: Hello, I'm John

当我们调用 person1.sayHello() 时,person1 本身没有 sayHello 方法,所以JavaScript会在 person1.__proto__(也就是 Person.prototype)中查找,找到了 sayHello 方法并执行。

原型链会一直向上查找,直到找到目标属性或方法,或者到达原型链的顶端(Object.prototype)。如果在 Object.prototype 中也没有找到,那么访问结果将是 undefined。例如:

function Animal() {}
Animal.prototype.move = function() {
    console.log('I can move');
};
function Dog(name) {
    this.name = name;
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
let myDog = new Dog('Buddy');
myDog.move(); // 输出: I can move

这里 Dog 的实例 myDog 通过原型链找到了 Animal.prototype 上的 move 方法。Dog.prototype 是通过 Object.create(Animal.prototype) 创建的,这使得 Dog.prototype[[Prototype]] 指向 Animal.prototype,从而形成了原型链。

原型链的重要性

  1. 代码复用:通过原型链,多个对象可以共享相同的属性和方法,避免了重复定义。例如在上面的 Person 例子中,所有 Person 的实例都可以共享 sayHello 方法,而不需要在每个实例中都定义一遍。
  2. 继承:原型链是JavaScript实现继承的基础。通过将一个构造函数的原型设置为另一个构造函数的实例,我们可以实现类似传统面向对象语言中的继承。这在后面的函数继承部分会详细介绍。
  3. 理解作用域链:虽然原型链和作用域链是不同的概念,但理解原型链有助于更好地理解作用域链。作用域链用于变量查找,而原型链用于对象属性查找,它们都是JavaScript在运行时查找信息的重要机制。

函数继承

传统的函数继承方式 - 构造函数绑定

在JavaScript早期,一种常见的实现继承的方式是通过构造函数绑定。例如,我们有一个 Animal 构造函数和一个 Dog 构造函数,想要让 Dog 继承 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 myDog = new Dog('Buddy', 'Golden Retriever');
myDog.speak(); // 输出: Buddy makes a sound

Dog 构造函数中,我们使用 Animal.call(this, name),这使得 Animal 构造函数中的代码在 Dog 的实例上下文中执行。这样,Dog 的实例就拥有了 Animal 构造函数定义的属性和方法。

这种方式的优点是简单直接,并且可以传递参数给父构造函数。然而,它也有缺点。由于每个 Dog 实例都重新创建了 speak 方法,这导致内存浪费。如果有很多 Dog 实例,每个实例都有自己独立的 speak 方法副本,而实际上它们可以共享同一个方法。

原型式继承

原型式继承是基于原型链的一种继承方式。我们可以使用 Object.create 方法来实现。例如:

let animal = {
    name: 'Generic Animal',
    speak: function() {
        console.log(this.name +'makes a sound');
    }
};
let dog = Object.create(animal);
dog.name = 'Buddy';
dog.breed = 'Golden Retriever';
dog.speak(); // 输出: Buddy makes a sound

这里通过 Object.create(animal) 创建了一个新对象 dogdog[[Prototype]] 指向 animal。这样 dog 就可以继承 animal 的属性和方法。

这种方式的优点是简单,并且可以实现属性的共享。但是,它也有局限性。如果原型对象中的属性是引用类型,那么所有继承自该原型的对象都会共享这个引用类型的属性,可能会导致意外的修改。例如:

let animal = {
    friends: ['Other Animal'],
    speak: function() {
        console.log(this.name +'makes a sound');
    }
};
let dog1 = Object.create(animal);
dog1.name = 'Buddy';
let dog2 = Object.create(animal);
dog2.name = 'Max';
dog1.friends.push('New Friend');
console.log(dog2.friends); // 输出: ['Other Animal', 'New Friend']

由于 friends 是数组(引用类型),dog1dog2 共享这个数组,dog1friends 的修改也会影响到 dog2

组合继承(经典继承)

组合继承结合了构造函数绑定和原型式继承的优点。我们来看一个例子:

function Animal(name) {
    this.name = name;
    this.friends = ['Other Animal'];
}
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;
let dog1 = new Dog('Buddy', 'Golden Retriever');
let dog2 = new Dog('Max', 'Labrador');
dog1.friends.push('New Friend');
console.log(dog1.friends); // 输出: ['Other Animal', 'New Friend']
console.log(dog2.friends); // 输出: ['Other Animal']
dog1.speak(); // 输出: Buddy makes a sound

在这个例子中,通过 Animal.call(this, name)Dog 的实例中初始化了 Animal 的属性,保证每个 Dog 实例有自己独立的 friends 数组。同时,通过 Dog.prototype = Object.create(Animal.prototype)Dog 的实例可以共享 Animal.prototype 上的 speak 方法。

这种方式在很长一段时间内是JavaScript中实现继承的标准方式,它避免了构造函数绑定中方法重复创建的问题,也解决了原型式继承中引用类型属性共享的问题。

ES6类继承

ES6引入了 class 关键字,使得JavaScript的继承语法更接近传统面向对象语言。例如:

class Animal {
    constructor(name) {
        this.name = name;
        this.friends = ['Other Animal'];
    }
    speak() {
        console.log(this.name +'makes a sound');
    }
}
class Dog extends Animal {
    constructor(name, breed) {
        super(name);
        this.breed = breed;
    }
}
let dog1 = new Dog('Buddy', 'Golden Retriever');
let dog2 = new Dog('Max', 'Labrador');
dog1.friends.push('New Friend');
console.log(dog1.friends); // 输出: ['Other Animal', 'New Friend']
console.log(dog2.friends); // 输出: ['Other Animal']
dog1.speak(); // 输出: Buddy makes a sound

这里 class Dog extends Animal 表示 Dog 类继承自 Animal 类。在 Dog 的构造函数中,super(name) 调用了父类 Animal 的构造函数来初始化继承的属性。

ES6类继承在语法上更加简洁明了,同时也实现了组合继承的功能。然而,需要注意的是,ES6类本质上还是基于原型链的,它只是对原型链继承进行了语法糖封装。

深入理解原型链与函数继承的细节

原型链中的 constructor 属性

在原型链中,每个原型对象都有一个 constructor 属性,它指向构造该原型对象的构造函数。例如,在前面的 Person 例子中,Person.prototype.constructor === Person。这个属性在对象创建和类型识别时非常有用。

然而,当我们通过 Object.create 等方式修改原型链时,可能会破坏这个 constructor 属性的指向。例如:

function Animal() {}
let dogProto = Object.create(Animal.prototype);
// dogProto.constructor 现在指向 Object,而不是 Animal
// 可以手动修复
dogProto.constructor = Animal;
function Dog() {}
Dog.prototype = dogProto;
let myDog = new Dog();

在这个例子中,通过 Object.create 创建 dogProto 后,dogProto.constructor 指向了 Object。为了保持一致性,我们手动将其修复为指向 Animal

函数继承中的方法重写

在函数继承中,子类可以重写从父类继承的方法。例如,在 AnimalDog 的继承关系中,我们可以在 Dog 类中重写 speak 方法:

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.speak = function() {
    console.log(this.name +'barks');
};
let myDog = new Dog('Buddy', 'Golden Retriever');
myDog.speak(); // 输出: Buddy barks

这里 Dog.prototype 重写了 Animal.prototype 中的 speak 方法。当调用 myDog.speak() 时,会执行 Dog.prototype 中的 speak 方法,而不是 Animal.prototype 中的。

原型链的性能问题

虽然原型链为JavaScript带来了强大的继承和代码复用能力,但在某些情况下可能会影响性能。当我们访问一个对象的属性时,如果该属性在对象本身不存在,JavaScript需要沿着原型链向上查找,这可能会增加查找时间。

例如,如果原型链很长,并且我们频繁访问一个不存在于对象本身的属性,就会导致性能下降。为了避免这种情况,可以尽量在对象本身定义常用的属性,减少原型链查找的次数。另外,现代JavaScript引擎对原型链查找进行了优化,在大多数情况下,性能问题并不显著,但在性能敏感的场景中,还是需要注意。

原型链与闭包的关系

原型链和闭包是JavaScript中两个重要的概念,它们之间虽然没有直接的联系,但在实际应用中可能会相互影响。闭包是指函数可以访问其外部作用域的变量,即使在外部作用域已经执行完毕后。

例如,我们可以在一个函数内部返回另一个函数,并且内部函数可以访问外部函数的变量:

function outer() {
    let value = 10;
    function inner() {
        console.log(value);
    }
    return inner;
}
let closureFunc = outer();
closureFunc(); // 输出: 10

当涉及到原型链和闭包时,需要注意的是,闭包中的变量查找和原型链中的属性查找是不同的机制。闭包主要用于访问外部作用域的变量,而原型链用于查找对象的属性。然而,如果在原型链中的某个函数使用了闭包,就需要同时考虑这两种机制的影响。例如:

function Animal() {
    let privateValue = 'private';
    this.getPrivate = function() {
        return privateValue;
    };
}
function Dog() {
    Animal.call(this);
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
let myDog = new Dog();
console.log(myDog.getPrivate()); // 输出: private

在这个例子中,Animal 构造函数中的 getPrivate 方法形成了一个闭包,它可以访问 privateValue 变量。Dog 的实例通过原型链继承了 getPrivate 方法,并且可以正常访问闭包中的 privateValue

原型链与函数继承的实际应用场景

面向对象编程

在JavaScript的面向对象编程中,原型链和函数继承是实现类和对象关系的核心机制。通过继承,我们可以创建具有层次结构的对象,实现代码的复用和模块化。例如,在一个游戏开发中,我们可能有一个 Character 类作为所有游戏角色的基类,然后有 WarriorMage 等子类继承自 Character

class Character {
    constructor(name, health) {
        this.name = name;
        this.health = health;
    }
    attack() {
        console.log(this.name +'attacks');
    }
}
class Warrior extends Character {
    constructor(name, health, strength) {
        super(name, health);
        this.strength = strength;
    }
    attack() {
        console.log(this.name +'attacks with sword');
    }
}
class Mage extends Character {
    constructor(name, health, mana) {
        super(name, health);
        this.mana = mana;
    }
    attack() {
        console.log(this.name +'casts a spell');
    }
}
let warrior = new Warrior('Conan', 100, 20);
let mage = new Mage('Gandalf', 80, 50);
warrior.attack(); // 输出: Conan attacks with sword
mage.attack(); // 输出: Gandalf casts a spell

这里通过原型链和类继承,WarriorMage 继承了 Character 的基本属性和方法,并根据自身特点重写了 attack 方法。

库和框架开发

在JavaScript库和框架开发中,原型链和函数继承也经常被使用。例如,在一些DOM操作库中,可能会有一个基类 Element 来表示HTML元素,然后有 ButtonInput 等子类继承自 Element。这些子类可以继承基类的通用方法,如添加事件监听器、修改样式等,同时也可以定义自己特有的方法。

function Element(tagName) {
    this.element = document.createElement(tagName);
}
Element.prototype.addClass = function(className) {
    this.element.classList.add(className);
};
function Button(text) {
    Element.call(this, 'button');
    this.element.textContent = text;
}
Button.prototype = Object.create(Element.prototype);
Button.prototype.constructor = Button;
Button.prototype.click = function(callback) {
    this.element.addEventListener('click', callback);
};
let myButton = new Button('Click me');
myButton.addClass('btn-primary');
myButton.click(() => console.log('Button clicked'));

通过这种方式,库开发者可以利用原型链和函数继承来组织代码,提高代码的可维护性和复用性。

代码复用与模块化

原型链和函数继承有助于实现代码复用和模块化。在一个大型项目中,可能会有很多重复的功能,通过继承可以将这些功能提取到基类中,子类只需要继承并根据需要进行扩展。例如,在一个电商项目中,可能有一个 Product 类作为所有商品的基类,然后有 BookClothing 等子类继承自 Product

class Product {
    constructor(name, price) {
        this.name = name;
        this.price = price;
    }
    getDetails() {
        return `Name: ${this.name}, Price: ${this.price}`;
    }
}
class Book extends Product {
    constructor(name, price, author) {
        super(name, price);
        this.author = author;
    }
    getDetails() {
        return `${super.getDetails()}, Author: ${this.author}`;
    }
}
class Clothing extends Product {
    constructor(name, price, size) {
        super(name, price);
        this.size = size;
    }
    getDetails() {
        return `${super.getDetails()}, Size: ${this.size}`;
    }
}
let book = new Book('JavaScript Basics', 20, 'John Doe');
let clothing = new Clothing('T - Shirt', 15, 'M');
console.log(book.getDetails()); // 输出: Name: JavaScript Basics, Price: 20, Author: John Doe
console.log(clothing.getDetails()); // 输出: Name: T - Shirt, Price: 15, Size: M

通过继承,BookClothing 复用了 Product 的基本属性和 getDetails 方法,并根据自身特点进行了扩展,实现了代码的模块化和复用。

原型链与函数继承的陷阱与避免方法

原型链污染

原型链污染是一种安全漏洞,攻击者可以通过修改原型对象来影响整个应用程序。例如,如果一个应用程序接收用户输入并将其作为对象的属性名,而没有进行充分的验证,攻击者可以输入 __proto__ 来修改原型对象。

// 假设这是一个有漏洞的代码
function Vulnerable() {}
let userInput = '__proto__.evilProperty = "Hacked"';
let obj = {};
obj[userInput] = 'Some value';
// 现在所有对象的原型都被污染了
let newObj = {};
console.log(newObj.evilProperty); // 输出: Hacked

为了避免原型链污染,应该对所有用户输入进行严格的验证和过滤,避免使用用户输入作为对象的属性名,特别是要防止 __proto__ 等特殊属性被恶意利用。

意外的原型链修改

有时候在代码中可能会意外地修改原型链,导致不可预期的结果。例如,在修改一个对象的原型时,没有正确处理 constructor 属性。

function Animal() {}
let dogProto = {};
// 意外地没有设置正确的原型关系
Dog.prototype = dogProto;
function Dog() {}
let myDog = new Dog();
// myDog.__proto__.constructor 现在指向 Object,而不是 Dog

为了避免这种情况,在修改原型链时,要确保正确设置 constructor 属性,并且在使用 Object.create 等方法时,要明确了解其对原型链的影响。

继承中的命名冲突

在继承体系中,可能会出现命名冲突的问题。例如,子类和父类定义了相同名称的属性或方法。

function Parent() {
    this.value = 'Parent value';
}
function Child() {
    Parent.call(this);
    this.value = 'Child value';
}
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
let child = new Child();
console.log(child.value); // 输出: Child value

在这个例子中,Child 类重写了 Parent 类中的 value 属性。虽然这在某些情况下是有意的,但也可能会导致混淆。为了避免命名冲突,可以使用更具描述性的属性和方法名,或者使用ES6的 Symbol 类型来定义唯一的属性名。

性能相关陷阱

如前面提到的,过长的原型链可能会导致性能问题。另外,频繁地访问原型链上的属性也可能影响性能。例如,在一个循环中多次访问原型链上的方法:

function Animal() {}
Animal.prototype.speak = function() {
    console.log('I speak');
};
function Dog() {
    Animal.call(this);
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
let myDog = new Dog();
for (let i = 0; i < 1000000; i++) {
    myDog.speak();
}

在这个循环中,每次调用 myDog.speak() 都需要沿着原型链查找 speak 方法,这可能会导致性能下降。为了提高性能,可以将原型链上的方法缓存到对象本身,或者尽量减少在性能敏感代码中对原型链的访问。

总结原型链与函数继承的要点

  1. 原型链:是JavaScript中对象属性查找的机制,基于对象之间的 [[Prototype]] 链接。每个函数都有一个 prototype 属性,通过 new 关键字创建的实例会将其 [[Prototype]] 指向构造函数的 prototype。原型链的顶端是 Object.prototype
  2. 函数继承:包括构造函数绑定、原型式继承、组合继承和ES6类继承等方式。构造函数绑定用于初始化属性,原型式继承用于共享属性和方法,组合继承结合了两者的优点,ES6类继承是对原型链继承的语法糖封装。
  3. 实际应用:在面向对象编程、库和框架开发以及代码复用与模块化等方面都有广泛应用。通过继承可以实现代码的复用、组织和扩展。
  4. 陷阱与避免:要注意原型链污染、意外的原型链修改、继承中的命名冲突以及性能相关陷阱。通过严格的输入验证、正确处理原型关系、合理命名和优化代码等方式来避免这些问题。

深入理解原型链与函数继承是掌握JavaScript高级编程的关键,它们为JavaScript带来了强大的面向对象编程能力,同时也需要开发者谨慎使用,以避免潜在的问题。