Class的基本语法及继承——JS

269 阅读14分钟

image.png

前言

对JavaScript进一步学习后,发现在ECMAScript 5 的特性来实现继承的代码写起来会非常冗长和混乱。光是实现继承就有原型链、盗用构造函数、组合继承、原型式继承、寄生式继承,寄生式组合继承六种方式,而且都有相应的问题。所以在ECMAScript 6 新引入的class关键字来正式的定义类,这是一种基础性语法糖(指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。)结构,但是本质上还是原型链和构造函数的概念。

一、Class基本语法

1.1 类定义

好了,言归正传!首先定义类的方式有两种:类声明和类表达式。

// 类声明
class Animals {}

// 类表达式
const Animals = class {};

与函数表达式类似,但是需要注意的是类不存在变量提升。并且类的内部默认就是严格模式

class Animals {};

console.log(Animals); // class Animals {}
console.log(Animals.prototype); // {constructor: ƒ}
console.log(typeof Animals) // function

其实,类class本质就是一个特殊的函数

const Animals = class Animal {
    getClassName() {
        console.log(Animals.name, Animal.name);
    }    
}
let a = new Animals(); 

a.getClassName(); // Animal Animal
console.log(Animals.name); // Animals
console.log(Animal.name); // ReferenceError: Animal is not defined

如果像这样的形式来定义,需要注意这个类的名字虽然是Animal但是只能在class内部使用,在class外部使用的话只能通过Animals来引用类。

1.2 类构造函数

constructor() 是类内部的默认方法,当通过new操作符来生成实例化对象时自动调用该方法。
使用new调用类的构造函数会执行如下操作。

  1. 在内存中创建一个新对象。
  2. 这个新对象内部的 [[prototype]] 指针指向构造函数的原型对象。
  3. 构造函数内部的 this 指向这个新对象。
  4. 执行构造函数内部的代码。
  5. 如果构造函数返回非空对象,则返回该对象;否则返回刚创建的新对象。
class Person {
    constructor(override) {
      this.name = '老王';
      this.age = 10;
      if (override) {
        return {
            name: '老李',
            age: 18
        }
      }
    }
}
let p = new Person; // Person {name: '老王', age: 10}
let p1 = new Person(); // Person {name: '老王', age: 10}

p === p1; // false
p1.__proto__ === Person.prototype; // true

let p2 = new Person(true); // {name: '老李', age: 18}
console.log(p2 instanceof Person); // false
console.log(p2.__proto__ === Object.prototype); // true
  • 新写法中,实例属性可以定义在类的最顶层,而不用写在constructor()里。
  • 类实例化时传入的参数会作为构造函数的参数,所以如果不需要参数,类名后面的括号也是可不写的。
  • 当不传参数通过new操作符返回this对象,虽然属性相同,但是每个实例对象都是独立的。
  • 构造函数返回this对象是该类的实例,但是如果返回的是其他对象,则不是该类的实例,因为它的[[prototype]]指针未被修改。可以用instanceof操作符来检查一个对象与类构造函数。

注意:__proto__ 并不是语言本身的特性,这是各大厂商具体实现时添加的私有属性,虽然目前很多现代浏览器的 JS 引擎中都提供了这个私有属性。不建议在生产中使用该属性,避免对环境产生依赖。在生产环境中,我们可以用 Object.getPrototypeOf() 方法来获取实例对象的原型,然后为其添加属性/方法。

function Animal() {
    this.age = 11
};
class Person {};

let a = Animal();
window.age; // 11
let p = Person(); // TypeError: Class constructor Person cannot be invoked without 'new'

调用类构造函数必须使用 new 操作符否则会报错。而当普通构造函数忘记使用new操作符调用时,会把this指向window
而类的方法内部如果有this,它默认指向类的实例,但是当单独使用时该方法可能会报错。

class Animal {}

let a = new Animal();
console.log(a.constructor === Animal); // true
console.log(a instanceof Animal); // true
console.log(a instanceof Animal.constructor); // false

let b = new Animal.constructor();
console.log(b.constructor === Animal); // false
console.log(b instanceof Animal); // false
console.log(b instanceof Animal.constructor); // true

constructor()方法的定义不是必须的,不定义构造函数相当于构造函数为空函数。
类本身在使用new调用时就会被当成构造函数,但是类中定义的constructor()方法不会被当成构造函数,所以用instanceof操作符来判断会返回false。如果在创建实例时直接将类中的constructor()单独作为普通构造函数使用,则不同。

类内部也可以使用getset关键字,而且其内部的方法书写时不需要用逗号,来隔开,否则会报错。

class Person {
    iname = '';
    set name(newName) {
        this.iname = newName;
        // 不要写this.name = newName 否则会爆栈
    }
    
    get name() {
        return this.iname;
    }
}

let p = new Person();
p.name = '老王';
console.log(p.name); // 老王

注意:set不要修改自身否则会递归爆栈,并且也不要使用与getset同名的属性不然会导致getset不被触发。

1.3 静态类方法

可以用static在类上定义静态方法。这些方法不会被类的实例继承,也不属于类的原型对象

class Person {
    constructor() {
        this.locate = () => console.log('我在实例对象上', this);
    }

    locate() {
        console.log('我在原型对象上', this);
    }

    static locate() {
        console.log('我在类本身', this);
    }
}

let p = new Person();

p.locate(); // 我在实例对象上 Person {locate: ƒ}
Person.prototype.locate(); // 我在原型对象上 {constructor: ƒ, locate: ƒ}
Person.locate(); // 我在类本身 class Person {...}

注意: 如果静态方法包含this关键字,这个this指的是类,而不是实例。

1.4 迭代器和生成器方法

类定义语法支持在原型和类本身上定义生成器方法。

class Person {
    *createNumberIterator() {
        yield* [4, 5, 6];
    }

    static *createNumberIterator() {
        yield* [1, 2, 3];
    }
}

let a = Person.createNumberIterator();
console.log(a.next().value); // 1
console.log(a.next().value); // 2
console.log(a.next().value); // 3

let p = new Person();
let b = p.createNumberIterator();
console.log(b.next().value); // 4
console.log(b.next().value); // 5
console.log(b.next().value); // 6

因为支持生成器方法,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象。

class Person {
    constructor() {
        this.values = [1, 2, 3];
    }

    *[Symbol.iterator]() {
        yield* this.values.entries();
    }
}

let p = new Person();
for (let [idx, values] of p) {
    console.log(values);
}
// 1
// 2
// 3

二、继承

2.1 ES6 class继承

class可以通过使用extends关键字来继承任何拥有[[Construct]]和原型的对象。也意味着这不仅可以继承一个类,也可以继承普通的构造函数。

class Animal {}
// 继承父类
class Mammal extends Animal {}
// 表达式的形式也是有效的
// let Mammal = class extends Animal {}

let bd = new Mammal();
console.log(bd instanceof Mammal)
console.log(bd instanceof Animal)

子类的方法可以通过super关键字引用它们的原型。这个关键字只能在子类中使用,并且仅限于类构造函数、实例方法和静态方法内部。

class Animal {
    constructor() {
        this.mouth = 1;
    }
}

class Mammal extends Animal {
    constructor() {
        super(); // 在构造函数中调用
        console.log(this instanceof Animal); // true
        console.log(this); // Mammal {mouth: 1}
    }
}
new Mammal();
class Animal {
    static shout() {
        console.log('叫');
    }
}

class Mammal extends Animal {
    static shout() {
        super.shout() // 在静态方法中调用
    }
}
Mammal.shout(); // 叫
  • 使用super()关键字调用父类构造函数,会将返回的实例赋值给this,所以不要在其调用之前引用this,否则会报错。
  • super关键字只能在子类构造函数和静态方法中使用。
  • 不能单独引用super,要么用它调用构造函数,要么用它引用静态方法否则会报错。
  • 如果需要给父类构造函数传参,则要手动传入。
  • 如果子类没有定义构造函数,则会默认添加并在实例化子类时调用super
  • 如果子类显式定义了构造函数,则要么必须在其中调用super(),要么必须在其中返回一个对象。

当需要定义一个可供其他类继承,但是本身不会被实例化的抽象基类时。可以用new.target属性来检测通过new调用的是哪个类或函数。

class Animal {
    constructor() {
        if(new.target === Animal) {
            throw new Error('本类不能被实例化')
        } 
    }
}

class Mammal extends Animal {
    constructor() {
        super(); 
    }
}
new Mammal(); // Mammal {}
new Animal(); // Error: 本类不能被实例化

当通过在抽象基类构造函数中进行检查,还可以要求子类必须在其自身定义某个方法,否则子类实例化时就会报错。

class Vehicle {
    constructor() {
        if (new.target === Vehicle) {
            throw new Error('不能被实例化');
        }
        if (!this.foo) {
            console.log(this);
            throw new Error('子类需要自己定义foo方法');
        }
        console.log('success', this);
    }
}

class Bus extends Vehicle {
    foo () {
        console.log('我是自身的foo');
    }
}

class Bicycle extends Vehicle {}

let a = new Bus(); // 
console.log(a instanceof Vehicle);
console.log(a instanceof Bus);
new Bicycle();

注意:new.target不能在函数外部使用,否则报错。

在介绍ES5的六种继承方式之前,介绍一个class继承链与之不同的地方。作为语法糖的class同时存在有两条继承链:一条是子类的[[prototype]]指针总是指向父类,表示构造函数的继承;另一条是子类原型的[[prototype]]指针总是指向父类原型,表示方法的继承。

2.2 ES5 继承方式

如果你看到这里的话,告诉你一个好消息,正片开始!!! 👽👽👽

2.2.1 原型链继承

如果清楚原型链的小伙伴就会知道,当一个构造函数实例化一个对象时,那么这个实例对象会通过内部指针[[prototype]]指向它构造函数的原型对象,即实例对象的原型。知道这个的话,那么是不是可以构想一下,如果一个实例对象是另一个实例对象的原型?这不就成功启动套娃了,ok有了猜想就赶紧实现!

        // 父类构造函数
        function Dad() {
            this.name = '我是爹地';
            this.obj = {
                sex: '男'
            }
        }
        // 父类原型上的方法
        Dad.prototype.saying = function () {
            console.log('开门英子,' + this.name);
        }

        // 子类构造函数
        function Son() {
            this.age = '八岁'
        }
        // 子类构造函数的原型对象的原方法
        Son.prototype.starting = function () {
            console.log('我是原来的子类原型对象');
        } 
        // 子类原型作为父类的实例,并修改子类构造函数的原型对象的[[prototype]]指针 
        Son.prototype = new Dad(); //此时子类原型的constructor属性会指向父类构造函数
        
        // 规范化一点, 将子类构造函数的原型对象constructor属性重新指回子类
        Son.prototype.constructor = Son;
        
        // 实现继承后子类原型上新定义的方法
        Son.prototype.shouting = function () {
            console.log('这TM是' + this.age);
        }        

        //实例化子类
        let son1 = new Son();
        console.log(son1.name); // 我是爹地
        console.log(son1.age); // 八岁
        console.log(son1.obj.sex); // 男 
        son1.saying(); // 开门英子,我是爹地
        son1.shouting(); // 这TM是八岁
        // 未改变prototype指针前的原型上定义的方法变得无效
        son1.starting(); // TypeError: son1.starting is not a function

注意:

  • 因为将Son.prototype对象的[[prototype]]指针重写了,所以后来Son构造函数的实例化对象引用不到原来的Son.prototype对象上的方法。
  • 子类构造函数的原型对象变成父类构造函数的实例时,它的constructor属性指向被修改了,需要手动指回原构造函数。
  • 如果是引用类型的数据,一个实例修改了数据会影响到其他实例。比如下面👇👇👇
        let son2 = new Son();
        son2.obj.sex = '女';
        console.log(son1.obj.sex); // 女

糟了!!son1说刚才还是男的,咋一下就变了。所以这也是原型链共享引用值会带来的问题。

2.2.2 盗用构造函数

为了解决原型包含引用值导致的继承问题,一种叫作"盗用构造函数"的技术被开发起来。思路就是:在子类构造函数中调用父类构造函数,可以用call()apply()方法来绑定this对象,这样每个实例就具有自己的属性。

        function Dad() {
            this.name = name;
            this.obj = {
                sex: '男'
            }
        }

        function Son() {
            Dad.call(this)
        }

        let son1 = new Son();
        son1.obj.sex = '女';
        console.log(son1.obj.sex); // 女
        
        let son2 = new Son();
        console.log(son2.obj.sex); // 男

好了利用构造函数法就完美的解决了引用值的问题,并且子类构造函数还可以向父类构造函数传参。
看👇👇👇

        function Dad(name) {
            this.name = name;
            this.obj = {
                sex: '男'
            }
        }

        function Son() {
            Dad.call(this, '老王')
        }

        let son = new Son();
        console.log(son.name); // 老王
        console.log(son.obj.sex); // 男

注意: 相信细心的同学已经注意到了,虽然解决了引用值的问题,但是貌似这玩意儿不能引用到构造函数的原型对象啊?所以这个方法也是有缺陷的。

2.2.3 组合继承

现在有的小可爱就要说了,woc,取其精华去之糟粕就完事了。把前面两种方式的优点组合起来就解决了吗,话不多说试试就试试!

        function Dad(name) {
            this.name = name;
            this.obj = {
                sex: '男'
            }
        }
        
        Dad.prototype.sayName = function() {
            console.log(this.name);
        }

        // 继承父类属性
        function Son(name, age) {
            Dad.call(this, name);
            this.age = age;
        }

        // 继承父类方法
        Son.prototype = new Dad();

        Son.prototype.sayAge = function() {
            console.log(this.age);
        }

        let son1 = new Son('小芳', 18);
        son1.obj.sex = '女';
        console.log(son1.name); // 小芳
        console.log(son1.age); // 18
        console.log(son1.obj.sex); 女

        let son2 = new Son('老王', 8); 
        console.log(son2.name); // 老王
        console.log(son2.age); // 8
        console.log(son2.obj.sex); // 男

不得不说,李云龙来了都得说你真是个人才!

2.2.4 原型式继承

接着又有人想着有没有不自定义类型也能通过原型实现对象之间的信息共享,就有了原型式继承的方式。ES5中有Object.create()方法可以实现,这个方法接收两个参数,第一个参数是这个方法返回的对象的原型,第二个参数是给这个新对象定义额外属性的对象(该对象的属性一般是自身自定义的属性独立于原型链上可枚举的属性)。

let person = {
    name: "老王",
    friends: ['老赵', '老钱', '老孙']
}

let person1 = Object.create(person, {
    age: {
        value: 18
    }
});
    person1.name = '老张'
    person1.friends.push('老周');
    // 原型person是原型person1的原型
    console.log(person.isPrototypeOf(person1)); // true
    console.log(person.friends); // ['老赵', '老钱', '老孙', '老周']

Object.create()方法相当于下面的伪代码。👇👇👇

// o对象是临时构造函数的实例对象的原型
function createObject(o) {
        function F() {}
        F.prototype = o;
        return new F();
}

这个封装函数内部有个临时的构造函数用于生成传入对象的实例对象。所以返回的对象的原型即为传入的对象.

注意:

  • 原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。
  • 这种方式省去了构造函数那部分直接通过原型来继承,本质上是一种浅拷贝,所以引用值共享的问题还是存在。

2.2.5 寄生式继承

还有一种与原型式继承比较接近的继承方式是寄生式继承,该方式有点类似于工厂模式,只关注对象并不在乎对象的类型。

function clone(original) {
    let temp = Object.create(original);
    // 可以添加方法来增强对象
    temp.saying = function() {
        console.log('hello');
    };
    return temp;
}

let person = {
    name: '老王',
    friends: ['老赵', '老钱', '老孙']
}

let p = clone(person);
    p.saying(); // hello

2.2.6 寄生式组合继承

寄生式组合继承解决了组合继承中重复调用父类构造函数导致浪费内存空间的问题,是js中比较成熟且有效的继承方式,也是六种继承方式中最有效的继承方式。一起来看看他的真面目吧!

        // 封装一个函数 将子类原型继承父类原型
        function inherit(Son, Dad) {
            let prototype = Object.create(Dad.prototype);
            prototype.constructor = Son;
            Son.prototype = prototype;
        }

        function Dad(name) {
            this.name = name;
            this.obj = {
                sex: '男'
            }
        }
        
        Dad.prototype.sayName = function() {
            console.log(this.name);
        }

        function Son(name, age) {
            Dad.call(this, name);
            this.age = age;
        }
        // 省略了再次调用父类构造函数的过程
        inherit(Son, Dad);

        Son.prototype.sayAge = function() {
            console.log(this.age);
        }

实际上这一系列操作的本质就是,先封装一个函数,这个函数的目的在于将父类原型继承给子类原型。接着骚操作来了!Object.create(Dad.prototype)操作返回的新对象的原型正是父类原型,然后把这个对象赋值给我们自己创建的prototype变量(自己定义),接下来再把这个对象的constructor指针指向子类构造函数,将这个对象赋值给子类的原型,大功告成!将子类原型继承父类原型的任务就完成了,这样我们只需要调用一次父类构造函数了。

小结

原型链继承

  • 优点:子类实例可以继承到父类构造函数和子类构造函数的属性,父类原型的方法。
  • 缺点:子类实例化之前原来的子类原型上的方法会失效,子类实例化时只能向子类构造函数传参而不能向父类构造函数传参, 且存在共享引用值的问题。

构造函数式继承

  • 优点:解决引用值共享的问题,可以向父类构造函数传参。
  • 缺点:不能访问到父类原型上的方法,只能在构造函数上定义方法,重复创建。

组合式继承

  • 优点:结合了原型链继承和构造函数式继承的优点。
  • 缺点:效率低,重复调用了两次父类构造函数,浪费内存空间。

原型式继承

  • 优点:不需要单独创建构造函数,直接通过原型继承。
  • 缺点:是对作为原型的对象进行浅拷贝,还是存在引用值共享问题。

寄生式继承

  • 优点:对原型式继承进行封装。(我感觉其实主要还是为了寄生式组合继承而生)
  • 缺点:封装函数中的方法还是存在重复创建的问题,不能重用。

寄生式组合继承

  • 优点:解决了组合式继承重复调用父类构造函数的问题,是比较成熟且有效的继承方式。

类class这个ES6 的新语法本质上还是对原型链和构造函数的使用,但是确实可以减少一些代码的冗长和混乱,写法更加优雅简洁。如果想更深入了解关于js继承相关的语法,可以查看相关书籍。
最后欢迎大家指正和交流。🎉🎉🎉

JavaScript高级程序设计(第4版)
你不知道的JavaScript (上卷)

ES6 入门教程 - ECMAScript 6入门 (ruanyifeng.com)

image.png