一篇带你了解ES5原型链以及面向对象原型继承

318 阅读9分钟

这里我们用ES5进行讲解,ES6实现很简单一个extends就完了,但是这样就一点都没有意义了。我们要做个coder,而不是Plagiarists,我们要知其然知其所以然才能走的远。

继承面向对象有三大特性:封装、继承、多态

  • 封装:我们前面将属性和方法封装到一个类中,可以称之为封装的过程;
  • 继承:继承是面向对象中非常重要的,不仅仅可以减少重复代码的数量,也是多态前提(纯面向对象中);
  • 多态:不同的对象在执行时表现出不同的形态;

默认原型链和自定义原型链

那么继承是做什么呢?🤔

  • 继承可以帮助我们将重复的代码和逻辑抽取到父类中,子类只需要直接继承过来使用即可;

  • 在很多编程语言中,继承也是多态的前提;

那么JavaScript当中如何实现继承呢?

var obj = {
      name: "why",
      age: 18
    }
    obj.__proto__ = {
    
    }

    obj.__proto__.__proto__ = {
      message: "Hello bbbb"
    }
    console.log(obj.message)

猜一猜打印结果是什么?🤔 image.png

这里可以看出原型链是有继承性的,obj里面没有message属性,然后就会往他的原型链上找,发现它的原型链上也没有,就会继续往上找,即原型链的原型链,obj.__proto__.__proto__,发现找到message: "Hello bbbb",然后打印出来。

那如果一直都没有值,会一直找吗?🤔 Object是顶级父类,如果Object里面也没有,那么就会打印出来undefined。

ES5继承

我们先看第一种方法实现继承

方式一: 直接赋值

    function Person(name, age, height, address) {
      this.name = name
      this.age = age
      this.height = height
      this.address = address
    }

    Person.prototype.running = function() {
      console.log("running~")
    }
    Person.prototype.eating = function() {
      console.log("eating~")
    }

    // 定义学生类
    function Student(name, age) {
      this.name = name
      this.age = age
    }
    Student.prototype = Person.prototype

这种方式是直接将Person的原型赋值给了Student,也就是说Student和Person的原型是同一个地址,当我们修改Person或者Student任意一个方法里面的属性,二者的原型都会改变。 image.png

Student.prototype.studying = function () {
  console.log("studying~");
};

const person1 = new Person("Alice", 25, 165, "Beijing");
person1.studying();

这里给Student原型添加一个方法,本意只想让Student实例拥有,但是出乎意料的是person1.studying(),也可以使用。 image.png

总结:

  • 原型对象共享问题,构造函数创建的实例对象共享了同一套原型上的属性和方法。修改任意一方,都会造成影响。

  • 破坏原型链的独立性,每个构造函数的原型链应该相对独立且有层次结构,形成 Student 实例对象 > Student.prototype -> Person.prototype -> Object.prototype 这样层次清晰的原型链。但直接赋值使得原型链变得混乱,丢失了原本应有的层级关系。不利于代码的扩展、维护以及对不同类的清晰区分和管理。

  • 无法正确使用 instanceof 操作符进行类型判断,instanceof 操作符用于判断一个对象是否是某个构造函数的实例,其判断依据就是对象的原型链上是否能找到对应的构造函数的原型对象。 但当 Student.prototype 和 Person.prototype 完全等同后,会导致 instanceof 的判断出现不符合预期的情况。这对于代码中基于类型进行逻辑判断、类型检查等操作带来了困扰,容易引发逻辑错误。

方法二:通过原型链实现方法继承

注意,后面Student全部简写成Stu,Person简写成P

如果我们现在需要实现继承,那么就可以利用原型链来实现了

    function Person(name, age, height, address) {
      this.name = name
      this.age = age
      this.height = height
      this.address = address
    }

    Person.prototype.running = function() {
      console.log("running~")
    }
    Person.prototype.eating = function() {
      console.log("eating~")
    }

    // 定义学生类
    function Student(sno, score) {
      this.sno = sno
      this.score = score
    }

    // 方式二: 创建一个父类的实例对象(new Person()), 用这个实例对象来作为子类的原型对象
    var p = new Person("why", 18)
    Student.prototype = p
 
    // 给子类的原型对象添加方法
    Student.prototype.studying = function() {
      console.log("studying~")
    }

    var stu1 = new Student(111, 100)
    var stu2 = new Student(111, 100)

    stu1.running()
    p.studying()

我们对着内存图来理解这段代码

    var p1 = new Person("why", 18)
    Student.prototype = p
    var stu1 = new Student("kobe", 30, 111, 100)

image.png 现在Stu的原型对象不再是它的显示原型对象而是p1对象的隐式原型,同理,我们创建出的stu1的对象的隐式原型指向的是p1对象的隐式原型,然后p的原型指向P的原型,形成一种比较特殊但不太规范的原型链结构。

但是,这种方法并不意味着 Student 的原型对象就是 Person 的原型对象

console.log(Student.prototype === Person.prototype); // false,它们不是同一个对象

结果

子类方法实现了继承,而父类无法访问子类的方法

总结:

目前有一个很大的弊端:某些属性其实是保存在p对象上的;

  • 第一,我们通过直接打印对象是看不到这个属性的; 意思是我们给子类赋值的name是没有用的,显示的是在父类p上的,我们即使打印出stu1,无法查看到name是谁的属性。
  • 第二,原型链上的共享属性问题,尝试执行代码
stu1.name = "kobe" 
stu2.name = "james" 
console.log(stu1.name,stu.name) // why,why

最终发现打印出的都是why,因为Stu里面是没有name这个属性的,他就会沿着原型链去查找,最后在p中找到了name属性,而此时p里面的name是why,所以最后都会打印出why。

  • 第三。构造函数调用导致的副作用
function Person(name) {
 this.name = name;
 console.log("Person constructor is called!");
}

var p = new Person("why"); // 打印 "Person constructor is called!",构造函数被多余调用。

可能你以为没什么事情,不就多打印了一个字符串吗。但是!构造函数中有逻辑副作用(比如网络请求、文件操作等)。这种构造函数调用导致的副作用会产生很大的影响! · 第四,子类的原型对象被污染,子类 Student.prototype 是父类 p,不是Person!, 的一个实例。如果直接对 Student.prototype 修改,会污染父类实例属性,影响继承的行为。

Student.prototype.running = function () {
  console.log("Student is running!");
};

stu1.running(); // "Student is running!"
p.running(); // 修改了父类实例的行为。

·第五,子类的 constructor 属性被破坏 置 Student.prototype = new Person("why", 18) 后,Student.prototype.constructor 会指向 Person,而非 Student

console.log(Student.prototype.constructor); // 输出: [Function: Person]
// 应该是 Student,但被指向了 Person

image.png

总结:如果对象是引用类型的话(包括数组、对象字面量、函数等复杂数据类型)这种方式会造成共享属性相关问题,上述代码看不出来,我们看这个代码。

注意:步骤4和步骤5不可以调整顺序,否则会有问题 因为如果我们先给Stu原型加方法,然后再改变Stu的原型,让它指向p的原型,你之前加的方法就失效了。

方法三:借用构造函数继承

    // 定义Person构造函数(类)
            function Person(name, age, height, address) {
      this.name = name
      this.age = age
      this.height = height
      this.address = address
    }

    Person.prototype.running = function() {
      console.log("running~")
    }
    Person.prototype.eating = function() {
      console.log("eating~")
    }

    // 定义学生类
    function Student(name, age, height, address, sno, score) {
      // 重点: 借用构造函数
      Person.call(this, name, age, height, address)
      this.sno = sno
      this.score = score
    }

    var p = new Person("why", 18)
    Student.prototype = p
    Student.prototype.studying = function() {
      console.log("studying~")
    }
    // 创建学生
    var stu1 = new Student("kobe", 30, 111, 100)
    var stu2 = new Student("james", 25, 111, 100)
    stu1.running()
    stu1.studying()

    console.log(stu1.name) //"kobe"
    console.log(stu2.name) //"james

重点在这段代码👀

      Person.call(this, name, age, height, address)
  • 这段代码调用了 Person 构造函数,并显式将 this 绑定到 Student 实例上。

  • 结果是 Person 构造函数中的属性(nameage 等)被复制到每个 Student 实例中,避免了这些属性的共享问题。

stu1.name = "curry"
console.log(stu1.name) //curry

解决了通过原型链实现方法继承代码共享问题。 总结: 这种方案只是解决属性共享问题,但是下面三个问题仍没有被解决。

  • 构造函数调用导致的副作用
  • 子类的 constructor 属性被破坏
  • 子类的原型对象被污染

注意:方法二 function Student(sno, score) ,我们给他加上name, age, height, address一样可以实现这种效果。

看了方法一,方法二,方法三,感觉这些方案实现继承都不太行,而且看起来都怪怪的,那有没有将这些问题都解决的方案呢?🤔

接下来隆重请出我们的寄生式函数

终极方案,寄生式函数

在介绍寄生式继承时,我们先看下原型式继承

原型式继承

这种模式要从道格拉斯·克罗克福德(Douglas Crockford,著名的前端大师,JSON的创立者)在2006年写的一篇文章说起:Prototypal Inheritance in JavaScript(在JavaScript中使用原型式继承) 它介绍了一种继承方法,而且这种继承方法不是通过构造函数来实现的

这里我们不写代码了,大家看看这几张图,理解下原型式继承的思想 image.png

寄生式继承函数

寄生式(Parasitic)继承是与原型式继承紧密相关的一种思想, 并且同样由道格拉斯·克罗克福德(Douglas Crockford)提出和推广的; 寄生式继承的思路是结合原型类继承和工厂模式的一种方式;

即创建一个封装继承过程的函数, 该函数在内部以某种方式来增强对象,最后再将这个对象返回;

大家在看很多博客中都会写这种方法,这里我们写一个兼容性最强的!👀

function createObject(o){
  function F(){}
  F.prototype = o
  return new F()
}

function inherit(Subtype,Supertype){
  Subtype.prototype = createObject(Supertype.prototype)
  Object.defineProperty(Subtype.prototype,"constructor",{
    enumerable:false,
    configurable:true,
    writable:true,
    value:Subtype
  })
}

这里来分析一下

  • createObject函数,建一个新的对象,并将新对象的原型指向参数对象 o。本质上是对 Object.create() 的手动实现。等效于:const newObj = Object.create(o);
  • inherit函数:
  1. 原型链继承:

    • 使用 createObject(Supertype.prototype) 创建一个新的对象,并将其作为 Subtype.prototype
    • 这样,Subtype 就可以继承 Supertype.prototype 上的方法,而不会直接修改 Supertype 的原型对象,避免污染。
  2. 修复 constructor

    • 默认情况下,修改 Subtype.prototype 会导致 Subtype.prototype.constructor 指向 Supertype
    • 使用 Object.definePropertyconstructor 修正为 Subtype,并确保 constructor 属性不可枚举(保持与默认行为一致)。
function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.sayHello = function () {
  console.log(`Hello, my name is ${this.name}`);
};

function Student(name, age, sno) {
  Person.call(this, name, age); // 借用构造函数,初始化实例属性
  this.sno = sno;
}

// 使用 inherit 实现继承
inherit(Student, Person);

Student.prototype.study = function () {
  console.log(`${this.name} is studying.`);
};

// 测试
const stu1 = new Student("Alice", 20, 101);
const stu2 = new Student("Bob", 22, 102);

stu1.sayHello(); // "Hello, my name is Alice"
stu1.study();    // "Alice is studying"
console.log(stu1.constructor); // [Function: Student]
console.log(stu1 instanceof Student); // true
console.log(stu1 instanceof Person);  // true

总结一下

方案三解决了属性继承问题,方案四完美融合了方案三,既解决了属性继承,又解决了方法继承实现了继承

看了这些是不是已经对ES5实现继承更加了解了?尝试自己去写一遍寄生式函数吧。🚀