logo

Javascript 中的类 (class)

JavaScript 自古以来支持类,只是从前没有 class 关键字,显得很不专业。本文总结 ES6 以来 JavaScript 从语法层面对面向对象的支持,了解现代 JavaScript 如何支持我们写出漂亮的 OOP 代码。

创建类

旧方法

从前,要在 JavaScript 中创建一个类,需要写一个构造函数,这个构造函数和其他普通函数看起来没有任何区别。为了人为区分构造函数和普通函数,坊间约定 (只是约定):构造函数的函数名以大写字母开头。以下是在 JavaScript 中构建类的几种过时的方式:

// 方法一
function Car() {
    this.turn = function(direction) {}
}

// 方法二
function Car() {}
Car.prototype.turn = function(direction) {}

// 方法三
function Car() {}
Car.turn = function(direction) {}

这些方式的缺点显而易见:

新方法

ES6 带来了 class 关键字来定义构造函数,这个优点是很明显的:看到 class 就知道是在定义一个构造函数/类。

不过虽然使用了 class 语法,但本质上我们还是在定义一个函数,一个只能用 new 调用的函数。而且 class 定义的类的另一个好处是不会被提升 (hoist) ,类只有在定义之后才可以被使用,而老款的方式在类定义之前就可以被使用。

// 类的本质就是函数
class Car {}
console.log(typeof Car) // function

// 老式方法在函数定义之前就可以被访问
console.log(Car1) // undefined
function Car1() {}

类的构造函数 (constructor)

JavaScript 的构造函数有一些不太引人注意的细节:

注意保持 constructor() 方法短小精悍、快速执行,毕竟,我们不希望在创建对象时很慢。

// 每个类默认都带有一个构造函数
class Car {}
console.log(Reflect.ownKeys(Car.prototype)) // [ 'constructor' ]

类的方法 (Method)

定义类的方法就是定义匿名函数,然后把 function 改成方法名。

class Car {
    constructor(year) {
        this.year = year
        this.miles = 0
    }
    
    drive(distance) {
        this.miles += distance
    }
}

方法可以访问和修改类的任何字段,以及执行操作,也可以访问作用域内的任何变量和方法,包括类中定义的实例方法 (但需要使用 this,如果没有 this,JavaScript 会在词法作用域内寻找方法名,找不到会报运行时错误)。

词法作用域:Lexical Scope in JavaScript

类的 “计算成员” (Computed Members)

类支持动态定义成员的名字 (成员包括:字段、属性、方法),只需要把相应的变量放入 [] 即可。除了在类内定义之外,还可以直接在实例上定义计算成员。

const NYD = "New Year's Day"

class Holidays {
    constructor() {
        this[NYD] = 'January 1'
        this["Valentine's Day"] = 'February 14'
    }
    
    ['list holidays']() {
        return Object.keys(this)
    }
}

const newHoliday = new Holidays()
newHoliday['list holidays']() // [ "New Year's Day", "Valentine's Day" ]

// 直接在实例上定义计算字段,只属于本实例
newHoliday['another holiday'] = 'July 4'

类的属性 (Properties)

属性是一个别致的存在:访问时像字段,但定义时像方法 (只是在方法名前添加 getset 关键字)。一个属性可以是可读的、可写的,或者兼而有之。

class Car {
    constructor(year) {
        this.year = year
        this.miles = 0
    }

    drive(distance) {
        this.miles += distance
    }
    
    // 定义可读属性时不允许传递任何参数
    get age() {
        return new Date().getFullYear() - this.year
    }
    
    get distanceTraveled() { return this.miles }
    // 定义可写属性时,只能传递一个参数,不能多也不能少
    set distanceTraveled(value) {
        if (value < this.miles) {
            throw new Error('cannot set value less than current')
        }
        this.miles = value
    }
}

const car = new Car(2007)

// 访问属性看起来像是访问字段
car.age // 17

// 由于我们没有设置 age 属性为可写,所以写操作无效,但也不会提示错误
// 如果使用 'use strict' 启用严格模式,会明确报错:`Cannot set property ...`
car.age = 10
car.age // 17

car.distanceTraveled // 0
car.distanceTraveled = 100
car.distanceTraveled // 100

可写属性可以用来在修改字段前做一些检查或者验证。

ES2022 添加了 Private properties,在字段或方法前添加 # 可使其仅在类内访问。

类级成员 (Class Members)

类级成员就是使用类直接访问的成员,实例不能访问,代码解释如下:

// 实例方法即实例直接调用的方法
const car = new Car() // 先创建一个实例
car.drive(10) // 实例方法

// 类方法即使用类直接调用的方法,如 isArray 不特定于某一个数组,而是直接在 Array 类上作用
Array.isArray([]) // true

字段、属性、方法都可以在类级定义,只需要在其前面添加 static 关键字。

class Car {
    constructor(year) {
        this.year = year
        this.miles = 0
    }

    drive(distance) { this.miles += distance }
    get age() { return new Date().getFullYear() - this.year }

    get distanceTraveled() { return this.miles }
    set distanceTraveled(value) {
        if (value < this.miles) {
            throw new Error('cannot set value less than current')
        }
        this.miles = value
    }
    
    // 定义类字段
    static distanceFactor = 0.01 
    
    // 定义类属性
    static get ageFactor() { return 0.1 }
    
    // 定义类方法
    static pickBetter(car1, car2) {
        const score = car =>
            car.age * Car.ageFactor + car.distanceTraveled * Car.distanceFactor
        
        return score(car1) < score(car2) ? car1 : car2
    }
}

const car1 = new Car(2007)
car1.drive(150000)

const car2 = new Car(2010)
car2.drive(175000)

console.log(Car.pickBetter(car1, car2)) // Car { year: 2007, miles: 150000 }

上述代码在定义 pickBetter() 方法时,使用了 Car.ageFactor 而不是 this.ageFactor 来访问类的属性,是因为 JavaScript 中的 this 是动态作用域,如果我们想要指定的 this 是当前类,那么这种直接使用当前类的类名的写法更安全,避免 this 被绑定到别的对象。

类作为表达式 (Class Expressions)

类作为表达式对于需要在运行时动态创建类时很有用。JavaScript 同时支持类语句 (class statement) 和类表达式 (class expression),两者的区别是:

// 类语句即我们常规定义类的方式
class Car {}

// 定义一个函数作为类的工厂
const createClass = function(...fields) {
    // 返回类表达式
    return class {
        constructor(...values) {
            fields.forEach((field, index) => this[field] = values[index])
        }
    }
}

// 调用此函数创建的类是匿名的,我们可以给予它任何名称
const Actor = createClass('firstName', 'lastName', 'age')
const fisher = new Actor('Carrie', 'Fisher', 20)

// 由于类在创建时没有名字,所以实例的输出结果前面没有类名,就像是一个普通的 JavaScript 对象
console.log(Actor) // [class (anonymous)]
console.log(Car) // [class Car] { distanceFactor: 0.01 }
console.log(fisher) // { firstName: 'Carrie', lastName: 'Fisher', age: 20 }

有时候在创建类表达式时,我们想要在类内引用类的名称 (比如上面使用 static 时),这时候给类一个名称是可以的,但这个名称只能在类内部使用。

const Movie = class Show {
    constructor() {
        console.log(`creating instance...`)
        console.log(Show) // Show 只能在类内使用
    }
}

console.log(Movie) // [class Show]
console.log(Show) // Uncaught ReferenceError: Show is not defined

总结

现代 JavaScript 带来了面向对象应有的一切。总体上,类相关的概念有:字段 (field),属性 (property),方法 (method),计算成员 (computed member,使用 []),类成员 (class member,使用 static),概念本身并不复杂。

如果有不清晰或文中存在过时概念,可以参考 MDN - Classes