Javascript中的类 (class)

原文首发 Javascript中的类 (class)

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

创建类

旧方法

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

javascript 复制代码
// 方法一
function Car() {
    this.turn = function(direction) {}
}

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

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

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

  • 太多并不统一、仅依靠 "约定" 的构建方式加重程序员的心智负担,容易产生错误。
  • 从代码角度出发,构造函数和普通函数没有区别:所以我们依然可以将构造函数当作普通函数调用,也可以 new 一个普通函数 (这样做不仅没有什么用,而且常常是错误的根源)。

新方法

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

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

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

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

类的构造函数 (constructor)

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

  • JavaScript 为每个类都提供了一个默认的构造函数。

  • 可以通过实现特殊的 constructor() 方法来覆盖默认的构造函数。

    • constructor() 可以接受任意数量的参数,其函数体内可以初始化字段或者执行操作。
    • 使用 new 关键字创建实例时会自动调用 constructor() 方法,只有这种方式能调用 constructor() 方法。
    • 如果一个类没有别的事情要做,就不用写 constructor() 方法,默认的构造函数就足够了。

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

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

类的方法 (Method)

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

kotlin 复制代码
class Car {
    constructor(year) {
        this.year = year
        this.miles = 0
    }
    
    drive(distance) {
        this.miles += distance
    }
}

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

词法作用域:Lexical Scope in JavaScript

类的 "计算成员" (Computed Members)

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

kotlin 复制代码
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 关键字)。一个属性可以是可读的、可写的,或者兼而有之。

javascript 复制代码
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)

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

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

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

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

javascript 复制代码
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),两者的区别是:

  • 在定义类的语句时,类名不能省略;但类表达式可以省略类名。
  • 类的表达式应该被当作表达式使用 - 即,应该被用于从函数中返回,作为参数传递,保存到变量中,等等。
javascript 复制代码
// 类语句即我们常规定义类的方式
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 时),这时候给类一个名称是可以的,但这个名称只能在类内部使用。

javascript 复制代码
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

相关推荐
迷雾漫步者31 分钟前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-1 小时前
验证码机制
前端·后端
燃先生._.2 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖3 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235243 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240254 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar4 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人5 小时前
前端知识补充—CSS
前端·css
GISer_Jing5 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试