跳转到内容

Class的基本语法

传统的 JavaScript 中,生成实例对象的传统方法是通过构造函数。但是这种写法与面向对象语言的写法差异大,ES6 引入了 Class(类)这个概念,作为对象的模板。通过 class 关键字,可以定义类。

简介

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

js
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return '(' + this.x + ', ' + this.y + ')';
  }
}

⚠ 注意

定义类时,前面不需要加 function ,直接将函数定义放进去就可以了。另外注意构造函数的名称和类名需要相同,且不需要加 function 关键字。

类的数据类型就是函数,类本身就指向构造函数。使用的时候,也是直接对类使用 new 命令,跟构造函数的用法完全一致。

构造函数的 prototype 属性在 ES6 的 “类”上面继续存在。事实上,类的所有方法都定义在类的 prototype 属性上面。由于类的方法都是定义在 prototype 对象上面,所以类的新方法可以添加在 prototype 对象上面。Object.assign() 方法可以很方便地一次向类添加多个方法。

js
class Point {
  constructor() {
    // ...
  }

  toString() {
    // ...
  }

  toValue() {
    // ...
  }
}

// 等同于

Point.prototype = {
  constructor() {},
  toString() {},
  toValue() {},
}

// 等同于

Object.assign(Point.prototype, {
  toString(){},
  toValue(){}
})

类的 prototype 对象上的 constructor 方法,直接指向“类”本身。

类的内部所有定义的方法,都是不可枚举的(non-enumerable)。ES5 的构造函数,所有的属性和方法都是可枚举的。

js
class Point {
  constructor() {
    // ...
  }

  toString() {
    // ...
  }

  toValue() {
    // ...
  }
}

Object.keys(Point.prototype)
// []
Object.getOwnPropertyNames(Point.prototype)
// ["constructor","toString","toValue"]

let Line = function(x, y) {
    // ...
}

Line.propotype.toString = function() {}

Object.keys(Line.prototype) // ["toString"]
Object.getOwnPropertyNames(Line.prototype) // ["constructor","toString"]

类的属性名可以采用表达式。

js
let name = 'myName';

class MMethod {
  [name]() {
    // ...
  }
}

严格模式

类和模块的内部,默认就是严格模式,所以不需要使用 use strict 指定运行模式。只要你的代码写在类或模块之中,就只有严格模式可用。考虑到未来所有的代码,其实都是运行在模块之中,所以 ES6 实际上把整个语言升级到了严格模式。

constructor方法

constructor 方法是类的默认方法,通过 new 命令生成对象实例时,自动调用该方法。一个类必须有 constructor 方法,如果没有显式定义,一个空的 constructor 方法会被默认添加。

js
class Point {
}

// 等同于

class Point {
  constructor() {}
}

constructor 方法默认返回实例对象(即 this ),不过完全可以指定返回另外一个对象。

js
class Foo {
  constructor() {
    return Object.create(null);
  }
}

new Foo() instanceof Foo // false

类必须使用 new 来调用,否则会报错。这是它和普通构造函数的主要区别,后者不用 new 也能执行。

js
class Foo {
  constructor() {
    return Object.create(null);
  }
}

Foo() // TypeError: Class constructor Foo cannot be invoked without 'new'

类的实例对象

与 ES5 相同点:

  1. 生成实例对象写法也是用的 new 命令,否则报错
  2. 和 ES5 一样,实例的属性除非显示定义在其本身(即定义在 this 上),否则都是定义在原型上(即定义在 class 上)
  3. 类所有实例共享一个原型对象
查看代码
js
class Point {

  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return '(' + this.x + ', ' + this.y + ')';
  }
}

let point = new Point(2, 3);
let point1 = new Point(3, 1);

point.toString() // (2, 3)

point.hasOwnProperty('x') // true
point.hasOwnProperty('y') // true
point.hasOwnProperty('toString') // false
point.__proto__.hasOwnProperty('toString') // true

point.__proto__ === Point.prototype // true

上方代码中,xy 属性定义在实例对象 point 上。toString 方法定义在 Point 类的原型上。

⚠ 注意

__porto__ 并不是语言本身的特性,而是各大厂商具体实现时添加的私有属性,虽然目前很多现代浏览器的 JS 引擎都提供这个私有属性,但依旧不建议在生产中使用,避免对环境产生依赖。生产环境中,可以使用 Object.getPrototypeOf() 方法来获取实例对象的原型,然后使用 Object.prototype.hasOwnProperty() 方法来判断属性是定义在实例对象上还是定义在原型上。

js
var p1 = new Point(1, 2)
var p2 = new Point(5, 2)

p1.__proto__.printName = function() { return 'Oops' }

p1.printName() // "Oops"
p2.printName() // "Oops"

let p3 = new Point(3, 4)
p3.printName() // "Oops"

Class表达式

与函数一样, Class 也可以使用表达式的形式定义。

js
const MyClass = class Me {
  getClassName() {
    return Me.name;
  }
};

let inst = new MyClass();
inst.getClassName() // Me
Me.name // Error: Me is not defined

上面的代码表明,Me 只在 Class 的内部可用,指代当前 Class。如果内部没有使用到,则可以省略。

js
const MyClass = class { /* ... */ };

采用 Class 表达式,可以写出立即执行的 Class。下方代码 person 就是一个立即执行的 Class 实例。

js
let person = new class {
  constructor(name) {
    this.name = name;
  }

  sayName() {
    console.log(this.name);
  }
}('张三');

person.sayName(); // "张三"

不存在变量提升

类没有变量提升(hoisting),这一点与 ES5 完全不同。这是因为 ES6 不会把类的声明提升到作用域的顶部,涉及到继承的知识点,必须保证子类在父类之后。

js
new Foo(); // ReferenceError
class Foo {}

{
  let Bar = class {}
  class Baz extends Bar {}
}

Baz 继承 Bar 时,Bar 已经定义了。如果 Class 存在变量提升,class 会被提升到代码头部,而 let 没有提升,导致继承时 Bar 还没定义造成报错。

私有方法

私有方法 ES6 没有提供,需要变通方法来模拟实现。有三种方法可以实现:

  1. 命名上加以区别

    js
     class A {
       // 公有方法
       foo(bar) {
         // some code
       }
    
       // 私有方法
       _bar() {
         // some code
       }
     }
  2. 将私有方法移出模块,模块内部方法对外均可见

    js
     class B {
       foo(bar) {
         bar.call(this, bar)
       }
     }
    
     function bar(baz) {
       return this.snaf = baz
     }
  3. 利用 Symbol 值唯一特性把私有方法命名为 Symbol

    js
    const bar = Symbol('bar')
    class C {
     [bar] () {
       // some code
     }
    }

私有属性

与私有方法类似,私有属性也不能从外部访问,只能通过类的内部方法访问。目前,私有属性只能通过 # 来定义。

js
class Point {
  #x;

  constructor(x = 0) {
    #x = +x;
  }

  get x() { return #x }
  set x(value) { #x = +value }
}

私有属性可以指定初始值,在构造函数执行时进行初始化。

js
class Point {
  #x = 0;
  #y = 0;

  constructor(x = #x, y = #y) {
    #x = +x;
    #y = +y;
  }
}

之所以用 # 表示而不用 private 关键字是因为 JavaScript 是一门动态语言,使用独立符号更可靠。该提案只规定私有属性的写法,但是也能用来编写私有方法。

js
class A {
  #a;
  #b;
  #sum() { return #a + #b }
  constructor(a, b) {
    #a = a;
    #b = b;
  }
  sum() { return #sum() }
}

this的指向

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

js
class Logger {
  printName(name = 'there') {
    this.print(`Hello ${name}`);
  }

  print(text) {
    console.log(text);
  }
}

const logger = new Logger();
const { printName } = logger;
printName(); // TypeError: Cannot read property 'print' of undefined

上面代码中,printName 方法中的 this 指向的是 undefined,导致找不到 print 方法而报错。

解决方法有三个:

  1. 在构造方法中绑定 this,这样就不会找不到 print 方法了。

    js
    class Logger {
      constructor() {
        this.printName = this.printName.bind(this);
      }
    
      // ...
    }
  2. 使用箭头函数。

    js
    class Logger {
      constructor() {
        this.printName = (name = 'there') => {
          this.print(`Hello ${name}`);
        };
      }
    }
  3. 使用 Proxy,获取方法的时候,自动绑定 this

    js
    function selfish (target) {
      const cache = new WeakMap();
      const handler = {
        get (target, key) {
          const value = Reflect.get(target, key);
          if (typeof value !== 'function') {
            return value;
          }
          if (!cache.has(value)) {
            cache.set(value, value.bind(target));
          }
          return cache.get(value);
        }
      };
      const proxy = new Proxy(target, handler);
      return proxy;
    }
    
    const logger = selfish(new Logger());

name属性

本质上,ES6 的类只是 ES5 的构造函数一层包装,所以函数许多特性都被 Class 继承,包括 name 属性。

js
class Point {}
Point.name // "Point"

name 属性总是返回紧跟在 class 关键字后面的类名。

Class的取值函数(getter)和存值函数(setter)

与 ES5 一样,在 Class 内部可以使用 getset 关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为。

js
class MyClass {
  constructor() {
    // ...
  }
  get prop() {
    return 'getter';
  }
  set prop(value) {
    console.log('setter: '+value);
  }
}

let inst = new MyClass();

inst.prop = 123;
// setter: 123

inst.prop
// 'getter'

Class的Generator方法

如果某个方法之前加上星号(*),就表示该方法是一个 Generator 函数。

js
class Foo {
  constructor(...args) {
    this.args = args;
  }
  * [Symbol.iterator]() {
    for (let arg of this.args) {
      yield arg;
    }
  }
}

for (let x of new Foo('hello', 'world')) {
  console.log(x);
}
// hello
// world

Class的静态方法

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

js
class Foo {
  static classMethod() {
    return 'hello';
  }
}

Foo.classMethod() // 'hello'

var foo = new Foo();
foo.classMethod()
// TypeError: foo.classMethod is not a function

父类的静态方法可以被子类继承,静态方法可以在 super 对象调用。

js
class Foo {
  static classMethod() {
    return 'hello';
  }
}

class Bar extends Foo {
  static classMethod() {
    return super.classMethod() + ', too';
  }
}

Class的静态属性和实例属性

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

js
class Foo {
}
Foo.num = 1
Foo.num // 1

目前只能用这种写法,ES6 明确规定 class 内部只有静态方法,没有静态属性。

js
class Foo {
  static myStaticProp: 42 // 错误写法
  prop: 42 // 错误写法
}

Foo.prop // undefined
Foo.myStaticProp // undefined

目前有一个提案关于实例属性和静态属性都规定了新写法。实例属性直接写在类的定义之中;静态属性在实例属性前加一个 static 关键字即可。同时为了更强的可读性,允许已经在构造器 constructor 里定义过的实例属性直接列出。

js
class Foo {
  // 实例属性
  bar = 'hello';
  // 静态属性
  static baz = 42;
  state

  constructor() {
    console.log(this.bar) // 'hello'
    this.state = {
      count: 0
    }
  }
}

new target 属性

new 是从构造函数生成的实例命令,ES6 为 new 命令引入了一个 new.target 属性,该属性一般用在构造函数之中,返回 new 命令作用于的那个构造函数。如果构造函数不是通过 new 命令或 Reflect.construct() 调用的,new.target 会返回 undefined,因此这个属性可以用来确定构造函数是怎么调用的。

js
function Person(name) {
  if (new.target !== undefined) {
    this.name = name;
  } else {
    throw new Error('必须使用 new 命令生成实例');
  }
}

// 另一种写法
function Person(name) {
  if (new.target === Person) {
    this.name = name;
  } else {
    throw new Error('必须使用 new 命令生成实例');
  }
}

var person = new Person('张三'); // 正确

var notAPerson = Person.call(person, '张三'); // 报错

有几个需要注意的点:

  1. Class 内部调用 new.target,返回当前 Class。
  2. 子类继承父类时,new.target 会返回子类。
  3. 在函数外部,使用 new.target 会报错。

总结

ES6 引入的 class 关键字为 JavaScript 提供了一种新的面向对象编程方式,使得类的定义和实例化更加直观和符合传统面向对象语言的风格。class 可以看作是构造函数的语法糖,它通过更简洁的语法封装了原型继承的细节。在类中,可以定义构造函数 constructor 来初始化实例属性,以及定义实例方法和静态方法。实例方法定义在类的 prototype 属性上,而静态方法则直接定义在类上,不会被实例继承。类的所有方法默认都是不可枚举的,这与 ES5 中构造函数的行为有所不同。

类的实例化使用 new 关键字,这与构造函数的调用方式一致。类的实例对象会继承类的原型属性和方法。类的属性名可以使用表达式,提供了更大的灵活性。此外,类内部默认是严格模式,无需显式声明。

ES6 的类不支持变量提升,类必须在声明后才能使用,这有助于避免一些常见的错误。类的私有方法和属性可以通过特定的命名约定或使用 Symbol 来模拟实现,但 ES6 本身并未提供原生的私有成员支持。私有属性可以通过 # 前缀来定义,这是后续提案中引入的特性。

类中的 this 指向实例对象,但在方法单独使用时可能会导致 this 失效,因此需要通过绑定或箭头函数等方式来确保 this 的正确指向。类还支持 getter 和 setter 方法,用于拦截属性的访问和赋值操作。此外,类可以定义 Generator 方法,以及使用 static 关键字定义静态方法和静态属性。

new.target 属性允许在构造函数中检测是否通过 new 调用,从而实现不同的逻辑分支。总的来说,ES6 的类提供了一种更加清晰和易于理解的方式来创建对象和实现继承,是 JavaScript 面向对象编程的重要组成部分。