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 会在词法作用域内寻找方法名,找不到会报运行时错误)。
类的 "计算成员" (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)
属性是一个别致的存在:访问时像字段,但定义时像方法 (只是在方法名前添加 get
,set
关键字)。一个属性可以是可读的、可写的,或者兼而有之。
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。