es5继承与es6通过class实现继承

72 阅读18分钟

Snipaste_2023-03-17_10-26-04.png

1. 原型链继承

原型链继承的基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。

function SuperType() {
    this.name = 'Yvette';
    this.colors = ['pink', 'blue', 'green'];
}
SuperType.prototype.getName = function () {
    return this.name;
}
function SubType() {
    this.age = 22;
}
SubType.prototype = new SuperType();
SubType.prototype.getAge = function() {
    return this.age;
}
SubType.prototype.constructor = SubType;
let instance1 = new SubType();
instance1.colors.push('yellow');
console.log(instance1.getName()); //'Yvette'
console.log(instance1.colors);//[ 'pink', 'blue', 'green', 'yellow' ]
 
let instance2 = new SubType();
console.log(instance2.colors);//[ 'pink', 'blue', 'green', 'yellow' ]

缺点:

  1. 通过原型来实现继承时,原型会变成另一个类型的实例,原先的实例属性变成了现在的原型属性,该原型的引用类型属性会被所有的实例共享。
  2. 在创建子类型的实例时,没有办法在不影响所有对象实例的情况下给超类型的构造函数中传递参数。

2. 借用构造函数

借用构造函数的技术,其基本思想为:

在子类型的构造函数中调用超类型构造函数。

function SuperType(name) {
    this.name = name;
    this.colors = ['pink', 'blue', 'green'];
}
function SubType(name) {
    SuperType.call(this, name);
}
let instance1 = new SubType('Yvette');
instance1.colors.push('yellow');
console.log(instance1.colors);//['pink', 'blue', 'green', yellow]
 
let instance2 = new SubType('Jack');
console.log(instance2.colors); //['pink', 'blue', 'green']

优点:

  1. 可以向超类传递参数
  2. 解决了原型中包含引用类型值被所有实例共享的问题

缺点:

  1. 方法都在构造函数中定义,函数复用无从谈起,另外超类型原型中定义的方法对于子类型而言都是不可见的。

3. 组合继承(原型链 + 借用构造函数)

组合继承指的是将原型链和借用构造函数技术组合到一块,从而发挥二者之长的一种继承模式。基本思路:

使用原型链实现对原型属性和方法的继承,通过借用构造函数来实现对实例属性的继承,既通过在原型上定义方法来实现了函数复用,又保证了每个实例都有自己的属性。

function SuperType(name) {
    this.name = name;
    this.colors = ['pink', 'blue', 'green'];
}
SuperType.prototype.sayName = function () {
    console.log(this.name);
}
function SuberType(name, age) {
    SuperType.call(this, name);
    this.age = age;
}
SuberType.prototype = new SuperType();
SuberType.prototype.constructor = SuberType;
SuberType.prototype.sayAge = function () {
    console.log(this.age);
}
let instance1 = new SuberType('Yvette', 20);
instance1.colors.push('yellow');
console.log(instance1.colors); //[ 'pink', 'blue', 'green', 'yellow' ]
instance1.sayName(); //Yvette
 
let instance2 = new SuberType('Jack', 22);
console.log(instance2.colors); //[ 'pink', 'blue', 'green' ]
instance2.sayName();//Jack

缺点:

  1. 无论什么情况下,都会调用两次超类型构造函数:一次是在创建子类型原型的时候,另一次是在子类型构造函数内部。

优点:

  1. 可以向超类传递参数
  2. 每个实例都有自己的属性
  3. 实现了函数复用

4. 原型式继承

原型继承的基本思想:

借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。

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

在 object() 函数内部,先创建一个临时性的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回了这个临时类型的一个新实例,从本质上讲,object() 对传入的对象执行了一次浅拷贝。

ECMAScript5通过新增 Object.create()方法规范了原型式继承。这个方法接收两个参数:一个用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象(可以覆盖原型对象上的同名属性),在传入一个参数的情况下,Object.create() 和 object() 方法的行为相同。

var person = {
    name: 'Yvette',
    hobbies: ['reading', 'photography']
}
var person1 = Object.create(person);
person1.name = 'Jack';
person1.hobbies.push('coding');
var person2 = Object.create(person);
person2.name = 'Echo';
person2.hobbies.push('running');
console.log(person.hobbies);//[ 'reading', 'photography', 'coding', 'running' ]
console.log(person1.hobbies);//[ 'reading', 'photography', 'coding', 'running' ]

在没有必要创建构造函数,仅让一个对象与另一个对象保持相似的情况下,原型式继承是可以胜任的。

缺点:

  1. 同原型链实现继承一样,包含引用类型值的属性会被所有实例共享。

5. 寄生式继承

寄生式继承是与原型式继承紧密相关的一种思路。寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数在内部已某种方式来增强对象,最后再像真地是它做了所有工作一样返回对象。

function createAnother(original) {
    var clone = object(original);//通过调用函数创建一个新对象
    clone.sayHi = function () {//以某种方式增强这个对象
        console.log('hi');
    };
    return clone;//返回这个对象
}
var person = {
    name: 'Yvette',
    hobbies: ['reading', 'photography']
};
 
var person2 = createAnother(person);
person2.sayHi(); //hi

基于 person 返回了一个新对象 -—— person2,新对象不仅具有 person 的所有属性和方法,而且还有自己的 sayHi() 方法。在考虑对象而不是自定义类型和构造函数的情况下,寄生式继承也是一种有用的模式。

缺点:

  1. 使用寄生式继承来为对象添加函数,会由于不能做到函数复用而效率低下。
  2. 同原型链实现继承一样,包含引用类型值的属性会被所有实例共享。

6. 寄生组合式继承

所谓寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法,基本思路:

不必为了指定子类型的原型而调用超类型的构造函数,我们需要的仅是超类型原型的一个副本,本质上就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。寄生组合式继承的基本模式如下所示:

function inheritPrototype(subType, superType) {
    var prototype = object(superType.prototype); //创建对象
    prototype.constructor = subType;//增强对象
    subType.prototype = prototype;//指定对象
}
  • 第一步:创建超类型原型的一个副本
  • 第二步:为创建的副本添加 constructor 属性
  • 第三步:将新创建的对象赋值给子类型的原型

至此,我们就可以通过调用 inheritPrototype 来替换为子类型原型赋值的语句:

function SuperType(name) {
    this.name = name;
    this.colors = ['pink', 'blue', 'green'];
}
//...code
function SuberType(name, age) {
    SuperType.call(this, name);
    this.age = age;
}
SuberType.prototype = new SuperType();
inheritPrototype(SuberType, SuperType);
//...code

优点:

只调用了一次超类构造函数,效率更高。避免在SuberType.prototype上面创建不必要的、多余的属性,与其同时,原型链还能保持不变。

因此寄生组合继承是引用类型最理性的继承范式。

类的声明与实例化

类的声明一般有两种方式

//类的声明
var Animal = function (name) {
    this.name = name || 'Animal';
};
Animal.prototype.eat = function(food="bone"){
    console.log(`${this.name} is eating ${food}`); 
}

//ES6中类的声明
class Animal1 {
    constructor (name) {
        this.name = name || 'es6_dog';
    }
    eat(food="bone"){ 
        console.log(`${this.name} is eating ${food}`); 
    }
}   

let dog = new Animal("dog");
let dog1 = new Animal1();

dog.eat('fish');
dog1.eat();
输出:
dog is eating fish
es6_dog is eating bone
复制代码

如何实现继承

实现继承的方式主要有两种:

借助构造函数实现继承

先看个例子

function Parent1 () {
    this.name = 'parent1';
}
function Child1 () {
    Parent1.call(this); //这里的call用apply也可以
    this.type = 'child1';
}
console.log(new Child1());
复制代码

 输出结果

可以看到,生成Child1里面有了父级的属性name,实现了继承。为什么就实现继承了呢?

因为在Child1里执行了这句   Parent1.call(this);   在子类的函数体里执行父级的构造函数,同时改变函数运行的上下文环境(也就是this的指向),使this指向Child1这个类,从而导致了父类的属性都会挂载到子类这个类上去,如此便实现了继承。

但这种继承的方法有一个缺点,它只是把父类中的属性继承了,但父类的原型中的属性继承不了。继续上面的代码

Parent1.prototype.say = function () {
    console.log("Parent1 prototype")
};

new Child1().say()
复制代码

从结果中可以看出 Child1中是没有say方法的,因为say是加在父类的原型上的,这种继承方式只改变父类构造函数在子类函数体中的指向,继承不了原型的属性。

借助原型链实现继承

原型链这里直接用了,不再详细介绍了,如果对原型链还不是很了解的话,建议先看看这个,详谈Javascript原型链

function Parent2 () {
    this.name = 'parent2';
    this.play = [1, 2, 3];
}

function Child2 () {
    this.type = 'child2';
}

Child2.prototype = new Parent2(); //通过把Child2的原型指向Parent2来实现继承
复制代码

在浏览器中检验一下

可以看到在Child2的实例的__proto__的属性中有Parent2的属性,由此实现了Child2从Parent2的继承。

但这种继承方式也有不足。接着看代码

var s1 = new Child2();
var s2 = new Child2();
s1.play.push(4);
复制代码

console.log('s1.play:'+s1.play); console.log('s2.play:'+s2.play);

 打印结果

我们只改了s1这个实例的属性,却发现Child2的其他实例的属性都一起改变了,因为s1修改的是它原型的属性,原型的属性修改,所有继承自该原型的类的属性都会一起改变,因此Child2的实例之间并没有隔离开来,这显然不是我们想要的。

优化1 组合方式

组合方式就是前两种方法组合而成的,上面两种方式都有不足,这种方式就解决了上面两种方式的不足。

看代码

function Parent3 () {
    this.name = 'parent3';
    this.play = [1, 2, 3];
}

function Child3 () {
    Parent3.call(this);  //子类里执行父类构造函数
    this.type = 'child3';
}

Child3.prototype = new Parent3(); //子类的原型指向父类

//以下是测试代码
var s3 = new Child3();
var s4 = new Child3();

s3.play.push(4);

console.log(s3.play, s4.play);
复制代码

打印结果 

可以看出,修改某个实例的属性,并不会引起父类的属性的变化。

这种方式的继承把构造函数和原型链的继承的方式的优点结合起来,并弥补了二者的不足,功能上已经没有缺点了。

但这种方法仍不完美,因为创建一个子类的实例的时候,父类的构造函数执行了两次。

每一次创建实例,都会执行两次构造函数这是没有必要的,因为在继承构造函数的时侯,也就是Parent3.call(this)的时候,parnet的属性已经在child里运行了,外面原型链继承的时候就没有必要再执行一次了。所以,接下来我们对这一方法再做一个优化。

优化2 组合方式的优化

 上面一种继承方式问题出在继承原型的时候又一次执行了父类的构造函数,所以优化就从这一点出发。

组合方式中为了解决借助构造函数继承(也就是本文中第一种)的缺点,父类的原型中的属性继承不了,所以才把子类的原型指向了父类。

但是父类的属性,在子类已经中已经存在了,子类只是缺少父类的原型中的属性,所以,根据这一点,我们做出优化。

function Parent4 () {
    this.name = 'parent4';
    this.play = [1, 2, 3];
}

function Child4 () {
    Parent4.call(this);
    this.type = 'child4';
}

Child4.prototype = Parent4.prototype;  //优化的点在这里

//以下为测试代码
var s5 = new Child4();
var s6 = new Child4();
console.log(s5, s6);

console.log(s5 instanceof Child4, s5 instanceof Parent4);
console.log(s5.constructor);
复制代码

在这种继承方式中,并没有把直接把子类的原型指向父类,而是指向了父类的原型。这样就避免了父类构造函数的二次执行,从而完成了针对组合方式的优化。但还是有一点小问题,先看输出结果

可以看到s5是new Child4()出来的,但是他的constructor却是Parent4.

这是因为Child4这个类中并没有构造函数,它的构造函数是从原型链中的上一级拿过来的,也就是Parent4。所以说到这里,终于能把最完美的继承方式接受给大家啦。

接下来。。。

优化3 组合的完美优化

先看代码吧

function Parent5 () {
    this.name = 'parent5';
    this.play = [1, 2, 3];
}

function Child5 () {
    Parent5.call(this);
    this.type = 'child5';
}

//把子类的原型指向通过Object.create创建的中间对象
Child5.prototype = Object.create(Parent5.prototype);

//把Child5的原型的构造函数指向自己
Child5.prototype.constructor = Child5;

//测试
var s7= new Child5();
console.log(s7 instanceof Child5, s7 instanceof Parent5)
console.log(s7.constructor);
复制代码

 本例中通过把子类的原型指向Object.create(Parent5.prototype),实现了子类和父类构造函数的分离,但是这时子类中还是没有自己的构造函数,所以紧接着又设置了子类的构造函数,由此实现了完美的组合继承。

测试结果

 

优化4 ES6的继承
//class 相当于es5中构造函数
//class中定义方法时,前后不能加function,全部定义在class的protopyte属性中
//class中定义的所有方法是不可枚举的
//class中只能定义方法,不能定义对象,变量等
//class和方法内默认都是严格模式
//es5中constructor为隐式属性
class People {
    constructor(name = 'god', age = 100) {
        this.name = name;
        this.age = age;
    }
    eat() {
        console.log(`${this.name} ${this.age} eat food`)
    }
}
//继承父类
class Women extends People {
    constructor(name = 'people', age = 27) {
        //继承父类属性
        super(name, age);
    }
    eat() {
        //继承父类方法
        super.eat()
    }
}
let womenObj = new Women('xiaoxiami');
womenObj.eat();

//es5继承先创建子类的实例对象,然后再将父类的方法添加到this上(Parent.apply(this))。 
//es6继承是使用关键字super先创建父类的实例对象this,最后在子类class中修改this。
复制代码

ES5继承 VS ES6继承

ES6 类的内部定义的所有方法,都是不可枚举的。
///ES5

function ES5Fun (x, y) {

 this.x = x;

 this.y = y;

}

ES5Fun.prototype.toString = function () {

  return '(' + this.x + ', ' + this.y + ')';

}

var p = new ES5Fun(13);

p.toString();

Object.keys(ES5Fun.prototype); //['toString']


//ES6

class ES6Fun {

 constructor (x, y) {

  this.x = x;

  this.y = y;

 }

 toString () {

  return '(' + this.x + ', ' + this.y + ')';

 }

}

Object.keys(ES6Fun.prototype); //[]
复制代码
ES6的class类必须用new命令操作,而ES5的构造函数不用new也可以执行。
ES5Fun ()

ES6Fun () //error
复制代码
ES6的class类不存在变量提升

必须先定义class后才能实例化,不像ES5中可以将构造函数写在实例化之后。

ES6的继承需要先调用super方法
// ES5的继承
// 原型链方式: 子类的原型指向父类的实例
// 缺点: 1. 因为原型链继承共享实例属性,属于引用类型传值, 修改某个实例的属性会影响其他的实例 
//2. 不能实时向父类的构造函数中传值, 很不方便
function Parent() {
    this.values = [1];
}
function Child(){

}
Child.prototype = new Parent();
const child1 = new Child();
console.log(child1.values); // [ 1 ]
child1.values.push(2);
const child2 = new Child();
console.log(child2.values); // [ 1, 2 ]
复制代码

ES6的继承是先创建父类的实例对象this(必须先调用super方法), 再调用子类的构造函数修改this.

通过关键字class定义类, extends关键字实现继承. 子类必须在constructor方法中调用super方法否则创建实例报错. 因为子类没有this对象, 而是使用父类的this, 然后对其进行加工

super关键字指代父类的this, 在子类的构造函数中, 必须先调用super, 然后才能使用this

// ES6的继承
// 在子类的构造器中先调用super(), 创建出父类实例, 然后再去修改子类中的this去完善子类
class Parent {
    constructor(a, b) {
        this.a = a;
        this.b = b;
    }
}

class Child extends Parent {
    constructor(a, b, c) {
        super(a, b);
        this.c = c;
    }
}

const child = new Child(1, 2, 3);
console.log(child); // { a: 1, b: 2, c: 3 }
复制代码

类的私有变量的实现

闭包定义局部变量(自执行函数)

关于闭包和自执行函数 之前文章有详细介绍 juejin.cn/post/705522…

let fn = (function () {
    let name = "图图";
    let age = 20;
    return {
        getName: function () {
            return name;
        },
        getAge: function () {
            return age;
        }
    }
})();
console.log(fn.getName()); //图图
console.log(fn.getAge()); //20
console.log(fn.name); //undefined
复制代码

使用ES6扩展的类型symbol类型定义

先来解释下symbol类型;Symbol值通过Symbol函数生成。这就是说,对象的属性名现在可以有两种类型,一种是原来就有的字符串,另一种就是新增的Symbol类型。凡是属性名属于Symbol类型,就都是独一无二的,可以保证不会与其他属性名产生冲突;类似与UUID的用法。

Symbol('1') == Symbol('1') //打印  false
let sy = sb = Symbol('a');
sy === sb //打印  true   说明该数据类型以引用的方式传值。
复制代码

解释了Symbol 类型数据,那下面来写一个基于Symbol类型的私有变量,私有属性吧。

let Person = (function(){
    const _name = Symbol('name');
    class  Person {
        constructor(name){
            this[_name] = name;
        }
        getName(){
            return this[_name]
        }
    }
    return Person
}())

let person = new Person('图图');
person.getName(); //图图
person._name;  //undefined
复制代码

使用ES6扩展的类型WeakMap类型定义

先来解释下WeakMap类型, 该类型数据是一个键-值(key-val)对的集合,只不过他的键(key)是一个引用,不同于一般的键-值。WeakMap 的使用如下。

const wm = new WeakMap();
const a = {}, b = {};
wm.set(a, '这是a对象键的值');
wm.set(b, '这是b对象键的值');
console.log(wm.get(a)) //打印 这是a对象键的值
console.log(wm.get(b)) //打印 这是b对象键的值

复制代码

解释了WeakMap类型数据,那下面来写一个基于WeakMap类型的私有变量,私有属性吧。

var Person = (function(){
    const _name = new WeakMap();
    class  Person {
        constructor(name){
            _name.set(this, name)
        }
        getName(){
            return _name.get(this)
        }
    }
    return Person
}())

let person = new Person('图图');
person.getName(); //图图
person._name;  //undefined

复制代码

class提案

当然好消息是ES2019中已经增加了对 class 私有属性的原生支持,只需要在属性/方法名前面加上 '#' 就可以将其定义为私有,并且支持定义私有的 static 属性/方法,同时我们现在也可以通过 Babel 已使用(babel会把#编译成上面weakMap的形式来实现私有属性),并且 Node v12 中也增加了对私有属性的支持。

class Person {
  // 私有属性
  #name; 

  constructor(name) {
    this.#name = name;
  }
  getName(){
    return this.#name;
  }
}

复制代码

为什么用#,不使用 private 关键字来声明?

private 关键字在很多不同的语言中用于声明私有字段。

来看看使用这种语法的语言:

 class EnterpriseFoo {
   public bar;
   private baz;
 
   method() {
     this.bar;
     this.baz;
   }
 }
复制代码

 

在这些语言中,以同样的方式访问私有字段和公共字段。所以它们才会这样定义。

但是在 JavaScript 中,我们不能使用 this.field 来引用私有属性(稍后深入),我们需要一种基于语法的方法来连接它们的关系。这两个地方使用 # 更能清楚的表明引用的是什么。

只读属性的实现

只读属性与上面私有变量有点类似,逻辑上你只要给你的私有属性增加一个getter,而不增加setter那么它就是一个只读属性。与上述代码一样

不过可以通过class的新语法 get来简化这个

class Person {
  // 私有属性
  #name; 

  constructor(name) {
    this.#name = name;
  }
  get name(){
    return this.#name;
  }
}

复制代码

然而对于简单类型这个就是比较完美的只读属性了,但是对于对象,数组等复杂类型,你仍然可以通过外部去增加属性。

class Person {
  // 私有属性
  #name; 

  constructor() {
    this.#name = {};
  }
  get name(){
    return this.#name;
  }
}

let person  = new Person();
person.name.address = '图图';
person.name // {address:'图图'}

复制代码

为了让对象类型的属性不可变,我们可以将这个属性freeze

使用Object.freeze()冻结的对象中的现有属性值是不可变的,不可编辑,不可新增。用Object.seal()密封的对象可以改变其现有属性值,但是不可新增。

class Person {
  // 私有属性
  #name; 

  constructor() {
    this.#name = {address:'图图'};
    Object.freeze(this.#name)
  }
  get name(){
    return this.#name;
  }
}

复制代码

当你freeze这个属性后,会造成一个问题就是,你在class内部也无法修改这个属性了,所以如果你是希望外部只读,但是会有方法可以修改这个值的话,那么就不可以使用freeze了。

Object.defineProperty与proxy

要设置一个对象的值可读,我们可以用更简单的办法,使用defineProperty,将其writable设为false

var obj = {};
Object.defineProperty( obj, "<属性名>", {
  value: "<属性值>",
  writable: false
});
复制代码

当然其限制也很大:

  1. 无法阻止整个对象的替换,也就是obj可以被直接赋值
  2. 需要对对象的每个属性进行设置,同时对于新增属性无法生效(除非你在新增的时候再调用一下这个)
  3. 嵌套对象也无法阻止对内部的编辑修改。

对此我们可以使用es6的proxy来进行优化,proxy能实现defineProperty的大多数功能,又没有以上的问题

var obj = {};
const objProxy = new Proxy(obj, {
    get(target,propKey,receiver) {
      return Reflect.get(target, propKey, receiver);
    },
    set() { // 拦截写入属性操作
      console.error('obj is not writeable');
      return true;
    },
  });
复制代码

对此,我们就不需要关心obj内部属性的新增了(尽管,对于嵌套对象,仍然无法阻止)

基于以上方案,我们可以对一开始的只读属性进行优化

class Person {
  // 私有属性
  #name; 

  constructor() {
    this.#name = {};
  }
  get name(){
    return new Proxy(this.#name, {
        get(target,propKey,receiver) {
          return Reflect.get(target, propKey, receiver);
        },
        set() { // 拦截写入属性操作
          console.error('obj is not writeable');
          return true;
        },
    });
  }

  addName(name){
    this.#name[name] = name;
  }
}

let person  = new Person();
person.name.firstName = 'hu'; // obj is not writeable
person.addName('hu') 
person.name //Proxy {hu: 'hu'}

复制代码

备注(call,apply,bind)实现原理

Function.prototype.myCall = function (context, ...args) {

    let ctx = context || window;
    //将当前被调用的方法定义在cxt.func上.(为了能以对象调用形式绑定this)
    //新建一个唯一的Symbol变量避免重复
    let func = Symbol();

    ctx[func] = this;

    let res = args.length > 0 ? ctx[func](...args) : ctx[func];

    delete ctx[func];

    return res;

}

//类数组(call的妙用)
let arg = { length: 2, 0: 'king', 1: 'brook' };
//能将具有length属性的对象转成数组
let argArr = Array.prototype.slice.call(arg);

console.log(argArr[0]);

// 前部分与call一样
// 第二个参数可以不传,但类型必须为数组或者类数组

Function.prototype.myApply = function (context, args = []) {

    let ctx = context || window;
    //将当前被调用的方法定义在cxt.func上.(为了能以对象调用形式绑定this)
    //新建一个唯一的Symbol变量避免重复
    let func = Symbol();

    ctx[func] = this;

    let res = args.length > 0 ? ctx[func](...args) : ctx[func]();

    delete ctx[func];

    return res;

}

Function.prototype.myBind = function (context, ...args) {
    //新建一个变量赋值为this,表示当前函数
    const fn = this
    //判断有没有传参进来,若为空则赋值[]
    args = args ? args : []
    //返回一个newFn函数,在里面调用fn
    return function newFn(...newFnArgs) {
        if (this instanceof newFn) {
            return new fn(...args, ...newFnArgs)
        }
        return fn.apply(context, [...args, ...newFnArgs])
    }
}

作者:demon陈少
链接:juejin.cn/post/699439…
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。