深入浅出,全方位剖析 ES6 的 class 类及其继承机制

一、class 类

ES6 之前生成实例对象的传统方法是通过构造函数,方式如下。

function Person(name, age) {
    this.name = name;
    this.age = age;
}
Person.prototype.fn = function() {};

const p = new Person('ZhangSan', 18);

ES6 提供了一种更接近传统语言的写法,即引入 class (类)的概念,定义类时,使用关键词 class,上述代构造函数的例子,可以换成类来实现,写法如下。

class Person {
    constructor(name, age) { // 构造方法
        name = name; 
        age = age; 
    }

    fn() { ... } // 原型方法
}
const p = new Person('ZhangSan', 18);

上述代码定义了一个 Person 类,constructor() 是类的构造方法,构造方法里面的 this 代表的是类的实例对象。

1、 类的语法,定义类的时候需要使用 class 关键词。

class Person { ... }

类的数据类型值为 'function',说明类本身是一个函数,而类本身就指向一个构造函数,既然是构造函数,使用的时候,就需要用 new 命令符。

class Person { ... }

typeof Person // 'function'
Person === Person.prototype.constructor;

2、constructor() 方法。

constructor() 方法是类的构造方法,也是类的默认方法。当使用 new 命令进行调用类时,该方法会自动执行。ES6 规定,一个类必须有一个 constructor() 方法,如果定义类的时候没有显示声明该方法,类会默认添加一个空的 constructor() 方法,该方法默认返回一个实例对象,即 this 。

class Person { 
    // 默认会添加一个空的 constructor 方法
};

Person.prototype.hasOwnProperty('constructor'); // true

// 等同于
class Person {
  constructor() { ... }
};

由于类的方法都是定义在 prototype 上,如果需要为上述的类 Person 追加新方法时,可以使用 Object.assign() 方法。

Object.assign(Person.prototype, { ... });

3、类的原型方法,与 constructor() 方法一致,类的原型方法也是直接在内部定义,多个原型方法之间不需要加逗号分割,加了逗号反而会报错。

class Person { 
    fn() { ... }
    sn() { ... }
};

ES5 中,构造函数通过 prototype 添加的原型方法,默认是可枚举的,相关的原型方法的属性特征是 enumerable: true

function Person(name, age) {
    this.name = name;
    this.age = age;
}
Person.prototype.fn = function() {};
const p = new Person('ZhangSan', 18);

for(let i in p) {
    console.log(i); // 依次输出 name, age, fn
}

Object.getOwnPropertyDescriptor(Person.prototype, 'fn');
// {writable: true, enumerable: true, configurable: true, value: ƒunction}

而在 ES6 中,类的原型方法是不可枚举的,它的属性特征是 enumerable: false,这是两者的区别,而通常在实际开发中在对实例对象的属性进行遍历的时候,基本不需要枚举原型对象的属性,而 ES5 的构造函数生成的实例,则会把原型方法的属性名称也返回,相比之下,类的做法会更好。

class Person {
    constructor(name, age) {
        this.name = name; 
        this.age = age; 
    }

    fn() { ... }
}
const p = new Person('ZhangSan', 18);

for(let i in p) {
    console.log(i); // 依次输出 name, age
}

Object.getOwnPropertyDescriptor(Person.prototype, 'fn');
// {writable: true, enumerable: false, configurable: true, value: ƒunction};

4、类的实例对象。

使用类生成实例与使用构造函数生成实例,在写法上是完全一致的,都需要使用 new 命令符,区别是如果没有使用 new 命令符时,构造函数会被当成普通函数执行,并不会报错,而 class 类则会报错。

function Person() { ... }
new Person(); // 执行构造函数,生成实例对象
Person(); // 当普通函数执行

class Person { ... }
new Person(); // 执行构造函数,生成实例对象
Person(); // 报错  TypeError: Class constructor Person cannot be invoked without 'new'

ES5 的构造函数的实例对象一样,类生成的所有实例对象也都共享一个原型。

class Person {
    fn() { ... }
}
const p1 = new Person();
const p2 = new Person();

p1.fn === p2.fn; // true

5、类的实例属性。

类的属性和方法,如果没有显式定义在实例对象本身(即定义在this对象上),就都是定义在原型上(即定义在class上)。

class Person {
    constructor(name, age) {
        this.name = name; // 实例属性
        this.age = age; // 实例属性
    }
    // 属性 fn 不是通过 this 定义,属于原型属性,属性值为一个函数(原型方法)
    fn() { ... } 
}
const p = new Person('ZhangSan', 18);

p.hasOwnProperty('name'); // true
p.hasOwnProperty('age'); // true
p.hasOwnProperty('fn'); // false

类的实例属性的新写法,ES2022 为类的实例属性,又规定了一种新写法。实例属性现在除了可以定义在 constructor() 方法里面的 this 上面,也可以定义在类内部的最顶层。这种写法的好处是一目了然就看出该类生成的实例对象有哪些属性,缺点是不能动态传参,如果需要根据参数动态赋值,需要将其写在 constructor() 内部的 this 对象身上。

class Person {
    name = '小鱼'; // 不变的实例属性
    age = 18; // 不变的实例属性

    constructor(sex) {
        this.sex = sex; // 可传参设置的实例属性
    }
}

6、类的取值函数 get 和 设置函数 set。

跟构造函数一样,类也有自己的 getset 函数

class Person {
    _name = '小鱼';

    get name() {
        return this._name;
    }

    set name(value) {
        this._name = value;
    }
}

const p = new Person();
p.name; // 输出小鱼
p.name = '大鱼';
p.name; // 输出大鱼

7、类的属性表达式。

类内部的属性名称可以使用变量表达式来表示,使用的时候需要将变量名放在中括号里面 "[ 属性名 ]" 。

const property = 'fn';
class Person {
    [property] () { ... }
}
const p = new Person();

Person.prototype.hasOwnProperty('fn'); // true

8、类的表达式。

类除了可以使用 class 关键词直接声明,也使用声明变量表达式的形式声明。

class Person { ... }
            
// 或
const SuperPerson = class Person{ ... }

// 或
const Person = class { ... }

上述第二条代码中的类名 Person 只能在类的内部使用,指代当前类,在 class 外部使用需要用 SuperPerson,如果类的内部不需要使用到类名,可以直接省略,使用第三条语句的写法。

9、类的静态属性与静态方法。

跟构造函数一样,类也有自己的静态属性与静态方法。类相当于实例对象的原型,类内部定义的属性与方法都会被生成的实例对象继承。如果定义属性或方法的前面加上关键词 static, 就成了静态属性或静态方法,类的静态属性是指定义在类身上的属性,不会被类的实例所继承,使用时只能通过类自身来调用。

// 老写法
class Person { ... };
Person.name = '小鱼';

// 新写法
class Person { 
    static name = '小鱼';
}

const p = new Person();
p.name; // 输出 undefined
Person.name; // 输出 '小鱼'

10、类的私有属性与私有方法。

ES2022class 类规定了私有属性与私有方法的写法,在属性名之前使用 # 表示,类的私有属性与私有方法。在这之前,由于没有专门的属性保护的方法,通常采用变通的方式实现属性保护。

(1)、使用 Symbol() 进行属性保护,将需要保护不被外部访问和修改的属性,可以压入一个以 Symbol()key 对象内。

const protect = Symbol();
function Person(name, age) { 
    // 使用模块化导出 Person 类或类的实例对象,外部无法访问变量 protect,因此无法读取和修改
    this[protect] = {};
    this[protect].name = name;
    this[protect].age = age;
}
Person.prototype.getName = function() {
    return this[protect].name;
}

const p = new Person('小鱼');
Object.keys(p); // [] 找不到相关的 name 和 age 属性
Object.getOwnPropertyNames(p); // [] 找不到相关的 name 和 age 属性
p[Symbol()]; // undefined ,找不到相关的 name 和 age 属性
p.getName(); // 小鱼

(2)、使用 weakMap 进行属性保护。

 const protect = new WeakMap();

class Person{
    constructor() {
        protect.set(this, 'hello world');
    }

    set protect(value) {
        protect.set(this, value);
    }

    get protect() {
        return protect.get(this);
    }
}

const p = new Person('小鱼');

// 此处,可以直接访问到属性值 hello world,但在实际开发中,
// 会使用模块进行导出,这样外部就访问不到了,起到保护的作用
console.log(p.protect); 

现在,通过在属性前添加 # 来表示类的私有属性或私有方法,但只能在类的内部通过 this 调用,在类的外部调用会报错。

class Person { 
    #name = '小鱼';
    
    get name() {
        return this.#name;
    }
    
    set name(value) {
        this.#name = value;
    }
    
    #setName() { // 内部调用
        return this.#name = 'hello world';
    }
    
    changeName() {
        return this.#setName();
    }
}
    
const p = new Person();
p.#name; // 报错
p.name; // 输出 '小鱼'

p.#setName(); // 报错
p.changeName(); // hello world

11、类的注意点。

跟构造函数不同,类不存在变量提升,在类声明之前使用,会直接报错。

new Person(); // ReferenceError
class Person{ ... }

类内部默认使用严格模式,无需另外声明 'use strict' 声明严格模式。

由于本质上,ES6 的类只是 ES5 的构造函数的一层包装,所以函数的许多特性都被Class继承,包括name属性。

class Person{ ... }
Person.name; // 输出 Person

12、类内部的 this 指向。

类的方法内部的 this,它默认指向类的实例。需要注意的一点,如果类内部包含有 this 的方法不是通道实例对象调用,而是单独使用(解构的函数),结果很可能会报错。

class Person{
    #name = '小鱼';
    showName() {
        return this.#name;
    }
}
const p = new Person();
p.showName(); // 输出 '小鱼'

const { showName } = p;
showName(); // 报错,this 为 undefined

13、类的 new.target 属性。

new.target 属性是 ES6new 命令引入的,new.target 属性一般在构造函数内部使用,返回 new 命令所作用的那个构造函数。如果构造函数不是通过 new 命令符或 Reflect.construct() 方法调用的,new.target 会返回 undefined,因此可以用这个属性来确定构造函数使用时是怎样调用的。

function Person(name) {
    if(!new.target) throw new Error('必须使用 new 命令生成实例');
    this.name = name;
}

nwe Person('小鱼'); // 正确

Person('小鱼'); // 报错

二、class 类的继承。

1、extends 关键字。ES6 规定类可以通过 extends 关键字,实现子类继承父类的属性和方法。

class Person {
    name = '小鱼';
}
class Child extends Person { ... }

const c = new Child();
c.name; // 输出 '小鱼'

2、super 关键字。ES6 规定,子类在继承父类的过程,必须在 constructor() 方法中调用 super() 方法,而且必须在使用 this 对象之前调用,否则就会报错。super 指代父类的构造函数,通过执行 super() 来创建一个父类的实例对象。这是因为子类自己的 this 对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,添加子类自己的实例属性和方法。如果不调用 super() 方法,子类就得不到自己的 this 对象,故而在通过 this 为子类自己的添加实例属性的时候,会报错。

比如有个 Person 父类。

class Person {
   fn() { ... }
}

现在有个 Child 的子类要继承父类 Person,按照下面这种写法,没有调用 super() 就直接使用 this 的话会报错。

class Child extends Person {
    constructor(name) {
        this.name = name;
    }
}

new Child('小鱼'); // 报错,没有在constructor 里面执行 super()

如果调用 super() 之前就使用 this,同样会报错。

class Child extends Person {
    constructor(name) {
        this.name = name;
        super();
    }
}

const c = new Child('小鱼'); // 报错,在调用 super() 之前 this 不存在

正确的写法是在 constructor() 构造函数内,先调用 super() 方法,然后在开始使用 this

class Child extends Person {
    constructor(name) {
        super();
        this.name = name;
    }
}

const c = new Child('小鱼'); // 正确

3、为什么子类的构造函数,一定要调用 super()

原因就在于 ES6 的继承机制与 ES5 完全不同。ES5 的继承机制,是先创造一个独立的子类的实例对象,然后再将父类的方法添加到这个对象上面,即“实例在前,继承在后”。ES6 的继承机制,则是先将父类的属性和方法,加到一个空的对象上面,然后再将该对象作为子类的实例,即“继承在前,实例在后”。这就是为什么 ES6 的继承必须先调用 super() 方法,因为这一步会生成一个继承父类的 this 对象,没有这一步就无法继承父类。

而子类在继承父类的时候调用的 super() 方法,实际上就是在调用父类的构造函数,即 constructor() 方法。

class Person {
    constructor() {
        console.log(888);
    }
}

class Child extends Person {
    constructor() {
        console.log(999);
    }
}

const c = new Child(); // 先输出 888, 后输出 999

如果子类的在继承父类的时候,没有显示声明 constructor() 和调用 super() 方法, constructor() 方法会默认添加,并且会默认调用 super() 方法。

class Person { ... }

class Child extends Person {

}

// 上述继承代码等同于
class Child extends Person {
    constructor(...args) {
        super(...args);
    }
}

4、父类的所有属性与方法都会被子类继承,除了私有属性与私有方法。类的私有属性与方法只能在类的内部自己使用,如果子类需要访问父类的私有属性,父类可以通过在内部定义相关私有属性的读写方法,子类通过继承这些方法,实现读写父类的私有属性。

class Person{
    #name = '小鱼';
    get name() {
        return this.#name;
    }
    set name(value) {
        this.#name = value;
    }
}

class Child extends Person { ... }
const p = new Child();

p.#name; // 报错

console.log(p.name); // 正常读取,输出 小鱼
p.name = '大鱼';
console.log(p.name); // 正常修改, 输出 大鱼

5、父类的静态属性与静态方法也会被子类继承。

class Person{
    static userName = '小鱼';
    static fn() {
        console.log('静态方法被继承');
    }
    }
    
    class Child extends Person { ... }
    Child.userName; // 小鱼
    Child.fn(); // 静态方法被继承

但是,如果父类的静态属性是一个复合类型的对象,那么子类继承父类的静态属性后,会指向同一个内存地址,因为子类在继承父类的静态属性时,采用的是浅拷贝。

class Person{
    static user = {name: '小鱼'};
}

class Child extends Person { ... }
Child.user.name; // 子类继承父类的静态属性, 输出 小鱼 
Child.user.name = '大鱼'; // 子类修改所继承的静态属性
Person.user.name; // 父类的静态属性会被子类修改

6、Object.create() 方法用来创建实例对象时指定其原型对象。Object.getPrototypeOf() 方法则可以用来获取一个实例的原型对象。

const parent = {
    name: '小鱼'
}
const child = Object.create(parent);
Object.getPrototypeOf(child); // {name: '小鱼'}

Object.getPrototypeOf() 方法除了可以用来获取一个实例的原型对象,也可以用来从子类身上获取父类。

class Person {
    name = '小鱼';
}
class Child extends Person { ... }
Object.getPrototypeOf(Child) === Person; // true

7、super 关键字在类中即可以当函数使用,也可以当对象使用,是两种不同的用法。

(1)、当作为函数使用时,代表的是父类的构造函数,且只能在子类的 constructor() 方法中使用,在别的地方使用会报错。ES6 规定在子类继承父类的时候,必须要在子类的构造函数里调用父类的构造方法,即 super() ,用于生成子类的 this 实例对象,也就说 super 虽然代表的是父类的构造函数,但返回的却是子类对象,这表明 super() 内部的 this 指代的就是子类的实例对象。所以,在子类中调用 super() 就相当于执行了 Person.prototype.constructor.call(this)

由于 super() 在执行的时候,子类还没有自己的 this 对象,如果此时父类和子类存在同名属性,拿到的就是父类的属性。

class Person {
    username = '小鱼';
    constructor() {
        console.log(this.username);
    }
}

class Child extends Person {
    username = '大鱼';
    constructor() {
        // 先执行父类的 constructor() 方法,输出 父类的 username 属性 小鱼
        super(); 

        // 子类的 this 已生成,输出子类的 username 属性 大鱼
        console.log(this.username); 
    }

    fn() {
        super(); // 报错 super 当做函数时,只能在子类的 constructor() 方法中使用
    }
}

const p = new Child();

(2)、super() 当对象使用时,在普通方法中,指代父类的原型对象。

class Person {
    username = '小鱼';
    fn() {
        console.log(this.username);
    }
}

class Child extends Person {
    constructor() {
        super();

        // 普通方法中,super 指代父类的原型,即 Person.prototype,所以,能读取到原型方法 fn
        super.fn(); // 输出父类的实例属性 username, 小鱼

        // super 是父类的原型对象,读取不到实例属性
        console.log(super.username); // 输出 undefined
    }
}

而且, super 在子类的普通方法内当对象使用的时候,通过 super 调用的方法,其内部的 this 指代的是子类的实例对象。

class Person {
    username = '小鱼';
    fn() {
        console.log(this.username);
    }
}

class Child extends Person {
    username = '大鱼';
    constructor() {
        super();
    }

    sn() {
        super.fn();
    }
}

const p = new Child();

// super.fn() 相当于 super.fn.call(this), this 指代子类的实例
p.sn(); // 输出 大鱼

在子类的普通方法中,通过 super 对象进行属性赋值时,该属性会被添加到子类的实例身上。

class Person { ... }

    class Child extends Person {
        fn() {
            // 这里的 super 就相当于子类的实例对象 this 
            super.username = '小鱼';
        }
    }
    const p = new Child();
    
    console.log(p.username); // 输出 undefined
    p.fn();
    console.log(p.username); // 输出 小鱼

super 在子类的静态方法中,当对象使用时,指代父类。

class Person {
    static username = '小鱼';
    flag = true;
}
class Child extends Person {
    static fn() {
        console.log(super.username); // 输出 小鱼
        console.log(super.flag); // 输出 undefined
    }
}

// 在子类的静态方法 fn 中,super 指代父类,所以能读取到父类身上的静态属性,而读取不到父类实例的属性
Child.fn();

super 在子类的静态方法中,当对象使用时,其内部的 this 指代子类自己,而不是子类的实例对象。

class Person {
    static username = '小鱼';
    static sn() {
        // 此处的 this 指代 子类 Child
        console.log(this.username);
    }
}
class Child extends Person {
    static username = '大鱼';
    static fn() {
        super.sn(); // 输出 大鱼
    }
}

// 在子类的静态方法 fn 中,super 指代父类,
// super() 内部的 this 指代的是子类,而不是子类的实例对象
Child.fn(); // 输出 大鱼

完。