通俗易懂的JS类与继承

435 阅读6分钟

众所周知JS中没有类的概念,要实现面向对象编程需要借助原型链模拟类的概念。这一文就带你搞懂JS中Class语法糖以及Class出现之前类与继承的实现方式

学习JS中的类与继承需要先搞懂原型链相关的知识,还不懂的小伙伴可以参考我的另一篇文章通俗易懂的JS原型链

先补充个知识点

关于修改对象属性,需要考虑下面几点:

  1. 本身和原型链上层都存在,只修改底层属性
  2. 本身不存在,原型链上层存在,且可写,在本身中添加该属性
  3. 本身不存在,原型链上层存在,且只读,无法修改或创建属性,严格模式下报错
  4. 本身不存在,原型链上层存在,且是setter,会调用setter,本身不会新建属性
  5. 3、4种情况,仍可以使用defineProperty向本身添加属性
  6. 如果属性是对象,使用对象方法修改对象本身并不是上述的情况,不会新添加属性

ES6-Class

class是ES6中提出的实现类的语法糖,所以这里我们简单介绍class的使用,再通过研究ES6之前实现类的方式来认识class的实现原理。

class本质是一个特殊的函数,与普通函数一个较大的区别是:普通函数声明会提升,class不会,必须先声明再使用

我们可以和函数一样通过表达式或声明两种方式定义类,表达式声明又可以匿名或具名声明:let c = class 名字 {}。区别在于匿名声明的类访问c.name时返回的是接收的变量名c,而具名声明返回的是明确定义的名字

class还有一个比较重要的特点在于,类声明和类表达式的主体都执行在严格模式下。所以如果把类方法赋值给其他变量并运行,得到的this将是undefined而不是Window

constructor是类专有的方法,用于初始化实例使用,相当于构造函数中的this.xxx = xxx这一部分。类中的原型方法直接使用name() {}的方式定义,在类中方法外直接使用name = value;定义的属性也会成为实例属性。所以:class中使用=赋值定义的属性或方法都会成为实例属性,只有方法或者通过C.x =xC.prototype.x =x才是定义的静态属性或者原型中的属性

在方法名前加上static关键字,便定义了一个静态方法,静态方法是只能通过类本身访问的方法,不能通过实例访问。像我们使用的Object.keys便是定义再Object类上的静态方法

通过class Dog extends Animal的方式能够实现继承,通过子类中定义了constructor必须先通过super(参数)传入父构造函数需要的参数之后才能使用this,子类方法中也可以使用super.x()调用父类中的方法。类不能继承常规对象,如果要继承常规对象,可以改用Object.setPrototypeOf()

ES6之前

一、原型链继承

我们知道使用new能够根据构造函数创建实例对象,实例对象能够访问原型中的方法。所以当子类构造函数的原型对象是父类的实例时,就实现了最简单的继承

Child.prototype = new Parent();

image.png 这时再使用Child创建实例,便能够访问Child和Parent原型中的属性和方法。但这种方式有几个问题:

  1. parent构造函数中的this.xxx = xxx会在图中的Parent实例对象中创建属性,所有的Child实例都能够修改这些属性,如果该属性是个引用类型则一个实例修改会影响到其他实例
  2. 创建Child实例时,不能向parent构造函数传参。也就是parent构造函数中通过this.xxx = xxx初始化的属性,在实例表现中只能是相同的(除非Child构造函数中重写覆盖)

二、原型式继承

原型式继承是一种更为简单的继承方式,通过ES5的Object.create、或模拟Object.create,方便的完成继承

// 封装方式
function createObj(o) {
    function F(){};
    F.prototype = o;
    return new F(); 
}
let Parent = { value: 1 };
let child = createObj(Parent);

// Object.create方式
let Parent = { value: 1 };
let child = Object.create(Parent);

image.png

关于Object.create我们还需要知道:Object.create(null)能够创建一个真正的空对象,没有原型等任何属性。

image.png

观察原型式继承我们可以发现这个缺点:

  1. 属性都在原型上,如果一个实例修改引用属性会影响所有实例

三、寄生式继承

寄生式继承就是在原型式继承基础上增加了子类属性和方法

function createObj (o) {
    var clone = Object.create(o);
    clone.sayName = function () { };
    return clone;
}

虽然扩展了子类,能够拥有自己的属性和方法,但一个比较明显的问题是每创建一个实例都需要创建一遍方法

四、经典继承

前面的原型链继承,实例初始化的部分属性(父类初始化的属性)实际上是在子类原型对象上的。经典继承通过子类借用父类的构造函数实现了所有初始化属性准确挂载在实例自身

function Parent () {
    this.a = 'a'
}
function Child () {
    Parent.call(this);
    this.b = 'b'
}
console.log(new Child());
// {
//    "a": "a",
//    "b": "b"
// }

经典继承解决了部分属性挂载在子类原型上导致引用属性所有实例都能修改的问题,还实现了创建子类时向父类构造函数传参的功能。

但能够看出来一个比较大的问题是,子类与父类没有实际上的关联,实例对象无法共享父类原型中的方法

五、组合继承

组合继承结合了原型链继承和经典继承,是class之前最常用的继承模式

组合继承借用父类构造函数的同时使用父类实例作为子类的原型,便实现了较为完善的继承

function Parent (name) { }
Parent.prototype.getName = function () {}
function Child (name, age) {
    Parent.call(this, name);
    this.age = age;
}
Child.prototype = new Parent();
Child.prototype.constructor = Child;

六、寄生组合式继承

思考组合继承的运行方式,可以发现Child.prototype = new Parent();这一步其实是没有必要的,因为创建每一个Child实例时实际上也执行了Parent初始化方法。所以通过寄生结合组合继承的方式,我们可以省略这一步,更加优化继承的模式

function Parent (name) { }
Parent.prototype.getName = function () { }
function Child (name, age) {
    Parent.call(this, name);
    this.age = age;
}

// 关键的三步
let F = function () {};
F.prototype = Parent.prototype;
Child.prototype = new F();

寄生组合方式被认为是ES6之前最佳的类继承方式