吃透JavaScript核心——原型链、继承

130 阅读7分钟

1 构造函数和原型

1.1 构造函数

在典型的OOP语言中,都存在着类的概念,类是对象的模板,对象是类的实例。
ES6之前,JS没有类的概念,对象不是基于类创建的,而是用构造函数来定义对象的属性和方法。 构造函数创建对象的过程: juejin.cn/post/700815…

1.2 构造函数存在的问题:

函数是复杂数据类型,会在内存中再开辟一个额外空间存放函数,存在浪费内存的问题。

549bb7e970546d036645523900c47cc.png 而,构造函数通过原型分配的函数是所有对象所共享的。 不会浪费内存 一般情况下,公共属性定义到构造函数里,公共方法放到原型对象里。

2 原型及原型链

什么是原型?对原型链是如何理解?原型及原型链所有的知识点都来自于以下理论:

原型是什么:一个对象,也称为prototype 或 原型对象。
原型的作用:共享方法。把那些不变的方法直接定义在prototype对象上,这样所有对象的实例就可以共享这些方法。

  • 每个函数都有 prototype 属性。
  • 每个对象都有  proto  属性(这个属性称之为原型)。 执行 new 的时候,对象的  proto  指向这个构造函数的 prototype 原型对象。

2.1 每个函数都有一个 prototype 属性

每个函数都有个 prototype 属性,这个属性指向函数的原型对象,同时 prototype 里面有个 constructor 属性回指到该函数:

function Demo() {}
Demo.prototype.constructor === Demo; // true
  • Demo.prototype 结构如下所示: 图片描述

2.2 原型链

1️⃣✅:当使用 new 操作符后,Demo 就变成了构造函数。🔽

  • //使用 new 操作符来创建一个实例对象:
    function Demo() {}
    const d = new Demo(); // d 就是创建出来的实例对象
    

2️⃣✅:d 既然是对象,自然有 proto(原型)对象原型__proto__原型对象prototype是等价的对象原型__proto__指向构造函数 Demo 的 prototype:🔽

  • function Demo() {}
    const d = new Demo();
    d.__proto__ === Demo.prototype; // true
    

3️⃣✅:__proto__对象原型的意义在于为对象的查找机制提供一个方向,它只是内部指向原型对象prototype,是一个非标准属性,实际开发中不能用。🔽

  • 当访问一个对象的属性时,程序会先去这个对象里面查找,如果没有找到会去这个对象的原型上查找,如下所示:
    function Demo() {
      this.name = "Demo";
    }
    Demo.prototype.say = function () {
      console.log("我是", this.name);
    };
    const d = new Demo();
    // 虽然 Demo 上没有 say 方法,但是因为Demo的prototype上有此方法,所以下面的调用可以正常打印。
    d.say(); // 我是Demo
    
  • 可以用一张图来描述这个查找过程: image.png

4️⃣✅:这里只体现出一层查找,实际上 proto逐层向上查找的,这个查找过程也就是我们所说的原型链。🔼

3 继承

JavaScript 如何实现继承?,在 ES6 之前,都是通过原型的方式来实现,这也是原型的重要使用场景之一,通常有如下几种实现方式:

3.1 原型赋值

//父类
function Parent(name) {
  this.name = name||"Tina";
  this.age = 20;
  this.arr = [1,2,3]
}
Parent.prototype.say = function () {
  console.log(this.name);
};

//子类
function Children() {}
Children.prototype = new Parent(); //直接new 了一个父类的实例,然后赋给子类的原型
Children.prototype.love = function () {
  console.log(this.name);
};
//孩子1
const child1 = new Children("niki");//❌创建子类实例时,无法向父类构造函数传参。
console.log(child1.name); // Tina
child1.love();// Tina
child1.say();// Tina
//孩子2
const child2 = new Children();

console.log(child1.age,child2.age)//20 20
child1.age++
console.log(child1.age,child2.age)//20 21

console.log(child1.arr,child2.arr)//[ 1, 2, 3 ] [ 1, 2, 3 ]
child1.arr.push(4)
console.log(child1.arr,child2.arr)//[ 1, 2, 3, 4 ] [ 1, 2, 3, 4 ]
//❌原型属性若是引用类型,如Array,会被所有实例共享,所以对 child1.arr 的修改,child2.arr 也会被修改;基本类型没有这个问题。
  • 这种方法:直接 new 了一个父类的实例,赋给子类的原型。相当于直接将父类原型中的方法属性以及挂在 this 上的各种方法属性全赋给了子类的原型。
  • 简单粗暴,弊端很多,主要问题是:
    • ❌创建子类实例时,无法向父类构造函数传参。
    • ❌来自原型对象的所有属性和方法被所有实例共享,但无法做到属性、方法独享。
      • 包含引用类型值的原型属性会被所有实例共享,某个子类实例修改父类的引用类型的值的话其他实例也会受到影响。(String、Number这种基本类型不被共享)。
      • 方法也是这样的:Sub1修改了父类的函数,其他所有的子类Sub2、Sub3...想调用旧的函数就无法实现了。 这种方式不合适。

3.2 构造函数

//父
function Parent(name) {
  this.name = name || "Tina";
}
Parent.prototype.say = function () {
  console.log(this.name);
};
//子
function Children(name) {
  Parent.apply(this, arguments); // 通过apply 调用 Parent 并改变 Parent 中的this指向
  //在子的构造函数里面用apply改变this指向实现继承,还可以实现传参
}
const child1 = new Children("niki");//✅创建子类实例时,可以向父类构造函数传参。
console.log(child1.name); // niki
// child1.say(); // ❌error 不能继承父类原型的方法。

const child2 = new Children("Lili");
console.log(child1.arr,child2.arr)//[ 1, 2, 3 ] [ 1, 2, 3 ]
child1.arr.push(4)
console.log(child1.arr,child2.arr)//[ 1, 2, 3, 4 ] [ 1, 2, 3]
//✅某个子类实例修改父类的引用类型的值,其他实例不会受到影响。
  • 这种方法:在子类的 prototype 通过改变 this 指向 来 调用父类,达到继承的目的。
  • 缺点:
    • 虽解决了传参的问题,但不能继承父类原型的方法,只能在函数体内定义。
    • 没有复用可言了,没有继承原型。 这种方式也不合适。
      注:apply argument

3.3 组合继承

原型赋值构造函数各有优缺点。 组合继承:

  • 使用原型赋值来继承方法。
  • 借用构造函数来继承属性。 从而发挥二者之长的一种继承模式。
//父
function Parent(name) {
  this.name = name || "Tina";
  this.arr = [1,2,3]
}
Parent.prototype.say = function () {
  console.log(this.name);
};
//子
function Children(name) {
  Parent.apply(this, arguments); //构造函数
}
Children.prototype = new Parent();//原型赋值
//Children.prototype.constructor = Children;//使得Children.prototype.constructor指向自己

const child1 = new Children("niki");//✅创建子类实例时,可以向父类构造函数传参。
child1.say(); // ✅ 能继承父类原型的方法。

const child2 = new Children("Lili");
child1.arr.push(4)
console.log(child1.arr,child2.arr)//[ 1, 2, 3, 4 ] [ 1, 2, 3]
//✅某个子类实例修改父类的引用类型的值,其他实例不会受到影响。
  • 缺点:看似完美,却把父类的属性继承了两份: 9ba84db6b0c0fee311b8f7d21a31a45.png
  • 如果父类本身比较大,这样实现继承对内存的消耗比较大,也不太合适。

3.4 寄生组合继承——完美的方式实现继承

//父
function Parent(name) {
  this.name = name || "Tina";
  this.arr = [1,2,3]
}
Parent.prototype.say = function () {
  console.log(this.name);
};
//子
function Children(name) {
  Parent.apply(this, arguments); //构造函数
}
Children.prototype = Object.create(Parent.prototype);//寄生继承
Children.prototype.constructor = Children;//使得Children.prototype.constructor指向自己

const child1 = new Children("niki");//✅创建子类实例时,可以向父类构造函数传参。
child1.say(); // ✅ 能继承父类原型的方法。

const child2 = new Children("Lili");
child1.arr.push(4)
console.log(child1.arr,child2.arr)//[ 1, 2, 3, 4 ] [ 1, 2, 3]
//✅某个子类实例修改父类的引用类型的值,其他实例不会受到影
  • Object.create() 方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。
  • 寄生继承
    • 不需要实例化父类,直接实例化一个临时副本实现了相同的原型链继承。(子类的原型指向父类副本的实例从而实现原型共享,修改子类实例的属性不会影响到父类)。寄生组合继承使用寄生继承修复了组合继承的bug。
    • 寄生继承与原型继承原理相同, 都是共用原型链,所以不可避免的会污染整个属性。

3.5 ES6 extends 实现继承

虽然说我们实现了继承,但是在 ES6 之前,实现一个继承还是相对复杂的,在 ES6 之后,我们有了 extends 关键字,很优雅的实现继承,如下所示:

class Parent {
  constructor(name) {
    this.name = name || "Tina";
  }
  say() {
    console.log(this.name);
  }
}

class Children extends Parent {
  constructor(name) {
    super(name); // 可以给父类传递参数
    this.age = 16;
  }
}
const child = new Children("lyh");
console.log(child); //{name: "lyh" , age: 16}

总结:JavaScript 是如何实现继承的?

继承方式描述优点缺点
原型继承创建一个父类直接赋值给子类的 prototype引用值会被所有的实例共享;
不能给父类传参。
构造函数继承在子类中通过改变 this 指向来调用父类解决了引用值共享的问题;
可以通过Child向Parent传参
不能继承父类的 prototype;
多执行了一次apply()
组合继承结合原型继承和构造函数继承的优点解决了引用值共享的问题;
可以通过Child向Parent传参;
Parent上的原型可以被继承。
子类继承了两份父类,重复继承;
多执行了一次apply()
寄生组合继承通过 Object.create 方式来优化组合继承实现起来比较复杂
ES6 extends 实现继承可能在低版本浏览器存在兼容性问题。

参考

继承: www.cnblogs.com/l-y-c/p/134…
✨✨✨图解继承:www.cnblogs.com/libin-1/p/5…