从零开始理解 JavaScript 的 prototype:构建更强大的面向对象应用

206 阅读5分钟

引言

JavaScript 是一种基于原型(prototype)的编程语言,这使得它在对象创建和继承方面具有独特的特性。理解 prototype 机制对于深入掌握 JavaScript 的面向对象编程至关重要。

28c45712c4916006cdf2b2994bfe6db1.gif

一、什么是 prototype

在 JavaScript 中,几乎所有的函数都有一个名为 prototype 的属性(除了箭头函数)。这个 prototype 属性是一个对象,所有由该函数作为构造函数创建的实例都会共享这个 prototype 对象上的属性和方法。

image.png

function Person(name,age){
    console.log(this);
    this.name = name;
    this.age = age;
}

//每个函数都有一个原型对象
Person.prototype={
    eat:function(){
        console.log(`${this.name}爱吃饭`)
    }
}

const xck = new Person('肖1',18);
console.log(xck);
xck.eat();
const xql = new Person('肖2',18);
console.log(xql);
xql.eat();

image.png

在这个例子中,Person 函数有一个 prototype 属性,它是一个对象,包含一个默认的 constructor 属性,指向 Person 构造函数本身。

二、prototype 的作用

1. 实现继承

prototype 是 JavaScript 中实现继承的核心机制。通过将方法和属性定义在 prototype 上,你可以让所有从特定构造函数创建的实例都能访问这些属性和方法。这是一种轻量级的继承方式,允许代码复用,同时也减少了内存占用,因为每个实例不需要单独保存这些方法和属性的副本。

Person.prototype.eat = function() {
    console.log(`${this.name} 爱吃饭`);
};

// 创建实例
let person1 = new Person('张三', 20);
person1.eat(); 

在这个例子中,eat 方法被添加到了 Person.prototype 上,因此 person1 实例可以直接调用 eat 方法。

2. 节省内存

由于 prototype 上的方法和属性是所有实例共享的,因此相比于在每个实例上都定义相同的方法和属性,这种方式可以显著节省内存。这对于需要创建大量对象的应用程序尤为重要。

3. 动态扩展

prototype 允许你在运行时动态地向构造函数添加新的方法和属性,这会影响到所有已经存在的实例。这种灵活性使得你可以在不修改原有代码的情况下,轻松地为现有对象添加新功能。

Person.prototype.sleep = function() {
    console.log(`${this.name} 正在睡觉`);
};

person1.sleep(); 

三、__proto__prototype 的区别

  • prototype:这是构造函数上的一个属性,指向了所有该构造函数创建的实例所共享的对象。你可以在这个对象上添加方法和属性,它们会被所有实例共享。

  • __proto__:这是每个实例对象上的一个内部属性,指向了创建该实例的构造函数的 prototype__proto__ 是非标准但广泛支持的属性,它反映了对象的原型链。ES6 引入了 Object.getPrototypeOf()Object.setPrototypeOf() 来标准化对原型的操作。

console.log(person1.__proto__ === Person.prototype); // true
console.log(Object.getPrototypeOf(person1) === Person.prototype); // true

四、为什么使用prototype而不在类中直接添加方法

image.png

class Person {
    constructor(name,age) {
      this.name = name;
      this.age = age;
    }
    eat() {
      console.log('爱吃饭')
    }   
 }
function Person(name,age){
    console.log(this);
    this.name = name;
    this.age = age;
}

//每个函数都有一个原型对象
Person.prototype={
    eat:function(){
        console.log(`${this.name}爱吃饭`)
    }
}

虽然这两段代码输出的结果相同,但是,第二段代码相较于第一段代码有以下几个优势:

保留了原型链

  • 在第一段代码中,直接给 Person.prototype 赋值了一个新的对象,这会覆盖掉默认的原型对象。这样做会导致所有继承自 Function.prototype 的方法(如 call, apply, bind)丢失,并且新创建的对象将不再拥有 constructor 属性指向原始的构造函数 Person
  • 第二段代码通过 Object.create(Object.prototype) 保留了原有的原型链,确保了实例对象可以访问到所有的内置方法。

正确的 constructor 属性

  • 在第一段代码中,由于直接替换了 Person.prototype,所以新创建的对象的 constructor 属性会指向 Object,而不是 Person。这对于维护和调试来说是一个潜在的问题,因为它改变了对象的预期行为。
  • 第二段代码显式地设置了 Person.prototype.constructor = Person;,这样创建的实例对象的 constructor 属性正确地指向了 Person 构造函数,保持了对象的身份标识。

更好的可扩展性

  • 第二段代码展示了如何在不破坏原有结构的情况下向原型添加多个方法(如 eatgreet)。这种方式使得代码更加模块化,易于维护和扩展。
  • 这也意味着如果将来需要添加更多方法或属性到 Person.prototype,你可以轻松做到这一点,而不会影响已经存在的实例。

更清晰的意图

  • 使用 Object.create 和显式设置 constructor 明确表达了你希望保留原型链并确保 constructor 指向正确的构造函数。这种做法对于其他开发者阅读你的代码时提供了更清晰的理解路径。

综上所述,第二段代码不仅修复了第一段代码中存在的问题,而且增强了代码的健壮性和可维护性,同时更好地遵守了JavaScript的编程规范。

五、实例对象与原型

 function Person(){
    
 }
 Person.prototype.name= '孔子'
 Person.prototype.age= 18
 Person.prototype.hometown= '山东'
 let person1= new Person();
 let person2= new Person();
 console.log(person1===person2);//名字相同 但是不是同一个对象
 console.log(person1.name,person2.name);
 console.log(person1.age,person2.age);
 console.log(person1.hometown,person2.hometown);

image.png

Person.prototype 上定义了三个属性:nameage, 和 hometown。这些属性是所有 Person 实例共享的,因为它们位于原型链上。当创建 person1 和 person2 时,这两个实例对象本身没有自己的 nameage, 或 hometown 属性。因此,当访问这些属性时,JavaScript 会沿着原型链查找,最终找到 Person.prototype 上的相应属性。person1 === person2 输出 false,因为即使两个对象具有相同的属性值,它们也是不同的对象实例,内存地址不同。

 function Person(name,age){
      this.name= name;
      this.age= age;
 }
 Person.prototype.name= '孔子'
 Person.prototype.age= 18
 Person.prototype.hometown= '山东'
 let person1= new Person('张三',20);
 let person2= new Person('李四',21);
 console.log(person1===person2);//名字相同 但是不是同一个对象
 console.log(person1.name,person2.name);
 console.log(person1.age,person2.age);
 console.log(person1.hometown,person2.hometown);

image.png

Person 构造函数接收两个参数 nameage,并在实例上设置了 this.namethis.age。这意味着每个实例都有自己独立的nameage 属性。由于构造函数内部已经为 nameage 设置了实例属性,这些实例属性会优先于原型上的同名属性。也就是说,当访问 person1.nameperson2.name 时,JavaScript 会首先查找实例上的属性,而不是原型上的属性。person1.name person2.name 分别输出 张三李四,因为它们是从构造函数中初始化的实例属性,覆盖了原型上的name属性。honetown

六、总结

prototype 是 JavaScript 实现继承的核心机制之一,它不仅提供了轻量级的代码复用方式,还允许动态扩展对象的功能。通过合理利用 prototype,你可以编写出更加高效、灵活和易于维护的代码。同时,理解 prototype 的工作原理有助于更好地调试和优化应用程序。无论是传统的构造函数模式还是现代的 ES6 类语法,prototype 都是不可或缺的一部分。