类的学习

91 阅读9分钟

前面我们了解了怎么实现继承,但是各种方法都有自己的问题,代码也比较冗余和混乱,所以为了解决这些问题,ES6引入了class关键字,可以正式定义类,类表面上看起来可以正式面向对象编程,但是实际上背后还是原型和构造函数的概念,类只是一个新的基础性语法糖结构

类理论

在本文的开头,我们先来了解一下类的一些相关理念,而这个过程我们并不会使用JS的类来讲解

此处使用一个例子(伪代码),后续的类理论将基于这个例子解析:

 class Vehicle {
     engines = 1
     ignition() {
         output('启动引擎')
     }
     drive(){
         ignition()
         output('开始行驶')
     }
 }
 ​
 class Car inherits Vehicle {
     wheels = 4
     drive(){
         inherited: drive()
         output('上马路了', wheels, '个轮子')
     }
 }
 ​
 class Boat inherits Vehicle {
     engines = 2
     ignition(){
         output('启动引擎', engines, '个引擎')
     }
     pilot(){
         inherited: drive()
         output('下水航行')
     }
 }

继承

在面向类的语言中,可以先定义一个类,然后定义一个继承前者的类

定义好子类之后,子类对于父类来说,就是一个独立并完全不同的类子类会包含父类行为的原始副本,也可以重写所有继承的行为甚至定义新行为

在上述例子中,CarBoat都是继承Vehicle的,都拥有Vehicleenginesignition等属性和方法

多态

定义:同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果

在上面的例子中,我们可以发现,Car重写了父类Vehicledrive方法,但是也在后面调用了inherited: drive()方法,这表明Car可以引用并重写继承自父类的原始方法drive()

这种技术就被称作多态,在本例中,具体来说叫做相对多态

相对:这只是多态的一个方面,任何方法都可以引用继承层次中高层的方法,说相对是因为我们并不会定义想要访问的绝对继承层次,而只是相对的查找上一层

许多语言中使用**super来代替本例子中的inherited,含义是超类**,表示当前类的父类/祖先类

混入

JS中原来是不存在可以被实例化的类的,一个对象并不会被赋值到其他对象(继承)

所以JS开发者也想出一个方法来模拟继承,那就是混入

显式混入

我们手动实现一个继承行为,也就是mixin

 function mixin(sourceObj, targetObj) {
     for(const key in sourceObj){
         //已经存在的属性或函数就不会进行重写,从而保留子类的同名属性
         //实现了子类对父类的属性或方法重写
         if(!(key in targetObj)) {
             targetObj[key] = sourceObj[key]
         }
     }
     return targetObj
 }
 ​
 var Vehicle = {
     engines: 1,
     ignition: function() {
         console.log('启动引擎')
     },
     drive: function(){
         this.ignition()
         console.log('开始行驶')
     }
 }
 //Car继承自Vehicle,并新增属性和方法
 var Car = mixin(Vehicle, {
     wheels: 4,
     drive: function(){
         Vehicle.drive.call(this)
         console.log('上马路了' + wheels + '个轮子')
         
     }
 })

在这段代码中,我们可以重点看一下Vehicle.drive.call(this)这一段代码,这就是显式多态

因为我们需要显式指定调用的对象,因为如果不这样做的话,直接执行Vehicle.drive()则会将this绑定到Vehicle对象而不是Car对象

这种显式多态会极大的增加维护成本,所以一般不使用

显示混入还有一种变体,称为寄生继承,它既是显式的又是隐式的:

先复制一份父类的定义,然后混入子类的定义,然后用这个符合对象构建实例

 function Vehicle() {
     this.engines = 1
 }
 Vehicle.prototype.ignition = function() {
     console.log('启动引擎')
 }
 Vehicle.prototype.drive = function() {
     this.ignition()
     console.log('开始行驶')
 }
 ​
 function Car() {
     var car = new Vehicle()
     car.wheels = 4
     var vehDrive = car.drive    //保存Vehicle的drive的特殊引用
     car.drive = function() {
         vehDrive.call(this)
         console.log('使用Car行驶')
     }
     return car
 }
 ​
 var myCar = Car()
 myCar.drive()

隐式混入

隐式混入也和之前的显式混入一样,都存在同样的问题

所谓隐式,实际上就是借用了函数Something.cool并在Another的上下文中同通过this绑定调用它,把Something的行为混入到Another

 var Something = {
     cool: function(){
         this.greeting = 'Hello world'
         this.count = this.count ? this.count + 1 : 1
     }
 }
 Something.cool()
 console.log(Something.greeting)
 console.log(Something.count)
 ​
 var Another = {
     cool: function(){
         Something.cool.call(this)
     }
 }
 Another.cool()
 console.log(Another.greeting)
 console.log(Another.count)

类定义

类定义

定义类的方法有两种:类声明和类表达式

 class Person {}
 const Animal = class {}

与函数表达式类似,类表达式在求值之前也不能被引用

 console.log(FunctionExpression) //undefined
 var FunctionExpression = function() {}
 console.log(FunctionExpression) //function(){}
 ​
 console.log(ClassExpression)    //undefined
 var ClassExpression = class {}
 console.log(ClassExpression)    //class {}

但是,与函数定义不同,函数声明可以提升,但是类定义不能提升

 console.log(FunctionDeclaration)    //FunctionDeclaration(){}
 function FunctionDeclaration(){}
 console.log(FunctionDeclaration)    //FunctionDeclaration(){}
 ​
 console.log(ClassDeclaration)   
 //Uncaught ReferenceError: Cannot access 'ClassDeclaration' before initialization
 class ClassDeclaration {}
 console.log(ClassDeclaration)

另外,跟函数声明不同的地方在于 :函数受函数作用域限制,类受块作用域限制

 {
     function FunctionDeclaration(){}
     class ClassDeclaration {}
 }
 console.log(FunctionDeclaration)    //FunctionDeclaration(){}
 console.log(ClassDeclaration)   //Uncaught ReferenceError: ClassDeclaration is not defined

类构成

类可以包含构造函数、实例方法、获取函数、设置函数、静态类方法等,但是这些都不是必须的,空的类照样有效

类名的首字母一般要大写,区别于通过它创建的实例

类表达式的名称是可选的,在把类表达式赋值给变量后,可以通过name属性来取得类表达式的名称字符串,但是不能在类表达式作用域外部访问这个标识符

 let Person = class PersonName {
     identify(){
         console.log(Person.name, PersonName.name)
     }
 }
 ​
 let p = new Person()
 p.identify()    //PersonName PersonName
 console.log(Person.name)    //PersonName
 console.log(PersonName)     //Uncaught ReferenceError: PersonName is not defined

类构造函数

constructor关键字用于在类定义块内部创建类的构造函数

在使用new操作符创建类的实例时,会调用constructor这个函数

constructor函数定义不是必须的,如果不定义这个函数则相当于将其定义为空函数

实例化

使用new操作符实例化Person相当于使用new调用其构造函数constructor

使用new操作符调用构造函数会发生一下几件事:

  • 在内存中创建一个新的对象
  • 这个对象的内部指针[[Prototype]]被赋值为构造函数的prototype属性
  • 构造函数内部的this被赋值为这个新对象
  • 执行构造函数内部的代码,给新对象添加属性
  • 如果构造函数返回非空对象,则返回该对象,否则返回新创建的对象
 class Person {
     constructor(){
         console.log('Person ctor')
     }
 }
 ​
 let p = new Person()    //Person ctor

如果实例化的时候不需要传参数,则类名后面的括号可以省略

 class Person {
     constructor(){
         console.log('Person ctor')
     }
 }
 ​
 let p = new Person  //Person ctor

默认情况下,类构造函数会在执行之后返回this对象,构造函数返回的对象会被用做实例化的对象,如果没有什么引用新创建的this对象,那么这个对象会被销毁

如果返回的对象不是this对象,而是其他对象,那么这个对象不会通过instanceof操作符检测出跟类有关联,因为这个对象的原型指针并没有被修改

 class Person{
     constructor(override) {
         this.foo = 'foo'
         if(override){
             return {
                 bar: 'bar'
             }
         }
     }
 }
 ​
 const p1 = new Person(),
       p2 = new Person(true)
 ​
 console.log(p1, p2) //Person{foo: 'foo'}  {bar: 'bar'}
 console.log(p1 instanceof Person)   //true
 console.log(p2 instanceof Person)   //false

调用类构造函数必须使用new操作符,不能像普通函数一样可以不使用new调用

实例化后的实例,也含有constructor方法,所以也可以通过这个方法去new一个新的实例

 class Person{}
 const p1 = new Person()
 console.log(p1.constructor)
 const p2 = new p1.constructor()     //需要用new调用

把类当成特殊函数

  • 类就是一个特殊函数,可以通过typeof操作符检测类型,检测出来是function

     class Person {}
     console.log(typeof Person)  //function
    
  • 类标识符prototype属性原型也有一个constructor属性指向类自身

     class Person {}
     console.log(Person.prototype)   //{constructor: ƒ}
     console.log(Person === Person.prototype.constructor)    //true
    
  • 可以通过instanceof操作符检查构造函数原型是否存在于实例的原型链中

     class Person{}
     const p = new Person()
     console.log(p instanceof Person)    //true
    
  • 虽然,在类的上下文中,类本身在使用的new调用时就会被当成构造函数

    但是,类中定义的constructor方法不会被当成构造函数,在对他使用instanceof操作符时会返回false

    而如果创建实例时直接将类构造函数当成普通构造函数来使用,那么instanceof操作符的结果将会反转

    class Person {}
    const p1 = new Person()
    console.log(p1.constructor === Person)  //true
    console.log(p1 instanceof Person)  //true
    console.log(p1 instanceof Person.constructor)   //false
    
    const p2 = new Person.constructor()
    console.log(p2.constructor === Person)  //false
    console.log(p2 instanceof Person)   //false
    console.log(p2 instanceof Person.constructor)  //true
    
  • 类可以像其他对象和函数一样把类作为参数传递

    const classList = [
        class {
            constructor(id){
                this.id_ = id
                console.log(`instance ${this.id_}`)
            }
        }
    ]
    function createInstance(classDefinition, id){
        return new classDefinition(id)
    }
    const foo = createInstance(classList[0], '1212')	//instance 1212
    
  • 跟立即函数表达式相似,类也可以立即实例化

    let p = new class Foo{
        constructor(x){
            console.log(x)	//bar
        }
    }('bar')
    console.log(p);	//Foo{}
    

实例、原型和类成员

实例成员

通过new操作符创建实例时,都会执行类构造函数constructor,在这个函数内部,可以为新创建的实例添加自有属性,每个实例对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享

class Person {
    constructor(){
        this.name = new String('ZSW')
        this.sayName = () => console.log(this.name)
        this.nickName = ['XYZ', '小包']
    }
}
let p1 = new Person()
let p2 = new Person()
p1.sayName()
p2.sayName()
console.log(p1.name === p2.name)	//false
console.log(p1.sayName === p2.sayName)	//false
console.log(p1.nickName === p2.nickName)	//false

原型方法与访问器

由于方法一般都是希望共享的,所以我们可以在类块中定义方法作为原型方法

class Person {
    constructor(){
        this.locate = () => console.log('instance')
    }
    locate(){
        console.log('prototype')
    }
}

const p = new Person()
p.locate()  //instance
Person.prototype.locate()   //prototype

可以把方法定义在类块中,但是不能在类块中给原型添加原始值或对象作为成员数据

class Person{
    name: 'ZXY'
}
//Uncaught SyntaxError: Unexpected identifier

类方法等同于对象属性,所以可以用字符串、符号、计算值作为键

const symbolKey = Symbol('symbolSey')
class Person {
    constructor(){
        this.locate = () => console.log('instance')
    }
    locate(){
        console.log('prototype')
    }
    [symbolKey](){
        console.log('Symbol属性')
    }
    ['computed' + 'key'](){
        console.log('计算属性')
    }
}

const p = new Person()
p.locate()  //instance
p[symbolKey]()	//Symbol属性,此时在实例本身找不到该属性,就会去原型上面找
p.computedkey()	//'计算属性
Person.prototype.locate()   //prototype

类定义也支持获取和设置访问器

class Person {
    set name(val){
        return this.name_ = '笨' + val
    }
    get name(){
        return this.name_
    }
}
const p = new Person()
p.name = '小包'
console.log(p.name);	//笨小包

静态类方法

可以在类上定义静态方法,这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例

与原型成员类似,静态成员每个类上只能有一个

使用static关键字作为前缀,在静态成员中,this引用类自身,其他约定与原型成员一致

class Person{
    constructor(){
        this.locate = () => console.log('instance', this)
    }
    locate(){
        console.log('prototype', this)
    }
    //定义在类本身上
    static locate(){
        console.log('class', this)
    }
}
const p = new Person()
p.locate()  //instance  Person{}
Person.prototype.locate()   //prototype  {constructor: ...}
Person.locate() //class  class Person{}

非函数原型和类成员

虽然类定义并不显示支持在原型或类上添加成员数据,但在类定义外部,可以手动添加

一般不会这么添加,因为在共享目标上添加可变数据成员是一种反模式对象实例应该独自拥有通过this引用的数据

class Person{
    sayName(){
        console.log(`${Person.greeting} ${this.name}`)
    }
}
Person.greeting = 'My Name is'
Person.prototype.name = 'XYZ'
const p = new Person()
p.sayName()	//My name is XYZ

继承

虽然ES6新增特性中的类继承机制使用的是新语法,但是背后依旧是原型链

继承基础

ES6支持单继承

使用**extends关键字**,就可以继承任何拥有[[Constructor]]和原型的对象不仅可以继承类,也可以继承普通构造函数

派生类会通过原型链访问的类和原型上面定义的方法this的值会反映调用相应方法的实例或者类

extend也可以在类表达式中使用,如let Bar = class extends Foo{}

class Vehicle {}
class Bus extends Vehicle{}
let b = new Bus()
console.log(b instanceof Bus)	//true
console.log(b instanceof Vehicle)	//true

构造函数、HomeObject、super()

派生类的方法可以通过super关键字引用他们的原型

这个关键字只能在派生类中用,而且仅限于类构造函数、实例方法和静态方法内部

class Bus extends Vehicle{
    constructor(){
        //不要在super之前引用this,否则会报错
        super()	//相当于super.constructor()
        console.log(this instanceof Vehicle)    //true
        console.log(this)   //Bus{hasEngine: true}
    }
}

new Bus()

在静态方法中,通过super调用继承类上定义的静态方法

class Vehicle {
    static identify(){
        console.log('vehicle')
    }
}

class Bus extends Vehicle{
    static identify(){
        super.identify()
    }
}

Bus.identify()	//vehicle

使用super所需要注意的问题:

  • super只能在派生类构造函数和静态方法中使用
  • 不能单独引用super关键字,要么用它调用构造函数,要么用它引用静态方法
  • 调用super()会调用父类构造函数,并将返回的实例赋值为this
  • super()的行为如同调用构造函数,如果需要给父类构造函数参数,则需要手动传入
  • 如果没有定义类构造函数,在实例化派生类时会调用super(),而且会传入所有给派生类的参数
  • 在类构造函数中,不能在调用super()之前引用this
  • 如果在派生类中显式定义了构造函数,则要么必须在其中调用super(),要么必须在其中返回一个对象

抽象基类

可能需要定义这样一个类,可供其他类继承,但是本身不会被实例化

虽然没有这种专门支持这种类的语法,但是通过new.target也可以实现

new.target可以返回new命令作用于的那个构造函数

class Vehicle {
    constructor(){
        console.log(new.target)
        if(new.target === Vehicle){
            throw new Error('Vehicle只能用于继承,不能实例化')
        }
    }
}

class Bus extends Vehicle{}

new Bus()	//class Bus{}
new Vehicle()	//Uncaught Error: Vehicle只能用于继承,不能实例化