深入浅出JavaScript继承机制:解密原型、原型链与面向对象实战攻略-LMLPHP

深入浅出JavaScript继承机制:解密原型、原型链与面向对象实战攻略-LMLPHP

深入浅出JavaScript继承机制:解密原型、原型链与面向对象实战攻略-LMLPHP

🔥 引言


🧱 原型基础

首先,每个JavaScript对象都有一个内置的属性叫做[[Prototype]],通常通过__proto__访问(非标准但广泛支持),它指向创建该对象的构造函数的prototype属性。构造函数的prototype本身也是一个对象,拥有自己的属性和方法。

示例代码

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

Animal.prototype.speak = function() {
    console.log('I am an animal');
};

let cat = new Animal('Kitty'); // 创建Animal的实例
console.log(cat)

在这里,cat__proto__指向Animal.prototype,这意味着cat可以访问Animal.prototype上的方法,如speak
深入浅出JavaScript继承机制:解密原型、原型链与面向对象实战攻略-LMLPHP


⛓️ 原型链的形成

当试图访问一个对象的属性或方法时,如果该对象本身没有定义,JavaScript引擎会向上查找其原型(__proto__指向的对象),这一过程会一直追溯到原型链的顶部,通常是Object.prototype。如果在那里还找不到,就会返回undefined

示例代码

console.log(cat.speak === Animal.prototype.speak); // true

这行代码确认了cat实例的speak方法确实是指向Animal.prototype上的speak方法,证实了继承关系的存在。

cat.speak(); // 输出: "I am an animal"

调用cat.speak()成功执行并打印出"I am an animal",这证明了cat实例能够正确地沿原型链访问到Animal.prototype上定义的speak方法。

console.log(cat.toString());

尽管在Animal构造函数或其原型上没有直接定义toString方法,cat.toString()仍然能够执行并按预期工作。这是因为所有JavaScript对象(除非被特殊修改)都默认从Object.prototype继承了toString方法。toString方法通常用于返回对象的字符串表示,对于普通的对象实例,默认情况下返回的是"[object Object]"


🔄 修改原型的影响

修改原型对象会影响所有通过该构造函数创建的实例。这是因为所有实例共享同一个原型对象。

Animal.prototype.speak = function() {
    console.log('Now I can talk too!');
};

cat.speak(); // 输出变为 "Now I can talk too!"

这里,我们修改了Animal.prototype上的speak方法,所有Animal的实例调用speak时都会反映出这一变化。

由于修改原型会影响到所有通过该构造函数创建的实例,开发中应当谨慎操作,以防止原型污染。一种常见做法是使用不可变(Immutable)的设计模式,或者在必要时为每个实例单独添加方法,而不是修改原型。

function giveUniqueVoice(animal, voice) {
    animal.speak = function() {
        console.log(voice);
    };
}

let specialCat = new Animal('Whiskers');
giveUniqueVoice(specialCat, 'Meow!');

specialCat.speak(); // 输出 "Meow!"
cat.speak(); // 输出 "My behavior has been changed!"

在这个例子中,我们通过giveUniqueVoice函数为特定实例specialCat添加了一个独特的speak方法,这样做不会影响到其他Animal实例的行为。


🏁 原型链的尽头

原型链的尽头,指的是JavaScript中对象原型链层级结构的最终点,这个终点是null。在JavaScript中,每个对象(除null外)都有一个内部属性称为[[Prototype]],它指向创建该对象的原型对象。这个原型对象本身也可能是一个对象,同样拥有自己的[[Prototype]],如此形成了所谓的原型链。

Object.prototype是大多数对象原型链中倒数第二层的对象,几乎所有JavaScript对象(直接或间接)的原型链最终都会追溯到Object.prototype,而Object.prototype[[Prototype]]则为null,形成了原型链的闭环。

如下代码所示:

class Animal {
	name = 'Animal';
	speak() {
		console.log('I am an animal');
	}
	constructor(name) {
		this.name = name;
	}
}
class Dog extends Animal {
	constructor(name) {
		super(name); 
	}
}
const myDog = new Dog('Rex');
console.log(myDog)

深入浅出JavaScript继承机制:解密原型、原型链与面向对象实战攻略-LMLPHP

  1. myDog (Dog实例):

    • 直接属性:name = 'Rex',这是因为在Dog类的构造函数中,通过super(name)调用了父类Animal的构造函数,并将'Rex'作为参数传递,从而设置了实例的name属性。
    • 内部属性[[Prototype]]指向Dog.prototype
  2. Dog.prototype:

    • 这是Dog类的原型对象,默认包含一个constructor属性指向Dog构造函数自身。
    • 内部属性[[Prototype]]指向Animal.prototype,因为Dog类通过extends Animal继承了Animal类,所以其原型链会链接到Animal类的原型对象。
  3. Animal.prototype:

    • 包含了Animal类定义的方法,如speak()
    • 内部属性[[Prototype]]指向Object.prototype,这是所有JavaScript对象原型链的标准终点前一站,表明Animal类的原型也是基于基础的JavaScript对象构建的。
  4. Object.prototype:

    • 这是所有JavaScript对象的原型链最终到达的地方,包含了像toString(), valueOf()等基本方法。
    • 内部属性[[Prototype]]null,标志着原型链的终点。

综上所述,myDog的原型链路径如下:

  • myDog -> Dog.prototype -> Animal.prototype -> Object.prototype -> null

这条链展示了从myDog实例出发,逐级向上通过原型链查找属性和方法的过程,直到抵达null,即原型链的顶层。

为什么null标志着结束?

null作为Object.prototype[[Prototype]],是一个特意的设计选择,。null既不是对象也不是函数,它是一种特殊的值,用来表示空值或者尚未赋值的状态。在原型链上下文中,它起到了终止链式查找的作用,防止无限循环查找。

实际意义 🌐

理解原型链的这一终端特性,对开发者来说有几个重要含义:

  1. 性能考量:它确保了属性查找有一个明确的终点,,从而优化了访问速度。
  2. 对象基础:揭示了所有对象共享的基本行为,强调了JavaScript中一切皆对象的原则,即使是null这样的特殊值也是对象行为逻辑的一部分。
  3. 继承体系的清晰度:有助于开发者构建清晰的继承结构,知道何时应该直接在对象上定义方法,何时通过原型链继承,以及如何避免无意中修改基础对象的行为。

原型链的尽头指向Object.prototype,其[[Prototype]]null,这一设计精巧地构建了JavaScript对象继承的基础框架。掌握这一概念,对于深入理解对象间的继承关系、避免常见的原型链错误,以及高效地设计和维护代码结构都是至关重要的。它是通往JavaScript高级编程之路上的一块基石。


🔄 继承的实现方式

1. 原型链继承 🌀

最直接的继承方式就是通过原型链。上面的例子已经展示了这一点,但我们可以更明确地设置原型链:

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

// 使用Animal的prototype作为Dog.prototype的原型
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog; // 修复constructor指向

Dog.prototype.bark = function() {
    console.log('Woof!');
};

let myDog = new Dog('Rex');
myDog.speak(); // I am an animal
myDog.bark(); // Woof!

这段代码展示了如何使用原型链继承JavaScript中实现继承。这里是逐步解析:

  • 定义子类构造函数 Dog:

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

    Dog 构造函数接收一个参数 name,并将其作为实例的 name 属性。

  • 设置 Dog.prototype 的原型为 Animal.prototype 的副本:

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

    这行代码是关键,它使用 Object.create 方法创建了 Animal.prototype 的一个新对象,然后将其赋值给 Dog.prototype。这样一来,所有通过 Dog 构造函数创建的实例都会在其原型链上找到 Animal.prototype,从而继承了 Animal 的属性和方法。

  • . 修复 constructor 指向:

    Dog.prototype.constructor = Dog;
    

    由于我们直接改变了 Dog.prototype 的指向,原本指向 Dogconstructor 现在会指向 Animal。为了修正这一点,我们需要手动将其设置回 Dog

  • Dog.prototype 上定义 bark 方法:

    Dog.prototype.bark = function() {
        console.log('Woof!');
    };
    

    这为 Dog 的实例添加了一个独有的方法 bark

  • 创建 Dog 的实例并测试:

    let myDog = new Dog('Rex');
    myDog.speak(); // 输出 "I am an animal"
    myDog.bark(); // 输出 "Woof!"
    

    通过 new Dog('Rex') 创建了一个名为 “Rex” 的狗实例。由于 Dog.prototype 指向了 Animal.prototype 的副本,myDog 可以访问到 Animal 上的 speak 方法。同时,它也有自己特有的 bark 方法。

综上所述,这段代码演示了如何利用原型链实现JavaScript中的继承,让子类能够复用父类的属性和方法,同时也能够扩展自己的特性。

  • 特点:简单直接,通过将子类型的原型指向父类型的实例,实现方法的继承。
  • 优点:易于实现,节省内存(共享方法)。
  • 缺点:;。

2. 构造函数继承 🏗️

另一种方式是通过在子类构造函数内部调用超类构造函数,这种方式不涉及原型链,而是直接复制属性。

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

function Dog(name) {
    Animal.call(this, name);
    this.species = 'Canine';
}

let myDog = new Dog('Rex');
console.log(myDog.name); // Rex
console.log(myDog.species); // Canine

这段代码展示了构造函数继承的方式实现JavaScript中的继承。下面是详细的解析:

  • 定义子类构造函数 Dog:

    function Dog(name) {
        Animal.call(this, name);
        this.species = 'Canine';
    }
    
    • Dog 构造函数内部,通过 Animal.call(this, name) 调用了 Animal 构造函数。这里的 call 方法改变了 Animal 内部 this 的指向,使其指向当前 Dog 实例,从而使得 Dog 实例能够继承 Animal 的属性和方法。这就是构造函数继承的核心所在。
    • 接着,Dog 构造函数还定义了自己的属性 species,设置为 'Canine'
  • 创建 Dog 实例并检查属性:

    let myDog = new Dog('Rex');
    console.log(myDog.name); // 输出 "Rex"
    console.log(myDog.species); // 输出 "Canine"
    

    通过 new Dog('Rex') 创建了一个 Dog 的实例,并传入名字 'Rex'。由于在 Dog 构造函数中调用了 Animal.call(this, name)myDog 实例继承了 Animalname 属性,值为 'Rex'。同时,myDog 实例还有自己特有的属性 species,值为 'Canine'

总结来说,这段代码演示了如何通过在子类构造函数内部手动调用父类构造函数(并使用 callapply 方法绑定正确的 this 上下文)来实现继承,这种方式允许子类继承父类的属性,同时可以扩展自己的属性和方法。

  • 特点:通过在子类构造函数内部调用父类构造函数,实现属性的继承。
  • 优点:每个实例都有自己的属性副本,解决了原型链继承中的属性共享问题。
  • 缺点:;每次实例化都会创建方法的新副本,浪费内存。

3. 组合继承(经典继承)👨‍👩‍👧‍👦

结合原型链继承和构造函数继承,是最常用的继承模式。

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

Animal.prototype.speak = function() {
    console.log('I am an animal');
};

function Dog(name) {
    Animal.call(this, name);
    this.species = 'Canine';
}

// 使用Animal的prototype作为Dog.prototype的原型
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
    console.log('Woof!');
};

let myDog = new Dog('Rex');
myDog.speak(); // I am an animal
myDog.bark(); // Woof!
console.log(myDog.species); // Canine

这段代码展示了JavaScript中的一种继承模式,结合了构造函数继承原型链继承(也称作组合继承),是实现继承的常用方式之一。下面是详细的解析:

  • 定义子类构造函数 Dog:

    function Dog(name) {
        Animal.call(this, name);
        this.species = 'Canine';
    }
    

    Dog 构造函数内部,使用 Animal.call(this, name) 调用了 Animal 构造函数,实现了属性的继承(构造函数继承)。同时,它还定义了特有的属性 species

  • 设置 Dog.prototype 并修复构造函数指针:

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

    这两行代码通过 Object.create(Animal.prototype) 设置了 Dog.prototype,使得 Dog 的实例可以通过原型链访问到 Animal.prototype 上的方法,实现了方法的继承(原型链继承)。然后,修正了构造函数指针,因为默认情况下,Object.create 会将原型链上原有的构造函数指针设为 Animal

  • Dog.prototype 上定义 bark 方法:

    Dog.prototype.bark = function() {
        console.log('Woof!');
    };
    

    Dog 类添加了特有的方法 bark

  • 创建 Dog 实例并测试:

    let myDog = new Dog('Rex');
    myDog.speak(); // 输出 "I am an animal"
    myDog.bark(); // 输出 "Woof!"
    console.log(myDog.species); // 输出 "Canine"
    

    myDog 既是 Dog 的实例,也能够访问到 Animalspeak 方法,同时具有 Dog 特有的 bark 方法和 species 属性,展示了组合继承的特性。

这种组合继承方式综合了构造函数继承和原型链继承的优点,既能够继承实例属性,又能有效复用方法,是JavaScript中较为完善的继承实现方式之一。

  • 特点:结合了原型链继承和构造函数继承的优点,是最常用的继承模式。
  • 优点:既能继承属性也能继承方法,且每个实例都有自己的属性副本,同时方法又是共享的。
  • 缺点:构造函数中调用了两次父类构造函数(一次在子类构造函数内部,一次在原型链设定时),稍微有些冗余。

4. ES6 Class继承 🎉

ES6引入了基于class的语法糖,使得继承更加清晰易懂。

class Animal {
    constructor(name) {
        this.name = name;
    }
    speak() {
        console.log('I am an animal');
    }
}

class Dog extends Animal {
    constructor(name) {
        super(name); // 调用父类构造函数
        this.species = 'Canine';
    }
    bark() {
        console.log('Woof!');
    }
}

let myDog = new Dog('Rex');
myDog.speak(); // I am an animal
myDog.bark(); // Woof!
console.log(myDog.species); // Canine

这段代码展示了使用ES6的class语法来实现面向对象编程中的继承。下面是代码的详细解析:

  • 定义基类 Animal:

    class Animal {
        constructor(name) {
            this.name = name;
        }
        speak() {
            console.log('I am an animal');
        }
    }
    

    Animal 类通过 constructor 方法定义了一个构造器,用于初始化 name 属性,并定义了一个 speak 方法。

  • 定义子类 Dog 继承 Animal:

    class Dog extends Animal {
        constructor(name) {
            super(name); // 调用父类构造函数
            this.species = 'Canine';
        }
        bark() {
            console.log('Woof!');
        }
    }
    
    • extends 关键字表明 Dog 类继承自 Animal 类。
    • Dog 的构造函数中,super(name) 调用了父类的构造函数,传递了参数 name,这是继承父类属性的关键步骤。
    • 定义了 Dog 特有的属性 species 和方法 bark
  • 创建 Dog 实例并测试:

    let myDog = new Dog('Rex');
    myDog.speak(); // 输出 "I am an animal"
    myDog.bark(); // 输出 "Woof!"
    console.log(myDog.species); // 输出 "Canine"
    

    通过 new Dog('Rex') 创建了一个 Dog 的实例,它继承了 Animal 类的所有属性和方法,同时拥有自己的特有属性 species 和方法 bark

使用 class 语法实现继承简化了传统构造函数和原型链的复杂性,提供了更接近于其他面向对象语言的继承模型,使得代码更加清晰和易于理解。

  • 特点:引入了面向对象编程中的class语法,使得继承的语义更加清晰,更接近其他面向对象语言。
  • 优点:语法简洁,易于理解,支持静态方法和类属性,提高了代码的可读性和可维护性。
  • 缺点:本质上仍然是基于原型,只是语法糖,新手可能会误解为传统的类继承模型。

🚀 实战示例:创建可扩展的动物王国

假设我们要构建一个简单的动物王国模拟器,其中包含各种动物,它们能发出不同的叫声。我们想要设计一个灵活的架构,使得新增动物种类时,无需修改现有代码,同时让每种动物都能继承通用行为(如发出叫声)和拥有特有行为。

1. 基础动物类 (Animal)

// 定义基础动物构造函数,接收一个name参数初始化动物名字
function Animal(name) {
    this.name = name; // 使用this关键字将传入的name赋值给新创建对象的name属性
}

// 在Animal的原型对象上定义一个speak方法,模拟动物发出声音
Animal.prototype.speak = function() {
    console.log('Some generic sound'); // 打印通用的声音文本
};

2. 具体动物类 (Dog & Cat)

// Dog构造函数,继承Animal
function Dog(name) {
    Animal.call(this, name); // 使用Animal.call调用超类构造函数,确保name属性被正确初始化
}

// 设置Dog的原型为Animal的原型的一个新对象实例,实现继承
Dog.prototype = Object.create(Animal.prototype);
// 修复构造函数指针,确保构造函数引用正确
Dog.prototype.constructor = Dog;

// 覆盖speak方法,使Dog有特定的叫声
Dog.prototype.speak = function() {
    console.log('Woof!'); // 打印Dog的叫声
};

// 类似的操作创建Cat类
function Cat(name) {
    Animal.call(this, name);
}

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

Cat.prototype.speak = function() {
    console.log('Meow!'); // 打印Cat的叫声
};

3. 实战应用

// 创建Dog和Cat的实例
const myDog = new Dog('Rex');
const myCat = new Cat('Whiskers');

// 调用各自的speak方法
myDog.speak(); // 输出: Woof!
myCat.speak(); // 输出: Meow!

// 动态添加行为到Animal原型,所有子类实例都能访问
Animal.prototype.sleep = function() {
    console.log(`${this.name} is sleeping.`); // 打印睡觉信息,使用模板字符串插入实例的名字
};

// 调用新添加的sleep方法
myDog.sleep(); // 输出: Rex is sleeping.
myCat.sleep(); // 输出: Whiskers is sleeping.

这个示例演示了如何利用原型链实现继承,保持代码的灵活性和扩展性。通过Object.create方法建立原型链关系,确保了子类能够访问父类的属性和方法,同时也能够覆盖或添加新方法以实现特有行为。动物王国的模拟展示了多态性,即不同对象对同一消息(如speak)做出不同响应的能力,以及代码的可维护性和扩展性。


📚 总结

本文全面解析了JavaScript中的继承机制,核心围绕原型链这一核心概念展开,阐述了其在对象继承中的作用与重要性,并介绍了几种主要的继承实现方式。以下是文章内容的概括:

📌 原型基础

  • 每个JavaScript对象都隐含一个[[Prototype]]属性,通常通过__proto__访问,指向创建它的构造函数的prototype对象。
  • 构造函数的prototype本身是个对象,包含可被实例共享的方法和属性。
  • 示例展示了如何通过原型链,实例能访问到构造函数原型上的方法。

📌 原型链的形成与查找规则

  • 当访问对象的属性或方法时,若对象自身未定义,则会沿其原型链向上查找,直至Object.prototype,最后到null终止。
  • 解释了所有对象共享Object.prototype上的基本方法,如toString()等。

📌 修改原型的影响

  • 修改原型对象会影响所有通过该构造函数创建的实例,因它们共享同一原型。
  • 强调需谨慎修改原型以防“原型污染”,建议采用不可变模式或针对实例单独添加方法。

📌 原型链的尽头

  • 深入探讨了Object.prototype[[Prototype]]null的意义,作为原型链的终点,保证了查找过程的终止。

📌 继承的实现方式

  1. 原型链继承:直接设置子类型的原型为父类型的实例,简单直接,共享方法,但需注意构造函数的修正。
  2. 构造函数继承:子类构造函数内部调用父类构造函数,实现属性继承,但不继承方法且方法不共享。
  3. 组合继承:结合上述两者,最常用,既继承属性也继承方法,但构造函数被调用两次。
  4. ES6 Class继承:引入class语法,简化继承表达,提供更清晰的面向对象编程风格,本质仍是基于原型。

每种继承方式都有其适用场景与优缺点,理解这些机制有助于开发者根据具体需求选择合适的继承策略,提升代码的效率与可维护性。文章强调了深入理解原型链与构造函数原理对于掌握JavaScript面向对象编程的重要性。


🔗 相关链接


深入浅出JavaScript继承机制:解密原型、原型链与面向对象实战攻略-LMLPHP

05-11 02:40