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

深入理解JavaScript原型链与继承机制

2021-01-135.1k 阅读

JavaScript 的原型链与继承机制基础概念

什么是原型(Prototype)

在 JavaScript 中,每个函数都有一个 prototype 属性。这个属性是一个对象,它包含了可以由特定类型的所有实例共享的属性和方法。当我们通过构造函数创建一个新对象时,新对象会通过内部的 [[Prototype]] (在 ES6 之前,访问这个内部属性的唯一方式是通过 __proto__,但 __proto__ 现在已经不推荐使用,虽然大部分浏览器仍然支持)链接到构造函数的 prototype 对象。

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

function Person(name) {
    this.name = name;
}
Person.prototype.sayHello = function() {
    console.log(`Hello, I'm ${this.name}`);
};

let person1 = new Person('John');

在上述代码中,Person 函数有一个 prototype 对象,person1 是通过 new Person() 创建的实例。person1[[Prototype]] 指向 Person.prototype。当我们调用 person1.sayHello() 时,JavaScript 引擎会首先在 person1 自身寻找 sayHello 方法,如果没有找到,就会沿着 [[Prototype]] 链向上查找,也就是到 Person.prototype 中查找,最终找到并执行 sayHello 方法。

原型链(Prototype Chain)

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

继续上面 Person 的例子,如果我们定义一个 Student 构造函数,并且让 Student 继承自 Person

function Student(name, grade) {
    Person.call(this, name);
    this.grade = grade;
}
Student.prototype = Object.create(Person.prototype);
Student.prototype.constructor = Student;
Student.prototype.sayGrade = function() {
    console.log(`I'm in grade ${this.grade}`);
};

let student1 = new Student('Jane', 3);

在这个例子中,student1[[Prototype]] 指向 Student.prototype,而 Student.prototype[[Prototype]] 指向 Person.prototype。所以当我们调用 student1.sayHello() 时,由于 student1Student.prototype 本身都没有 sayHello 方法,JavaScript 会沿着原型链找到 Person.prototype 中的 sayHello 方法并执行。

深入理解原型链的构建与运作

构造函数、实例与原型的关系

  1. 构造函数的 prototype:每个构造函数都有一个 prototype 属性,这个属性指向一个对象,这个对象就是该构造函数所创建实例的原型对象。例如,前面提到的 Person 构造函数,Person.prototype 就是 Person 实例的原型对象。
  2. 实例的 [[Prototype]]:通过构造函数使用 new 关键字创建的实例,其内部有一个 [[Prototype]] 链接,这个链接指向构造函数的 prototype 对象。例如,person1[[Prototype]] 指向 Person.prototype
  3. 原型对象的 constructor:原型对象默认有一个 constructor 属性,它指向构造函数本身。在前面的例子中,Person.prototype.constructor 指向 Person 构造函数。不过,当我们像在 Student 的例子中重新设置 Student.prototype 时,需要手动将 Student.prototype.constructor 重新指向 Student,以保持这种关系的正确性。

原型链查找机制的细节

当我们访问一个对象的属性或方法时,JavaScript 引擎遵循以下步骤:

  1. 对象自身属性查找:首先,引擎会在对象自身的属性中查找是否有匹配的属性或方法。例如,对于 person1,如果有代码 person1.name,由于 person1 自身有 name 属性(在构造函数 Person 中通过 this.name = name 设置),所以直接返回该属性的值。
  2. 原型链查找:如果对象自身没有找到所需的属性或方法,引擎会沿着 [[Prototype]] 链向上查找。以 person1.sayHello() 为例,person1 自身没有 sayHello 方法,所以查找 Person.prototype,找到了 sayHello 方法并执行。如果在 Person.prototype 中也没有找到,会继续向上查找 Person.prototype[[Prototype]],直到到达 Object.prototype。如果在 Object.prototype 中也没有找到,返回 undefined

JavaScript 继承机制的多种实现方式

原型链继承

  1. 原理:通过将子构造函数的原型对象设置为父构造函数的实例,从而实现继承。例如:
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.prototype = new Animal();
Dog.prototype.constructor = Dog;

let myDog = new Dog('Buddy', 'Golden Retriever');
myDog.speak();

在上述代码中,Dog.prototype 被设置为 Animal 的一个实例,这样 Dog 的实例就可以访问 Animal.prototype 中的方法,如 speak。 2. 缺点:原型链继承存在一些问题。首先,子构造函数无法向父构造函数传递参数,像 Dog 构造函数不能给 Animal 构造函数传递 name 参数。其次,所有实例共享父构造函数原型对象上的属性,如果这些属性是引用类型,一个实例对其修改会影响其他实例。例如,如果在 Animal.prototype 上添加一个数组属性,不同的 Dog 实例对该数组的操作会相互影响。

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

  1. 原理:在子构造函数内部通过 callapply 方法调用父构造函数,从而在子构造函数实例的作用域中执行父构造函数的代码,达到继承父构造函数属性的目的。例如:
function Vehicle(numWheels) {
    this.numWheels = numWheels;
}

function Car(make, model, numWheels) {
    Vehicle.call(this, numWheels);
    this.make = make;
    this.model = model;
}

let myCar = new Car('Toyota', 'Corolla', 4);
console.log(myCar.numWheels);

在上述代码中,Car 构造函数通过 Vehicle.call(this, numWheels) 调用了 Vehicle 构造函数,将 Vehicle 构造函数的属性 numWheels 复制到了 Car 实例中。 2. 缺点:借用构造函数继承虽然解决了原型链继承中不能向父构造函数传递参数以及实例共享引用类型属性的问题,但它不能继承父构造函数原型对象上的方法。因为 Vehicle.prototype 上的方法并没有被复制到 Car 的实例或原型中。

组合继承(伪经典继承)

  1. 原理:组合继承结合了原型链继承和借用构造函数继承的优点。它通过借用构造函数继承父构造函数的属性,通过原型链继承父构造函数原型对象上的方法。例如:
function Shape(color) {
    this.color = color;
}
Shape.prototype.getColor = function() {
    return this.color;
};

function Circle(radius, color) {
    Shape.call(this, color);
    this.radius = radius;
}
Circle.prototype = Object.create(Shape.prototype);
Circle.prototype.constructor = Circle;
Circle.prototype.getArea = function() {
    return Math.PI * this.radius * this.radius;
};

let myCircle = new Circle(5, 'blue');
console.log(myCircle.getColor());
console.log(myCircle.getArea());

在上述代码中,Circle 构造函数通过 Shape.call(this, color) 继承了 Shape 构造函数的 color 属性,又通过 Circle.prototype = Object.create(Shape.prototype) 继承了 Shape.prototype 上的 getColor 方法。 2. 缺点:组合继承在调用父构造函数时,实际上会执行两次父构造函数的代码。一次是在 Circle.prototype = Object.create(Shape.prototype) 时,因为 Object.create 创建的对象内部会隐含地调用父构造函数;另一次是在 Circle 构造函数内部通过 Shape.call(this, color) 调用。这会导致一些不必要的性能开销。

寄生组合式继承

  1. 原理:寄生组合式继承是对组合继承的优化,它避免了组合继承中父构造函数被调用两次的问题。其核心思想是通过 Object.create 创建一个新的对象作为子构造函数的原型,然后手动设置其 constructor,并通过 call 方法调用父构造函数初始化实例属性。例如:
function inheritPrototype(subType, superType) {
    let prototype = Object.create(superType.prototype);
    prototype.constructor = subType;
    subType.prototype = prototype;
}

function Person(name) {
    this.name = name;
}
Person.prototype.sayName = function() {
    console.log(this.name);
};

function Student(name, grade) {
    Person.call(this, name);
    this.grade = grade;
}
inheritPrototype(Student, Person);
Student.prototype.sayGrade = function() {
    console.log(this.grade);
};

let student = new Student('Alice', 2);
student.sayName();
student.sayGrade();

在上述代码中,inheritPrototype 函数实现了寄生组合式继承。它首先通过 Object.create(superType.prototype) 创建一个新的原型对象,这个对象继承了父构造函数原型对象的属性和方法,但没有调用父构造函数。然后设置新原型对象的 constructor 为子构造函数,最后将子构造函数的 prototype 设置为这个新的原型对象。这样既实现了属性的继承(通过 Person.call(this, name)),又实现了方法的继承,同时避免了父构造函数的重复调用。 2. 优点:寄生组合式继承被认为是 JavaScript 中实现继承的最佳方式,它既解决了原型链继承和借用构造函数继承的缺点,又避免了组合继承中父构造函数重复调用的性能问题。

ES6 类继承

  1. 语法:ES6 引入了 class 关键字,使得 JavaScript 的继承语法更加简洁和直观。例如:
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;
    }
    bark() {
        console.log(`${this.name} barks`);
    }
}

let myDog = new Dog('Max', 'Labrador');
myDog.speak();
myDog.bark();

在上述代码中,Dog 类通过 extends 关键字继承自 Animal 类。在 Dogconstructor 中,通过 super(name) 调用了父类 Animalconstructor 方法,从而初始化 name 属性。Dog 类还可以定义自己特有的方法,如 bark。 2. 本质:虽然 class 语法看起来像传统面向对象语言中的类继承,但在 JavaScript 底层,它仍然是基于原型链的。class 只是语法糖,其内部实现仍然使用了原型链和构造函数的概念。例如,Dog.prototype 仍然指向一个对象,这个对象的 [[Prototype]] 指向 Animal.prototype,并且 Dog 实例的 [[Prototype]] 指向 Dog.prototype

原型链与继承机制在实际开发中的应用场景

代码复用与模块化

在大型项目开发中,代码复用是提高开发效率的关键。通过继承机制,我们可以将一些通用的属性和方法抽象到父类中,子类继承父类并根据需要进行扩展。例如,在一个图形绘制库中,我们可以定义一个 Shape 类作为所有图形类的基类,Shape 类包含一些通用的属性(如颜色、填充等)和方法(如绘制边框、设置样式等)。然后 CircleRectangle 等具体图形类继承自 Shape 类,并实现自己特有的绘制逻辑。这样,不同图形类之间就可以复用 Shape 类的代码,减少重复代码,提高代码的可维护性。

面向对象设计模式的实现

许多面向对象设计模式在 JavaScript 中依赖于原型链和继承机制来实现。例如,策略模式可以通过继承来定义不同的策略类,这些策略类继承自一个基类,基类定义了通用的接口,子类实现具体的策略逻辑。再比如,装饰器模式可以通过继承和原型链来动态地为对象添加新的行为。在这些设计模式的实现中,原型链和继承机制使得代码结构更加清晰,易于扩展和维护。

框架与库的开发

在 JavaScript 框架和库的开发中,原型链和继承机制也起着重要作用。例如,在一些 DOM 操作库中,可能会定义一个基类来封装通用的 DOM 操作方法,如获取元素、添加事件监听器等。然后不同的功能模块(如动画模块、表单处理模块等)可以继承这个基类,并扩展自己特有的功能。这样,整个库的代码结构更加模块化,各个模块之间通过继承实现了代码的复用和功能的扩展。

总结原型链与继承机制中的常见问题与注意事项

原型链污染

  1. 概念:原型链污染是一种安全漏洞,当恶意代码能够修改原型对象的属性时,就可能导致原型链污染。例如,如果一个库没有对传入的对象进行充分的验证,恶意代码可以通过向原型对象添加属性来影响整个应用程序的行为。
  2. 示例
// 假设存在一个函数,它接受一个对象并将其属性合并到一个目标对象中
function merge(target, source) {
    for (let key in source) {
        target[key] = source[key];
    }
    return target;
}

// 恶意对象
let maliciousObject = {
    __proto__: {
        evilMethod: function() {
            console.log('恶意代码执行');
        }
    }
};

let normalObject = {};
merge(normalObject, maliciousObject);
// 现在 normalObject 可以访问 evilMethod,即使它本身没有定义该方法
normalObject.evilMethod();
  1. 防范措施:为了防止原型链污染,在进行对象属性合并等操作时,应该使用 Object.hasOwnProperty 方法来确保只复制对象自身的属性,而不是原型链上的属性。例如:
function merge(target, source) {
    for (let key in source) {
        if (source.hasOwnProperty(key)) {
            target[key] = source[key];
        }
    }
    return target;
}

理解 this 在继承中的指向

  1. 问题:在继承关系中,this 的指向可能会让人困惑。特别是在方法调用时,this 的指向取决于调用该方法的对象。例如,在父类和子类的方法中,如果不注意 this 的绑定,可能会导致错误的结果。
  2. 示例
function Parent() {
    this.value = 'parent';
    this.printValue = function() {
        console.log(this.value);
    };
}

function Child() {
    Parent.call(this);
    this.value = 'child';
}
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

let child = new Child();
let printValue = child.printValue;
printValue(); // 输出 'parent',而不是预期的 'child',因为此时 this 指向全局对象(在严格模式下为 undefined)
  1. 解决方法:为了确保 this 的正确指向,可以使用箭头函数(箭头函数没有自己的 this,它的 this 继承自外层作用域),或者在调用方法时明确绑定 this,例如 printValue.call(child)

原型对象修改的影响

  1. 问题:直接修改原型对象可能会对已经创建的实例产生意外影响。因为所有实例都共享原型对象,如果在实例创建后修改原型对象,新添加的属性和方法会对所有实例可见。
  2. 示例
function Person() {}
Person.prototype.sayHello = function() {
    console.log('Hello');
};

let person1 = new Person();
Person.prototype.sayGoodbye = function() {
    console.log('Goodbye');
};
let person2 = new Person();

person1.sayGoodbye(); // 可以正常调用,即使 person1 创建时没有 sayGoodbye 方法
  1. 注意事项:在修改原型对象时,应该谨慎考虑其对现有实例的影响。如果可能,尽量在创建实例之前完成原型对象的定义和修改。如果必须在实例创建后修改原型对象,要确保这种修改不会导致程序出现意外行为。

继承层次过深的问题

  1. 问题:当继承层次过深时,代码的维护和理解会变得困难。查找属性和方法时,原型链会变得很长,这会影响性能。而且,深层次的继承可能会导致代码的耦合度增加,一个父类的修改可能会影响到多个子类。
  2. 解决方法:尽量保持继承层次的简洁和适度。如果发现继承层次过深,可以考虑使用其他设计模式,如组合模式来替代继承。组合模式通过将对象组合在一起,而不是通过继承来实现功能的复用,这样可以降低代码的耦合度,提高代码的灵活性和可维护性。

综上所述,深入理解 JavaScript 的原型链与继承机制对于编写高质量、可维护的 JavaScript 代码至关重要。在实际开发中,我们需要充分利用它们的优势,同时注意避免常见的问题和陷阱。通过合理运用继承机制和遵循最佳实践,我们可以构建出更加健壮、高效的 JavaScript 应用程序。