JavaScript Class 静态成员那些事

2024-09-10JavaScript

五年前[^1]我写过一篇文章梳理 ES6 class 和 ES5 构造函数的转换。在那篇文章中有一个话题没有展开,那就是 class 当中静态成员的一些行为,尤其是静态函数的一些行为。这篇文章来对静态成员的一些行为进行探究。

static 成员及继承

我们已经知道,如果我们写了如下的一个 class:

class BaseClass {
  static staticFunc1() {}
}

这种写法等价于在 ES5 中的写法:

function BaseClass() {}

BaseClass.staticFunc1 = function() {};

容易验证:

class BaseClass {
  static staticFunc1() {}
}

console.log(typeof BaseClass);     // 'function'
console.log(Object.hasOwn(BaseClass, 'staticFunc1'));    // true.

const baseClass = new BaseClass();
console.log(baseClass.constructor === BaseClass);    // true
console.log(Object.hasOwn(baseClass, 'staticFunc1'));    // false. Not a instance member

如果我们使用继承,那么 static 成员也会通过原型链得到继承:

class DerivedClass extends BaseClass {
  static staticFunc2() {}
}

等价于:

function DerivedClass() {}

// ...此处省略对实例成员的继承...

// 对静态成员的继承
Object.setPrototypeOf(DerivedClass, BaseClass);

DerivedClass.staticFunc2 = function() {};

此时可以发现,对于成员 staticFunc1(),如果我们想要在 staticFunc2() 当中调用,有三种方式:

  1. BaseClass.staticFunc1()
  2. DerivedClass.staticFunc1()
  3. this.staticFunc1()

这三种有什么区别?

static 方法中的 this

首先,我们要搞清楚在 static 方法当中,this 的指向。

我们已经知道,

对于典型的函数,this 的值是函数被访问的对象。换句话说,如果函数调用的形式是 obj.f(),那么 this 就指向 obj。[^ref1]

因此,对于上述三种方式,this 的值分别是:

  1. BaseClass
  2. DerivedClass
  3. staticFunc2() 此时的 this

注意,static 方法(方式 3)是不能在 DerivedClass 实例上调用的,因为 DerivedClass 实例上不存在成员 staticFunc1

class DerivedClass extends BaseClass {
  // ...

  instanceFunc() {
    this.staticFunc1();    // TypeError: this.staticFunc1 is not a function
  }
}

容易注意到,对于 this 的值,方式 1 和 2 是确定的,但是 3 是动态的。这一特性可以用于实现一些“多态”功能[^2]。

static 方法的“多态”

我们有时需要在基类当中访问子类的 static 成员。例如我们编写一个前端框架组件类,基类负责生命周期和渲染,而子类负责提供 HTML 和 CSS。典型地,CSS 一般通过 static 成员提供来节省内存[^3]:

class CustomElement {
  // ...

  // Just for overriding
  static get styles() {return ``;}

  // Called by other lift cycle members
  private renderDOM() {
    // How to get MyCoolElement.styles?
  }

  // ...
}

class MyCoolElement extends CustomElement {
  // ...

  static get styles() {
    return `
        p { text-align: both;}
        ...
    `;
  }

  // ...
}

我们不能在 CustomElement 当中使用 MyCoolElement.styles,因为作为基类不可能知道子类。这里的技巧在于,我们可以利用 this 的动态特性,以及 constructor 属性。

记得我们在上面提到的:

console.log(baseClass.constructor === BaseClass);    // true

那么我们在 renderDOM() 当中得到的 this 是谁?是 MyCoolElement。如果你不明白为什么,参考我之前的文章。简单来说,JavaScript 的继承不会构造两个实例,只会构造一个实例,因此 this 总是指向被构造的实例。

那么剩下的问题就简单了。通过 this.constructor,我们就可以在运行时从基类当中得到子类的静态成员:

class CustomElement {
  // ...

  // Called by other lift cycle members
  private renderDOM() {
    // this.constructor is MyCoolElement at runtime
    const styles = this.constructor.styles;    // 'p { text-align: both;}...'
  }

  // ...
}

注释

  • [^1]: 什么叫丧心病狂地摸鱼啊(后仰)。
  • [^2]: 尽管在 JavaScript 并不存在类似于 C++、Java 一样的多态,但是行为上是相似的,都是到运行时再决定具体对象。
  • [^3]: Lit 就是这么做的。

参考文献

  • [^ref1]: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/this