class

45 阅读11分钟

ES6 里引入Class 概念,但实际上它背后使用的仍是原型和构造函数的概念。

ES6 中类的继承,和ES5 原型链继承略有不同。

类定义

【类声明】

要声明一个类,可以使用带有class 关键字的类名。

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

【类表达式】

类表达式可以命名或不命名。

 // 未命名/匿名类
 const Rectangle = class {
     constructor(height, width) {
         this.height = height
         this.width = width
     }
 }
 console.log(Rectangle.name) // output: "Rectangle"
 ​
 // 命名类
 const Rectangle = class Rectangle2 {
     constructor(height, width) {
         this.height = height
         this.width = width
     }
 }
 console.log(Rectangle.name) // 输出: "Rectangle2"

在把类表达式赋值给变量后,可以通过name 属性取得类表达式的名称字符串。

 const Person = class PersonName {
     identify() {
         console.log(Person.name, PersonName.name)
     }
 }
 const p = new Person()
 p.identify() // PersonName PersonName

类构造函数

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

构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。

实例化

 class Person {
     constructor() {
         console.log('person ctor')
     }
 }

使用new 操作符实例化Person 的操作等于使用new 调用其构造函数。

使用new 调用类的构造函数会执行如下操作。

1、在内存中创建一个新对象

2、这个新对象内部的[[Prototype]] 指针被赋值为构造函数的prototype 属性

3、构造函数内部的this 指向新对象

4、执行构造函数内部的代码(给新对象添加属性和方法)

5、如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的。

类构造函数与构造函数的主要区别:调用类构造函数必须使用new 操作符。而普通构造函数如果不使用new 调用,就会以全局的this(通常是window)作为内部对象。调用类构造函数时如果忘了使用new 则会抛出错误。

把类当成特殊函数

声明以一个类之后,通过typeof 操作符检测类标识符,表明它是一个函数。

类标识符有prototype 属性,而这个原型也有一个constructor 属性指向类自身。

 class Person {}
 console.log(typeof Person) // function
 console.log(Person === Person.prototype.constructor) // true

类本身在使用new 调用时就会被当成构造函数。类中定义的constructor 方法不会被当成构造函数,在对它使用instanceof 操作符时会返回false。

 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

实例成员

每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享。

原型方法与访问器

为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。

类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键。

类定义也支持获取和设置访问器。语法与行为跟普通对象一样。

静态类方法

静态类成员在类定义中使用static 关键字作为前缀。在静态成员中,this 引用类自身。其他所有约定跟原型成员一样。

静态类方法非常适合作为实例工厂:

 class Person {
     constructor(age) {
         this.age_ = age
     }
     sayAge() {
         console.log(this.age_)
     }
     static create() {
         return new Person(Math.floor(Math.random()*100))
 }
 // ES6 写法
 class Vehicle {
     // 构造函数
     // constructor 方法是一个特殊的方法,这种方法用于创建和初始化一个由class 创建的实例对象
     // 一个类只能拥有一个名为constructor 的特殊方法。如果类包含多个constructor 的方法,则将抛出SyntaxError。
     // 一个构造函数可以使用 super 关键字来调用一个父类的构造函数
     constructor(name) {
         this.name = name // this 关键字代表实例对象,即指向即将要生成的实例对象
     }
     // 实例方法
     // 不需要加上function 这个关键字,直接把函数定义放进去就行,方法与方法之间不需要逗号分隔,加了会报错
     ignition() {
         console.log("Start up:" + this.name)
     }
     // 静态方法
     // 加上static 关键字,就表示该方法不会被实例继承,而是直接通过类来调用,而不是在类的实例上调用。
     // 如果在实例上调用静态方法,会抛出一个错误,表示不存在该方法。
     static getType(){
         console.log("Type: Vehicle")
     }
     static getName() {
         console.log("Name: Vehicle")
     }
 ​
     // 静态变量(ES7 语法)
     static defaultProps = {color: "red"}
 ​
     // 存储器属性
     // 与 ES5 一样,在“类”的内部可以使用get 和set 关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为。
     set prop(value){
         console.log("setter:" + value)
         this.value= value
     }
     
     get prop(){
         return this.value
     }
 }
 ​
 const vehicle = new Vehicle ('vehicle') // 实例
 console.log(vehicle) // Vehicle { name: 'vehicle' }
 console.log(vehicle.name) // vehicle
 vehicle.prop= 1 // setter: 1
 console.log(vehicle.prop) // 1
 vehicle.ignition() // "Start up:vehicle"
 ​
 // ES6 的类,完全可以看作构造函数的另一种写法
 console.log(typeof Vehicle) // "function"
 console.log(Vehicle === Vehicle.prototype.constructor) // true
 ​
 ​
 ​
 // 类Vehicle 的原型对象Vehicle.prototype 有一个constructor 属性指向Vehicle,在类Vehicle 中定义的方法是其原型对象的方法。
 ​
 // 静态方法调用
 console.log(Vehicle.getName()) // 通过类直接调用 => Name: Vehicle
 console.log(vehicle.getName()) // 类的实例 => TypeError: vehicle.getName is not a function
 // ES5 写法
 // 构造函数
 function Vehicle (name) {
     this.name = name
 }
 // 实例方法
 Vehicle.prototype.ignition = function () {
     console.log("Start up:" + this.name)
 }
 // 静态方法
 Vehicle.getType = function (){
     console.log("Type: Vehicle")
 }
 Vehicle.getName= function (){
     console.log("Name: Vehicle")
 }
 // 静态变量
 Vehicle.defaultProps = {color: "red"}
 ​
 console.log(Object.keys(Vehicle.prototype)) // ignition 方法可枚举 => ['ignition']
 console.log(Object.getOwnPropertyNames(Vehicle.prototype)) // ['constructor', 'ignition']

image-20230226194440439

【用class 声明的类的特点】

1、所有方法都定义在prototype 对象上面。因此,类的新方法可以添加在prototype 对象上面

 class Point {
     constructor() {}
     toString() {}
     toValue() {}
 }
 ​
 ​
 // Object.assign() 方法可以很方便地一次向类添加多个方法。
 // Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象分配到目标对象。它将返回目标对象。
 ​
 Object.assign(Point.prototype, {
     toString(){},
     toValue(){}
 })

2、类的内部所有定义的方法,都是不可枚举的(non-enumerable),这一点与 ES5 的行为不一致。

Object.keys() 方法会返回一个由一个给定对象的自身可枚举属性组成的数组,数组中属性名的排列顺序和正常循环遍历该对象时返回的顺序一致 。

Object.getOwnPropertyNames() 方法返回一个由指定对象的所有自身属性的属性名(包括不可枚举属性但不包括Symbol 值作为名称的属性)组成的数组。

 console.log(Object.keys(Vehicle.prototype)) // []
 ​
 console.log(Object.getOwnPropertyNames(Vehicle.prototype)) // ['constructor', 'ignition', 'prop']
 ​
 console.log(Object.keys(Point.prototype)) // ['toString', 'toValue']

3、与 ES5 一样,类的所有实例共享一个原型对象。

 const p1 = new Point(2,3)
 const p2 = new Point(3,2)
 ​
 p1.__proto__ === p2.__proto__  //true
 ​
 e.g.
 const p1 = new Point(2,3)
 const p2 = new Point(3,2)
 ​
 p1.__proto__.printName = function () { return 'Oops' }
 ​
 p1.printName() // "Oops"
 p2.printName() // "Oops"
 ​
 const p3 = new Point(4,2)
 p3.printName() // "Oops"
 ​
 // p1 和p2 都是Point 的实例,它们的原型都是Point.prototype,所以__proto__ 属性是相等的。
 // 这也意味着,可以通过实例的__proto__ 属性为“类”添加方法。
 ​
 // __proto__ 并不是语言本身的特性,这是各大厂商具体实现时添加的私有属性。
 // 虽然目前很多现代浏览器的 JS 引擎中都提供了这个私有属性,但依旧不建议在生产中使用该属性,避免对环境产生依赖。
 ​
 // 生产环境中,可以使用 Object.getPrototypeOf 方法来获取实例对象的原型,然后再来为原型添加方法/属性。

image-20230226200118169

4、ES6 把整个语言升级到了严格模式。

5、class不存在变量提升,必须保证子类在父类之后定义。

类的继承

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

 // 创建Vehicle 的子类Car
 class Car extends Vehicle {
     constructor(name){
         super(name)
         this.wheels = 4
     }
     // 实例方法
     drive(){
         this.ignition()
         console.log("Rolling on all "+this.wheels+"wheels!")
     }
     // 静态方法
     static getType(){
         console.log("Type: Car")
     }
 }
 ​
 const car = new Car('smart')
 car.drive() // Start up:smart // Rolling on all 4 wheels!
 // 类实例无法访问静态方法
 console.log(car.getType) //undefined
 // 只有类本身可以调用静态方法
 Car.getType() //Type: Car

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

 class Vehicle {
     identifyPrototype(id) {
         console.log(id, this)
     }
     static identifyClass(id) {
         console.log(id, this)
     }
 }
 ​
 class Bus extends Vehicle {}
 ​
 const v = new Vehicle()
 const b = new Bus()
 ​
 b.identifyPrototype('bus') // bus, Bus {}
 v.identifyPrototype('vehicle') // vehicle, Vehicle {}
 ​
 Bus.identifyClass('bus') // bus, class Bus {}
 Vehicle.identifyClass('vehicle') // vehicle, class Vehicle {}
 ​

super 关键字

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

 // 在类构造函数中使用super 可以调用父类构造函数
 class A {}
 ​
 class B extends A {
     constructor() {
         super() // A.prototype.constructor.call(this)
     }
 }
  • 调用super() 会调用父类构造函数,并将返回的实例赋值给this

     class Vechicle {}
     class Bus extends Vehicle {
         constructor() {
             super()
             console.log(this instanceof Vehicle)
         }
     }
     new Bus() // true
    
  • 如果没有定义类构造函数,在实例化派生类时会调用super(),而且会传入所有传给派生类的参数。

  • 在类构造函数中,不能在调用super() 之前引用this。

  • 如果在派生类中显式定义了构造函数,要么必须在其中调用super(),要么必须在其中返回一个对象。

 class A {
     p() { return 2 }
 }
 ​
 class B extends A {
     constructor() {
         super()
         console.log(super.p()) // 2
     }
 }
 ​
 const b = new B()
 ​
 // 子类B 当中的super.p(),就是将super当作一个对象使用。super 在普通方法之中,指向A.prototype,所以super.p() 就相当于A.prototype.p()。
 ​
 // 由于super 指向父类的原型对象,所以定义在父类实例上的方法或属性,是无法通过super 调用的。
 class A {
     constructor() { this.p = 2 }
 }
 ​
 class B extends A {
     get m() { return super.p }
 }
 ​
 const b = new B() // p 是父类A 实例的属性
 b.m // undefined
 ​
 ​
 // ES6 规定,在子类普通方法中通过super 调用父类的方法时,方法内部的this 指向当前的子类实例。
 class A {
     constructor() {
         this.x = 1
     }
     print() {
         console.log(this.x)
     }
 }
 ​
 class B extends A {
     constructor() {
         super()
         this.x = 2
     }
     m() {
         super.print()
     }
 }
 ​
 const b = new B()
 b.m() // 2
 ​
 // super.print()虽然调用的是A.prototype.print(),但是A.prototype.print()内部的this 指向子类B的实,实际上执行的是super.print.call(this)。
 ​
 // 由于this 指向子类实例,所以如果通过super对某个属性赋值,这时super就是this,赋值的属性会变成子类实例的属性。
 class A {
     constructor() {
         this.x = 1
     }
 }
 ​
 class B extends A {
     constructor() {
         super()
         this.x = 2
         super.x = 3
         console.log(super.x) // undefined
         console.log(this.x) // 3
     }
 }
 ​
 const b = new B()
 ​
 // super.x 赋值为3,这时等同于对this.x 赋值为3。而当读取super.x 的时候,读的是A.prototype.x,所以返回undefined。
 // 在子类的静态方法中通过super 调用父类的方法时,方法内部的this 指向当前的子类,而非子类实例
 class Parent {
     static myMethod(msg) {
         console.log('static', msg)
     }
 ​
     myMethod(msg) {
         console.log('instance', msg)
     }
 }
 ​
 class Child extends Parent {
     static myMethod(msg) {
         super.myMethod(msg)
     }
 ​
     myMethod(msg) {
         super.myMethod(msg)
     }
 }
 ​
 Child.myMethod(1) // static 1
 ​
 const child = new Child()
 child.myMethod(2) // instance 2

抽象基类

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

new.target 保存通过new 关键字调用的类或函数。通过在实例化时检测new.target 是不是抽象基类,可以阻止对抽象基类的实例化。

通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法。因为原型方法在调用构造函数之前就已经存在了,所以可以通过this 关键字来检查相应的方法。

 class Vehicle {
     constructor() {
         console.log(new.target)
         if (new.target === Vehicle) {
             throw new Error('Vehicle cannot be directly instantiated')
         }
         if (!this.foo) {
             throw new Error('Inheriting class must define foo()')
         }
     }
 }
 ​
 // 派生类
 class Bus extends Vehicle {
     foo() {}
 }
 ​
 class Van extends Vehicle {}
 ​
 new Bus()
 new Vehicle()
 new Van()

ES6 继承 vs ES5

ES5 的继承:先创建子类实例,然后将父类原型上的方法添加到子类实例上。

实质是先创造子类的实例对象this,然后再将父类的方法添加到this 上面 => Parent.apply(this)

ES6 的继承:先创建父类实例,然后子类实例this 指向父类this,并且进行修改。

实质是先将父类实例对象的属性和方法,加到this上面 => 所以必须先调用super 方法

 class Point {
     constructor(x, y) {
         this.x = x
         this.y = y
     }
 }
 ​
 class ColorPoint extends Point {
     constructor(x, y, color) {
         this.color = color // ReferenceError
         super(x, y)
         this.color = color // 正确
     }
 }
 ​
 let cp = new ColorPoint(25, 8, 'green')
 cp instanceof ColorPoint // true
 cp instanceof Point // true

正因为ES6 做继承时,是先创建父类实例,所以有如下特性:

  • 继承时,子类一旦显性声明了constructor函数,就必须在构造函数里面调用super()方法,从而取得父类this对象;也可以不显性声明constructor 函数,ES6 会在默认生成的构造方法里面调用super()。
  • 允许定义继承原生数据结构(Array, String等)的子类
 class myArray extends Array{
     constructor(...args) {
         super(...args)
     }
 }
 ​
 let array = new myArray()
 array.push(2)
 array.length // 1
 array[0] // 2

Object.getPrototypeOf()

用来从子类上获取父类,可以使用这个方法判断一个类是否继承了另一个类。

 Object.getPrototypeOf(ColorPoint) === Point // true

面试题

要求设计 LazyMan 类

 LazyMan('Tony')
 // Hi I am Tony
 ​
 LazyMan('Tony').sleep(10).eat('lunch')
 // Hi I am Tony
 // 等待了10秒...
 // I am eating lunch
 ​
 LazyMan('Tony').eat('lunch').sleep(10).eat('dinner')
 // Hi I am Tony
 // I am eating lunch
 // 等待了10秒...
 // I am eating diner
 ​
 LazyMan('Tony').eat('lunch').eat('dinner').sleepFirst(5).sleep(10).eat('junk food')
 // Hi I am Tony
 // 等待了5秒...
 // I am eating lunch
 // I am eating dinner
 // 等待了10秒...
 // I am eating junk food
 class LazyManClass {
     constructor(name) {
         this.taskList = []
         console.log(`Hi I am ${name}`)
         setTimeout(() => {
             this.next()
         }, 0)
     }
     eat(food) {
         this.taskList.push(
             () => {
                 console.log(`I am eating ${food}`)
                 this.next()
             }
         )
         return this
     }
     delay(sec) {
         return () => {
             setTimeout(() => {
                 console.log(`等待了${sec}秒`)
                 this.next()
             }, sec * 1000)
         }
     }
     sleep(sec) {
         this.taskList.push(this.delay(sec))
         return this
     }
     sleepFirst(sec) {
         this.taskList.unshift(this.delay(sec))
         return this
     }
     next() {
         const fn = this.taskList.shift()
         fn && fn()
     }
 }
 ​
 function LazyMan(name) {
     return new LazyManClass(name)
 }
 // LazyMan('Tony')
 // LazyMan('Tony').sleep(10).eat('lunch')
 LazyMan('Tony').eat('lunch').eat('dinner').sleepFirst(5).sleep(10).eat('junk food')