细读 ES6 | Class 上篇

简介: 今天仔细看下 ES6 中的 Class 语法。

前言


今天仔细看下 ES6 中的 Class 语法。


正文


一、简介


1. 类的由来


在 JavaScript 中,生成实例对象的传统方法是通过构造函数。

function Point(x, y) {
  this.x = x
  this.y = y
}
Point.prototype.toString = function() {
  return '(' + this.x + ', ' + this.y + ')'
}
var p = new Point(1, 2)


上面这种写法,跟传统的面向对象语言(比如 C++、Java)差异很大,很容易让新学习这门语言的程序员感到困惑。

在 ES6 提供了更接近传统语言的写法,引入了 Class(类)这个概念,作为对象的模板。通过 class 关键字,可以定义类。

基本上,ES6 的 class 可以看作只是一个语法糖,它的绝大部分功能,ES5 都可以做到,全新的 class 写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。


上面示例,可以使用 ES6 的 class 改写为:

class Point {
  constructor(x, y) {
    this.x = x
    this.y = y
  }
  toString() {
    return '(' + this.x + ', ' + this.y + ')'
  }
}


上面的示例定义了一个“类”,可以看到里面有一个 constructor() 方法,这就是构造方法,而 this 关键字则代表实例对象。这种全新的 Class 写法,本质上与开头的 ES5 的构造函数 Point 是一致的。

Point 类除了构造方法,还定义了一个 toString() 方法。注意,定义了 toString() 方法的时候,前面不需要加上 function 这个关键字,直接把函数定义放进去就可以了。另外,方法与方法之间不需要逗号 , 分隔,加了会报错。

ES6 的类,完全可以看作构造函数的另一种写法。

class Point {
  // ...
}
console.log(typeof Point) // "function"
Point.prototype.constructor === Point // true
// 上面代码表明,类的数据类型就是函数,类本身就指向构造函数。


使用的时候,也是直接对类使用 new 关键字,跟构造函数的用法完全一致。还有,当实例化不指定参数列表时,new Point() 等同于 new Point


与 ES5 有一点区别的是,类不能直接当作函数一样调用,即 Point() 是会抛出错误的:TypeError: Class constructor Point cannot be invoked without 'new'。而 ES5 中,若构造函数不使用 new 关键字进行实例化,而是直接当作函数调用是没问题的。

class Bar {
  doStuff() {
    console.log('stuff')
  }
}
const b = new Bar()
b.doStuff() // "stuff"


构造函数的 prototype 属性,在 ES6 的“类”上依然存在。事实上,类的所有方法都定义在类的 prototype 属性上面。我们在控制台打印下 point 实例对象:

class Point {
  constructor() {}
  toString() {}
  toValue() {}
}
const point = new Point()


上面的示例中,constructor()toString()toValue() 这三个方法,其实都是定义在 Point.prototype 上面。

20.webp.jpg

point.constructor === Point.prototype.constructor // true

上面的示例中,pointPoint 类的实例,它的 constructor() 方法就是 Point 类原型的 constructor() 方法。


小结:


  • 在 Class 内部定义的方法,尽管与 ES5 一样最终都是挂载在 prototype 上的,但这些方法是不可枚举的。这一点与 ES5 的行为不一致。
  • 在 Class 内部定义的属性,则是挂载在实例对象上的。


二、constructor


constructor() 方法是类的默认方法,通过 new 关键字实例化对象是,内部会自动调用该方法。一个类必须有 constructor() 方法。当你定义一个类时,若无显式定义,会自动添加一个空的默认 constructor() 方法(由 JS 引擎自动添加),即:

class Point {}
// 相当于
class Point {
  constructor() {}
}


constructor() 方法默认返回实例对象(即 this),亦可返回任意一个对象(引用类型的值)。

class Point() {
  constructor() {
    return Object.create(null)
    // 1. 若不显式 return 的话,默认返回 this
    // 2. 显式返回只能是引用值(即对象),若是原始值是无效的,此时仍然是返回 this。
    // 3. 以上两点,跟 ES5 实现构造方法表现是一致的。
    // 4. 一般情况,无需定义显式 return。
  }
}
const point = new Point()
console.log(point instanceof Point) // false


上面示例中,constructor() 返回了一个全新对象,导致了 point 对象并不是 Point 的实例对象。


三、类的实例


上面提到,Class 不能当做函数直接调用,否则会抛出语法错误的。正确地,应使用 new 关键字进行实例化。

class Point {}
// 正确
const p1 = new Point()
// 错误
const p2 = Point() // Uncaught TypeError: Class constructor Point cannot be invoked without 'new'


在 Class 中,如何定义属性和方法?那它们是挂载到实例对象,还是类的原型上?


下面我们来看看吧:

class Point {
  // 这样定义属性,也是挂载到实例对象的,并非挂载到 Point.prototype 上的哦
  z = 0
  constructor(x, y) {
    // 通过如下 this.xxx 的形式,可以显式地为实例对象增删属性和方法
    this.x = x // 
    this.y = y
    this.show = () => {
      return `The point is (${this.x}, ${this.y}).`
    }
    this.tmp = "It's temporary property."
    delete this.tmp
  }
  // 类似如下 setX、setY、setZ 等定义类的方法,它们最终是挂载到 Point.prototype,并非实例对象
  setX(x) {
    this.x = x
  }
  setY(y) {
    this.y = y
  }
  setZ(z) {
    this.z = z
  }
}
// 既然上面的方式定义属性,都挂载到实例对象上,
// 那怎样给 Point.prototype 添加“属性”呢?
// 只能利用 Point.prototype.xxx 了,像这样:
Object.assign(Point.prototype, {
  prop: 'haha',
  method: function () {}
})
const point = new Point(1, 10)


我们来打印一下 point 实例对象,一目了然:


13.webp.jpg


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

const p1 = new Point(1, 1)
const p2 = new Point(2, 2)
Object.getPrototypeOf(p1) === Object.getPrototypeOf(p2) // true


因此,不建议在实例中,利用 __proto__ 去改写原型,它会改变类的定义,进而影响到该类的所有实例。

// ❌ 以下做法不被推荐
const p1 = new Point(1, 1)
const p2 = new Point(2, 2)
p1.__proto__.print = function () {
 console.log('Oops')
}
p2.print() // "Oops"


请注意,以下这种写法及其结果。

class Point {
  fn() {
    console.log(1)
  }
}
// 在执行到这里时 class 内部的 fn 已经完成挂载到 Point.prototype 上,
// 因此下面会把原先原型上的 fn 方法覆盖
Point.prototype.fn = function() {
  console.log(2)
}
const p = new Point()
p.fn() // 2


四、setter、getter


在 JavaScript 中,我们可以借助 settergetter 语法,以安全的方式来访问对象的属性。使用 getter 可以访问属性值,而 setter 可以修改属性值。

// 本例的 setter、getter 设计在实际中并无意义,
// 这里只是为了举例而举例罢了。
class Point {
  constructor(x, y) {
    this.x = x
    this.y = y
  }
  set prop(x) {
    console.log('setter:', x)
    this.x = x
  }
  get prop() {
    console.log('getter:', this.x)
    return this.x
  }
}
const point = new Point(1, 10)
point.prop = 100 // setter: 100
point.prop // gettter: 100


上面示例中,prop 属性有对应的存值函数和取值函数,因此存取行为都被自定义了。还有 gettersetter 方法是设置在属性的 Descripter 对象上的。


五、属性表达式


类的属性名,可以采用表达式,即计算属性名。

let methodName = 'getX'
class Point {
  [methodName]() {
    // ...
  }
}
// 访问
const point = new Point()
point[methodName] // or point.getX


六、类的表达方式


类内部是在严格模式下运行的。


类可以这样定义:

// 1️⃣ 类声明
class Foo {
  constructor() {}
}
// 2️⃣ 匿名类表达式(匿名类,就像匿名函数表达式一样)
const Foo = class {
  constructor() {}
}
// 3️⃣ 具名类表达式
const Foo = class NamedFoo {
  constructor() {
    // 在内部,可以使用 NamedFoo 或 Foo 访问类的属性或(静态)方法。
    // 但是,在类的外部只能使用 Foo,不能使用 NamedFoo。
    // 若内部无需使用到 NamedFoo,则可以使用匿名的方式。
    console.log(NamedFoo.name) // "NamedFoo"
    console.log(Foo.name) // "NamedFoo"
  }
}
Foo.name // "NamedFoo"
NamedFoo.name // ReferenceError: NamedFoo is not defined


以上三种类的表达方式,可以对应上:函数声明、匿名函数表达式、(具名)函数表达式,这点是相同的。


还有,利用“类表达式”的形式,可以写出立即执行的 Class,这点与函数表达式是相同的。

// 此时 foo 就是类的实例对象
const foo = new class {
  constructor(name) {
    this.name = name
  }
}('Frankie')
foo.name // "Frankie"


以上三种方式都可以定义一个类,但需要注意的是:

// 1. 重复声明一个类会抛出类型错误。
// 在这点上,class 与 let、const 表现是一致的,均不可重复声明。
class Foo {}
class Foo {} // Uncaught TypeError: Identifier 'Foo' has already been declared
// 2. class 同样不会“提升”(Hoisting),
// 因此实例化之前,一定要先声明类,否则会抛出引用错误。
const foo = new Foo() // ReferenceError: Cannot access 'Foo' before initialization
class Foo {}


七、注意点


  1. 严格模式
    在类和模块的内部,默认就是严格模式,无需通过 use strict 来指定,也仅有严格模式可用。
  2. 提升问题
    刚才提到使用 class 关键字声明的类,不存在“提升” (Hoisting)问题。这种规定的原因与类的继承有关,必须保证子类在父类之后定义。
  3. name 属性
    本质上,ES6 的类只是 ES5 的构造函数的一层包装,所以函数的许多特性都被 class 继承,包括 name 属性。它总是返回 class 关键字后面的类名,若是匿名类表达式声明,则返回变量名。

class Foo {}
const Bar = class {}
const B = class Baz {}
console.log(Foo.name) // "Foo"
console.log(Bar.name) // "Bar"
console.log(B.name) // "Baz"


  1. Generator 方法
    如果在某个方法之前加上星号(*),则表示该方法是一个 Generator 函数。
    以下示例中,Foo 类的 Symbol.iterator 方法就是一个 Generator 函数。Symbol.iterator 方法返回一个 Foo 类的默认遍历器,for...of循环会自动调用这个遍历器。

class Foo {
  constructor(...args) {
    this.args = args
  }
  *[Symbol.iterator]() {
    for (let arg of this.args) {
      yield arg
    }
  }
}
const foo = new Foo('Hello', 'World')
for (let x of foo) {
  console.log(x)
}
// "Hello"
// "World"


  1. this 指向
    类的方法内部如果含有 this,它默认指向类的实例。但是,必须非常小心,一旦单独使用该方法,很可能会报错。注意,如果是静态方法内,this 指向类本身。

class Point {
  constructor(x, y) {
    this.x = x
    this.y = y
  }
  // static classMethod() {
  //   return this // this 指向 Point 本身
  // }
  getX() {
    return this.x
  }
}
const point = new Point(1, 10)
console.log(point.getX()) // 1
const { getX } = point
getX() // TypeError: Cannot read property 'x' of undefined


在上述示例中,getX 方法的 this 默认指向 Point 实例对象。但是,如果将这个方法提取出来单独使用,this 会指向该方法运行时所在的环境(由于 class 内部是严格模式,所以 this 实际指向的是 undefined),导致找不到 getX 方法而报错。


解决方法如下:

// 解决方法一
class Point {
  constructor(x, y) {
    this.x = x
    this.y = y
    this.getX = this.getX.bind(this) // 构造函数中绑定实例对象
  }
  getX() {
    return this.x
  }
}
// 解决方法二
class Point {
  constructor(x, y) {
    this.x = x
    this.y = y
  }
  // 这写法,相当于在 constructor 中定义了:
  // this.getX = () => { /* ... */ }
  getX = () => {
    return this.x
  }
}
// 注意,两者还是有区别的:
// 1. 两种解决方法,都会在 Point 的实例对象上,定义了一个 getX 方法。
// 2. 解决方法一,除了在实例对象上含有 getX 方法,在其实例对象的原型上也有一个 getX 方法。
// 3. 而解决方案二,其实只会将 getX 挂载到实例对象上,而原型上是没有的。
// 4. 以上的区别,其实上面的内容都有提到,如还不太清楚,建议回头再看看。


八、静态方法


类相当于实例的原型,所有在类中定义的方法,都会被实例继承。如果在一个方法前,加上 static 关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这个就被称为“静态方法”

class Foo {
  static classMethod() {
    console.log('Hello World!')
  }
}
// Correct
Foo.classMethod() // "Hello World!"
// Wrong
const foo = new Foo()
foo.classMethod() // TypeError: foo.classMethod is not a function


上述示例中,Foo 类的 classMethod 方法前有 static 关键字,表示该方法是一个

静态方法,可以直接在 Foo 类上调用,而不是在 Foo 类的实例对象上调用。若通过实例对象调用静态方法,会抛出错误,因为实例对象上并没有 classMethod 方法。

注意,静态方法内 this 指向类本身,而非实例对象。

class Foo {
  static bar() {
    ths.baz() // this 指向 Foo 本身
  }
  static baz() {
    console.log('baz')
  }
  // 这是没问题的,允许静态方法与非静态方法重名
  // static qux() {
  //   // ...
  // }
  // 该方法只会在实例化时才会挂载到实例对象上
  // 而 Foo 类本身是不含此方法的
  // 因此,静态方法与非静态方法是可以重名的。
  qux() {
    console.log('qux')
  }
}
Foo.bar() // "baz"
Foo.qux() // TypeError: Foo.qux is not a function


父类的静态方法,可以被子类继承。

class Foo {
  static classMethod() {
    console.log('The static method of the parent class.')
  }
}
class Bar extends Foo {}
// 可以在子类中调用父类的 classMethod 静态方法
Bar.classMethod() // "The static method of the parent class."


若子类也定义了 classMethod 静态方法,可以通过 super 对象调用父类的 classMethod 静态方法。

class Foo {
  static classMethod() {
    console.log('The static method of the parent class.')
  }
}
class Bar extends Foo {
  static classMethod() {
    super.classMethod() // 调用父类静态方法
    console.log('Static method of subclass.')
  }
}
Bar.classMethod()
// "The static method of the parent class."
// "Static method of subclass."


九、静态属性


静态属性指的是 Class 本身的属性,即 Class.propName,而不是定义在实例对象上的属性。


目前,根据 ECMAScript 规定,Class 内部只有静态方法,没有静态属性。静态属性只能通过在 Class 外部定义。

class Foo {}
Foo.prop = 1 // 静态属性 prop
const foo = new Foo()
console.log(foo.prop) // undefined
console.log(Foo.prop) // 1


现在有一个提案提供了类的静态属性,写法是在属性签名加上 static 关键字。

class Foo {
  static prop = 1
}


通过以上方式来定义静态属性,显然要比老式写法更好地组织代码,其语义更好。而老式写法往往很容易让人忽略这个静态属性。


十、私有方法和私有属性


在目前,在 Class 内部定义的属性和方法,在类的外部都是可以访问到的。

而私有方法和私有属性的目的在于,它们只允许在 Class 内部访问,而外部是不能访问的。但由于目前 ECMAScript 标准并未提供,只能通过变通的方式模拟实现。


  1. 通过命名加以区别

class Foo {
  // 公有方法
  bar() {
    this._baz()
  }
  // 私有方法,通过在变量方法名之前添加下划线 "_" 区分
  _baz() {
    // do something...
  }
}


但显然这仍然可在 Foo 实例对象中访问到 instance._baz()


  1. 将私有方法移出类

class Foo {
  // 公有方法
  bar(...args) {
    baz.apply(this, args)
  }
}
// 相当于私有方法
function baz() {
  // do something...
}


以上示例,间接使得 baz 成了类的“私有方法”,它对类的实例是不可见的。

  1. 利用 Symbol 的唯一性,将私有方法的名称命名为 Symbol 值。

const _baz = Symbol('baz')
class Foo {
  // 公有方法
  bar(...args) {
    this[_baz].apply(this, args)
  }
  // 私有方法
  [_baz]() {
    // do something...
  }
}


以上示例中,_barSymbol 值,一般在封装类时不让其在获取到,以达到私有方法和私有属性的效果。但是仍然可通过 Reflect.ownKeys() 依然可以获取到。

Reflect.ownKeys(Foo.prototype) // ["constructor", "bar", Symbol(baz)]


私有属性的提案


目前,有一个提案为 Class 添加私有属性。在属性名之前,使用 # 表示。

class Foo {
  // 公用属性
  prop = 'public property'
  // 公有方法
  bar(...args) {
    this.#bar.apply(this, args)
  }
  // 私有属性
  #prop = 'private property'
  // 私有方法
  #bar() {
    // do something...
  }
}
const foo = new Foo()
foo.bar('bar') // Correct
foo.prop // Correct
Reflect.ownKeys(Foo.prototype) // ["constructor", "bar"]
// 外部不可访问私有属性和私有方法,会报错。
// foo.#prop // Wrong, SyntaxError: Private field '#prop' must be declared in an enclosing class
// foo.#bar() // Wrong


在上述示例中,#prop#bar 就是私有属性和私有属性,且 # 是属性名的一部分,使用时也必须带有 #,因此 #propprop 是两个不同的属性。


另外,私有属性也可以设置 settergetter 方法。


还有,私用属性和私有方法,前面也可以加上 static 关键字,使其成为静态的私有属性或方法。

class Foo {
  // 静态属性
  static prop = 'private property'
  // 静态私有属性
  static #prop = 'static private property'
  // 静态方法
  static bar() {
    console.log(Foo.prop)
    console.log(Foo.#prop)
  }
  // 静态私有方法
  static #bar() {
    console.log(Foo.prop)
    console.log(Foo.#prop)
  }
}
// 正常访问
Foo.prop // "private property"
Foo.bar() // "private property"、"static private property"
// 以下报错
Foo.#prop // Private field '#prop' must be declared in an enclosing class
Foo.#bar()


上面示例中,#prop 是静态私有属性,#bar 是静态私有方法,在 Class 外部是不能访问的,只能在内部使用。

还有,静态的私有属性或方法,都是可以被子类继承的。

class Bar extends Foo {}
Bar.prop // Correct
Bar.bar() // Correct


十一、new.target 属性


new 运算符是从构造函数生成实例对象的关键字。在构造函数是通过 newReflect.constructor() 调用的,那么 new.target 指向被被调用的构造函数,否则返回 undefined

因此,可以利用它来确保构造函数只能通过 new 关键字来调用。例如:

function Point(x, y) {
  // 也可以这样判断:`new.target === Point`
  if (new.target !== undefined) {
    this.x = x
    this.y = y
  } else {
    throw new TypeError('Point() must be called with new.')
  }
}
const p1 = new Point(1, 2) // 正确使用方式
const p2 = Point(3, 4) // TypeError: Point() must be called with new.


而在类的构造方法中,new.target 指向“直接”new 执行的构造函数。那么,当子类继承父类时,在父类的构造方法中 new.target 指向子类。

class Point {
  constructor(x, y) {
    this.x = x
    this.y = y
    // 若子类继承父类时,new.target 指向子类。
    console.log(new.target === Point)
    // if (new.target === Point) {
    //   throw new TypeError('The Point class cannot be instantiated.')
    // }
  }
}
class P extends Point {
  constructor(x, y, z) {
    super(x, y)
    this.z = z
  }
}
const point = new Point(1, 2) // 会打印 true
const p = new P(1, 2, 3) // 会打印 false


利用此特性,可以写出不可独立使用,必须继承后才会使用的父类。如注释部分。

若在函数外部使用 new.target 会抛出错误:

new.target // SyntaxError: new.target expression is not allowed here



十二、参考


目录
相关文章
|
6月前
|
前端开发
【面试题】如何使用ES6 ... 让代码优雅一点?
【面试题】如何使用ES6 ... 让代码优雅一点?
|
6月前
|
前端开发 JavaScript
前端 TS 快速入门之三:class 类
前端 TS 快速入门之三:class 类
89 1
|
前端开发
前端学习笔记202306学习笔记第四十二天-Es6-class的使用和继承3
前端学习笔记202306学习笔记第四十二天-Es6-class的使用和继承3
36 0
|
前端开发
前端学习笔记202306学习笔记第四十二天-Es6-class的使用和继承2
前端学习笔记202306学习笔记第四十二天-Es6-class的使用和继承2
49 1
前端学习笔记202306学习笔记第四十二天-Es6-class的使用和继承2
|
前端开发
前端学习笔记202306学习笔记第四十二天-Es6-class的使用和继承1
前端学习笔记202306学习笔记第四十二天-Es6-class的使用和继承1
46 0
|
JavaScript 前端开发
细读 ES6 | Class 下篇
ES6 中的继承。
136 0
|
监控 前端开发 JavaScript
每天3分钟,重学ES6-ES12(十五)异步代码处理方案
每天3分钟,重学ES6-ES12(十五)异步代码处理方案
99 0
|
JavaScript 前端开发 Java
每天3分钟,重学ES6-ES12(十二)不常用但却常问的迭代器
每天3分钟,重学ES6-ES12(十二)不常用但却常问的迭代器
119 0
ES6 从入门到精通 # 23:ES6 的模块化实现(完结)
ES6 从入门到精通 # 23:ES6 的模块化实现(完结)
151 0
ES6 从入门到精通 # 23:ES6 的模块化实现(完结)
ES6 从入门到精通 # 21:class 类的用法
ES6 从入门到精通 # 21:class 类的用法
76 0
ES6 从入门到精通 # 21:class 类的用法