JavaScript 类(class)

156 阅读10分钟

在面向对象编程的语言中, 的概念或许并不陌生,虽然JavaScript中皆为对象,但是ES6之前并未有类概念。所以,ECMAScript 6 中新引入的 class 关键字具有正式定义类的能力。那么 到底是什么呢,首先看看MDN中对类的定义:

类是用于创建对象的模板。他们用代码封装数据以处理该数据。 —— MDN

但是JavaScript中的类和其他传统面向对象的编程语言有所区别,即:

JavaScript 中的类建立在原型上,是 ECMAScript 中新的基础性语法糖结构。

一、类的定义

实际上,JavaScript 中的类是“特殊的函数”,所以类的定义也主要有两种方式:类声明类表达式

(1)类声明

// 类声明
class Preson() {

}

注意:类声明和函数声明的区别\color{red}{注意:类声明和函数声明的区别}

  • 函数声明会提升,但是类声明不会
  • 函数受 函数作用域 限制,类受 块作用域 限制

(2)类表达式

// 类表达式
const Person = class {

}

类表达式可以命名或不命名,命名类表达式的名称式该类体的局部名称(可以通过类的 name 属性访问)

// 命名类表达式
const Person = class Person1 {

}
// 通过name属性可以访问到命名
console.log(Person.name)  // Person1

二、类的构成

通常,JavaScript中一个类可以包含 构造函数方法原型方法获取函数设置函数静态类方法。但这些并不是必须的,即使都不包含,类的定义一样有效。此外,建议类名使用“大驼峰”的格式,首字母大写。
有关类中的代码,最需要注意的是:

默认情况下,类中定义的代码都在严格模式下执行。

2.1 类构造函数

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

  • constructor方法是一个特殊的方法,用于创建和初始化一个由class创建的对象。
class Person {
    // 构造函数
    constructor(name, age){
        this.name = name;
        this.age = age;
    }
}
let person = new Person('dali', '12');
console.log(person);  // Person {name: 'dali', age: '12'}
  • 一个类只能拥有一个名为 “constructor”的特殊方法。如果类包含多个constructor的方法,则将抛出 一个SyntaxError
class Person {
    constructor(name, age){
        this.name = name;
        this.age = age;
    }

    // SyntaxError: A class may only have one constructor
    constructor() {

    }
}
  • 构造函数的定义不是必须的,不定义构造函数相当于将构造函数定义为空函数
class Person {

}
let person = new Person();
console.log(person);  // Person {}
  • 一个构造函数可以使用 super 关键字来调用一个父类的构造函数。(后面继承详述)

(1)类实例化过程

使用 new 操作符实例化类的操作,其实就等同于使用 new 调用其构造函数(constructor)。步骤如下:

  • 在内存中创建一个新对象
  • 这个新对象内部的 [[prototype]] 指针被赋值为构造函数的 prototype 属性。
  • 构造函数内部的this执行被赋值的新对象
  • 执行构造函数内部的代码(给新对象添加属性)
  • 如果构造函数返回非空对象,则返回该对象,否则,返回刚创建的新对象。 注意:\color{red}{注意:}在类构造函数中为新创建对象添加的“自有”属性,不会在原型上共享。即这些属性,每个对象都有可能不同。

(2)立即实例化

let person = new class Person {
    // 构造函数
    constructor(name, age){
        this.name = name;
        this.age = age;
    }
}("dali", 11)
console.log(person);  // Person {name: 'dali', age: 11}

(3)类构造函数和普通构造函数的区别

  • 调用类构造函数必须使用 new 操作符,若不是用 new 操作符调用,会报错 TypeError
class Person {
    // 构造函数
    constructor(name, age){
        this.name = name;
        this.age = age;
    }
}
let p = new Person('dali', '12');
console.log(p);  // Person {name: 'dali', age: '12'}

// 不使用 new 调用类构造函数会报错
console.log(p.constructor())  // TypeError: Class constructor Person cannot be invoked without 'new'
  • 普通构造函数的调用可以不使用new操作符,但此时函数内部的 this 指向全局对象。
function Person(name, age) {
    this.name = name;
    this.age = age;
}
let p = new Person('dali', '12');
console.log(p);  // Person {name: 'dali', age: '12'}

// 当直接调用构造函数时,函数内部this指向全局对象
Person("haha", 13);
console.log(window.name, window.age);  // haha 13

上述代码中直接调用构造函数Person("haha", 13)后,全局对象window(浏览器中)上就多了nameage 属性,并且值和调用的构造函数Person的参数相同。说明直接调用构造函数Person时,函数内部this指向全局对象。

(4)类 是 “特殊的函数”

首先定义一个 Person 类,方便下面代码示例:

// Person类
class Person {
    // 构造函数
    constructor(name, age){
        this.name = name;
        this.age = age;
    }
}

// Person类的实 p
let p = new Person('dali', '12');
  • ECMAScript 的八大基本类型(Undefined、Null、Boolean、Number、String、BigInt、Symbol、Object)中并不包含 class 类型。所以ECMAScript中的类(class)其实就是一种特殊的函数
console.log(typeof(Person))  // function
console.log(Object.prototype.toString.call(Person))  // [object Function]
console.log(Person instanceof Function)  // true

上述代码中,三种判断类型的方法,都表明Person类是一个函数。

  • 类也有原型(prototype)属性,且原型对象上也有一个constructor属性指回类本身。类实例的内部属性__proto__指向类的原型。
console.log(Person.prototype);  // {constructor: ƒ}
console.log(Person.prototype.constructor === Person)  // true
console.log(Object.getPrototypeOf(p) === Person.prototype)  // true
  • 类 与 类实例的constructor属性 相等
console.log(Person === p.constructor)  // true

输出Personp.constructor 结果如下:

image.png

  • 注意区分:Person.constructor类构造函数
    • Person.constructor:类是一个特殊的函数,而函数是一个对象,那么类实际上也是一个对象,也是通过new 操作符调用普通构造函数创建的一个实例。 所以,Person.constructor是创建 Person 的普通构造函数。
    • 类构造函数就是类中关键字constructor定义的那段代码,使用 new 初始化类时被调用。
// Person 是 Function对象实例
console.log(Person.constructor === Function)  // true
console.log(Object.getPrototypeOf(Person) === Function.prototype)  // true

输出Person.constructor结果如下:

image.png

2.2 原型方法

类定义语法把在类块中定义的方法作为原型方法,即:在类块中定义的方法都会添加到类的原型上

class Person {
    // 构造函数
    constructor(name, age){
        this.name = name;
        this.age = age;
    }

    // 原型方法
    sayName() {
        console.log(this.name);
    }
}

let p = new Person('dali', 12);
p.sayName()  // dali

// sayName 并非实例的自身属性
console.log(p.hasOwnProperty('sayName'))  // false

// sayName 定义在类的原型上
console.log(Person.prototype)  // {constructor: ƒ, sayName: ƒ}
console.log(Person.prototype.hasOwnProperty('sayName'))  // true

上述代码中定义在类块中的函数sayName出现在类的原型上,实例自身并没有该方法。

  • 注意定义原型方法的语法,以下方式定义的类成员不会出现在原型上。
class Person {
    // 原型方法
    sayName() {
        console.log(this.name);
    }
    
    // 该种方式定义的 sayAge 是一个公有字段
    sayAge = function() {
        console.log(this.age);
    }
}
let p = new Person();

console.log(p);  // Person { sayAge: ƒ }
console.log(Person.prototype);  // {constructor: ƒ, sayName: ƒ}
  • 类定义也支持 gettersetter,但也是定义在原型上的。
class Person {
    get name() {
        return this.name_;
    }

    set name(newValue) {
        this.name_ = newValue
    }
}
// getter 和 setter 方法也定义在原型上
console.log(Person.prototype.hasOwnProperty('name'));  // true


let p = new Person();
// 实例自身没有 getter 和 setter 方法,而是继承自类的原型
console.log(p.hasOwnProperty('name'));  // false

console.log(p);  // Person {}
// 调用setter方法
p.name = 'dali';
console.log(p);  // Person {name_: 'dali'}

2.3 静态类方法

static 关键字用来定义一个类的一个静态方法。调用静态方法不需要 实例化 该类,但不能通过一个类实例调用静态方法。静态方法通常用于为一个应用程序创建工具函数。

class Person {
    // 构造函数
    constructor(name, age){
        this.name = name;
        this.age = age;
    }

    // 静态方法
    static sayHello() {
        console.log('hello');
    }
}

// 直接通过类调用静态方法
Person.sayHello();  // hello

let p= new Person('dali', 12);
// 不能通过实例调用类静态方法
p.sayHello();  // TypeError

此外static 关键字也可以用来定义一个类的一个静态属性

class Person {
    static sta = 10;
}

// 直接通过类访问静态属性
console.log(Person.sta)  // 10

let p= new Person();
// 不能通过实例访问静态属性
console.log(p.sta)  // undefined

2.4 实例属性和原型属性

通常,定义在类构造函数中的属性是实例属性,例如:

class Person {
    // 构造函数
    constructor(name, age){
        this.name = name;
        this.age = age;
    }
}

let p= new Person('dali', 12);
console.log(p)  // Person { name: 'dali', age: 1 }

console.log(Person.prototype)  // {constructor: ƒ}

此外如果想定义类原型属性,可以通过以下方式:

// 定义原型上的属性
Person.prototype.proper = 'haha'
console.log(Person.prototype)  // {proper: 'haha', constructor: ƒ}

2.5 公有字段和私有字段

注意:公有字段和私有字段在浏览器中的支持是有限的

(1)公有字段

class Rectangle {
  height = 0;
  width;
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }
}

(2)私有字段

class Rectangle {
  #height = 0;
  #width;
  constructor(height, width) {
    this.#height = height;
    this.#width = width;
  }
}

从类外部引用私有字段是错误的。它们只能在类里面中读取或写入。通过定义在类外部不可见的内容,可以确保类的用户不会依赖于内部,因为内部可能在不同版本之间发生变化。

三、类的继承

注意:类的继承仍然基于原型链

3.1 继承语法

ES6类支持单继承extends 关键字在 类声明 或 类表达式 中用于创建一个类作为另一个类的一个子类。可以继承任何拥有[[construct]] 和 原型的对象,即:类 和 普通构造函数 都可被继承。
(1)继承自类

class Animal {
    constructor(name) {
      this.name = name;
    }
  
    speak() {
      console.log(`${this.name} makes a noise.`);
    }
  }
  
  class Dog extends Animal {
    constructor(name) {
      super(name); // 调用超类构造函数并传入name参数
    }

  }
  var d = new Dog('Mitzie');
  // speak 方法继承自Animal类
  d.speak();// Mitzie makes a noise.

(2)继承自普通构造函数

function Animal(name) {
    this.name = name;
}
Animal.prototype.speak = function() {
    console.log(`${this.name} makes a noise.`);
}
  
class Dog extends Animal {
    constructor(name) {
        super(name); // 调用超类构造函数并传入name参数
    }
}
var d = new Dog('Mitzie');
// speak 方法继承自Animal类
d.speak();// Mitzie makes a noise.

注意:对于普通对象的继承,可以使用Object.create() 或者 Object.setPrototypeOf()

3.2 super关键字

super 关键字用于调用对象的父对象上的函数

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

(1)构造函数中使用super关键字

class Animal {
    constructor(name) {
        this.name = name;
    }
}

  
class Dog extends Animal {
    constructor(name, type) {
        super(name); // 相当于调用 super.constructor(name)
        this.type = 'corgi';
    }
}

var d = new Dog('Mitzie', 'corgi');
console.log(d)  // Dog {name: 'Mitzie', type: 'corgi'}

(2)静态方法中使用super关键字

class Animal {
    static hello = 'hello';
}

class Dog extends Animal {
    static sayHello() {
        console.log(super.hello);
    }
}
Dog.sayHello(); // hello

(3)HomeObject

ES6 给类构造函数和静态方法添加了内部特性[[HomeObject]],这个特性是一个指针,指向定义该方法的对象,这个指针自动赋值,且只能在JavaScript引擎内部访问。super始终会定义为[[HomeObject]]的原型。

(4)super 关键字使用注意事项

  • super 只能在派生类构造函数和静态方法中使用
  • 不能单独使用 super 关键字,要么调用父类构造函数,要么引用父类静态方法。
class Animal {}

  
class Dog extends Animal {
    constructor() {
        console.log(super);  //  SyntaxError: 'super' keyword unexpected here
    }
}
var d = new Dog();
  • 若派生类未定义构造函数,实例化时派生类会自动调用super()
class Animal {
    constructor(name) {
        this.name = name;
    }
}
  
class Dog extends Animal { }

var d = new Dog("haha");
console.log(d)  // Dog {name: 'haha'}
  • 派生类的构造函数中,不能在super()之前引用this
class Animal {
    constructor(name) {
        this.name = name;
    }
}
  
class Dog extends Animal { 
    constructor(name, type) {
        this.type = type;
        // ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
        super(name);
    }
}
  • 派生类中若显示定义了构造函数,那么必须在其中调用super(),要么必须在其中返回一个对象。
class Animal {
    constructor(name) {
        this.name = name;
    }
}
  
class Dog extends Animal { 
    constructor(name) {
        // ReferenceError
    }
}


// 或者返回一个对象不会报错
class Dog extends Animal { 
    constructor(name) {
        return {};
    }
}

3.3 抽象基类

即可提供其他类继承,但本身不会被实例化。

3.4 Mix-ins 混入

抽象子类或者 mix-ins 是类的模板。 一个 ECMAScript 类只能有一个单超类,所以想要从工具类来多重继承的行为是不可能的。子类继承的只能是父类提供的功能性。因此,例如,从工具类的多重继承是不可能的。该功能必须由超类提供。 一个以超类作为输入的函数和一个继承该超类的子类作为输出可以用于在ECMAScript中实现混合:

var calculatorMixin = Base => class extends Base {
  calc() { }
};

var randomizerMixin = Base => class extends Base {
  randomize() { }
};

使用 mix-ins 的类可以像下面这样写

class Foo { }
class Bar extends calculatorMixin(randomizerMixin(Foo)) { }

个人对 Mix-ins 模式的理解是,如上述代码,Foo相当于一个基类(即父类),randomizerMixin等就类似于Java中的接口(interface),提供一些操作实例的方法。
但是这种模式在一些前端框架中都被弃用了,例如react中从混入模式转向了组合模式,并提出"组合优于继承"

小结

JavaScript中的类(class)是一种语法糖结构,实际上就是一个特殊的函数对象。需明确以下几点:

  • 理解类构造函数于普通构造函数的区别
  • 类的原型方法、静态方法、实例方法之间的区别
  • 类继承中 super 关键字的使用

主要参考: [1] 《JavaScript高级程序设计(第四版)》
[2] 类 —— MDN