前端分享--ES6之类与继承(详解)

1,441 阅读13分钟

类的声明与实例化

类的声明一般有两种方式

//类的声明
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 = "king";
    let age = 20;
    return {
        getName: function () {
            return name;
        },
        getAge: function () {
            return age;
        }
    }
})();
console.log(fn.getName()); //king
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('king');
person.getName(); //king
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对象键的值

ps:WeakMap 的键必须是对象引用。WeakMap 中的键是弱引用,意味着如果键对象没有其他引用,垃圾回收机制可能会将其回收并释放内存。这使得 WeakMap 更适合于存储键值对的场景,其中键是临时对象或者可能被频繁创建和销毁的对象。

如果键不是对象引用,而是基本类型值(比如字符串、数字等),则会导致 WeakMap 无法正常工作,因为基本类型值不会被垃圾回收机制跟踪,所以 WeakMap 无法正确地进行内存管理和回收。因此,使用 WeakMap 时,键必须是对象引用。

解释了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('king');
person.getName(); //king
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 = 'king';
person.name // {address:'king'}

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

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

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

  constructor() {
    this.#name = {address:'king'};
    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 = 'king'; // obj is not writeable
person.addName('king') 
person.name //Proxy {king: 'king'}

备注(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])
    }
}