如何TypeScript中相對優雅地實現類的多繼承

夏目有三三發表於2024-12-06

首先,在 js 中還沒有真正的多繼承。但是在實際工作中經常需要抽離通用模組並按需組成新的業務模組,這就對類的多繼承有了實際需求。

舉個例子,現在我們有個基礎類 Animal

class Animal {
  constructor(name?: string) {
    if (name) this.myName = name;
  }
  myName: string = "animal";
}

另外有兩個 Animal 的子類,分別是會跑的 Horse 和會飛的 Bird

class Horse extends Animal {
  constructor(...arg: any[]) {
    super(...arg);
    // ...do something
  }
  run() {
    console.log("I can run");
  }
}

class Bird extends Animal {
  @Decorator() // 帶有裝飾器
  fly() {
    console.log("I can fly");
  }
  static wings = 2;
}
function Decorator(): MethodDecorator {
  return function (target, propKey, descriptor) {
    console.log(`${target.constructor.name} ${String(propKey)}`);
  };
}

現在我們需要一個同時繼承 HorseBird 特性的既會跑又會飛的 Unicorn ,重新寫新的類既不高效也不優雅。下面我們來討論如何實現多繼承。

為了便於後續說明,先定義幾個型別宣告:

type Constructor<T = unknown> = new (...args: any[]) => T;
type ConstructorExtFn<T extends Constructor> = (Cls: T) => T;

type UnionToIntersection<T> = UnionToFunction<T> extends (arg: infer P) => any ? P : never;
type UnionToFunction<T> = T extends any ? (arg: T) => any : never;

傳統 mixin

這種方式原理類似於 Object.assign ,將多個類的原型方法、屬性、靜態屬性等複製到目標類上,以下是簡單實現:

function Mixin<T extends Constructor<{}>[]>(
  ...mixins: T
): Constructor<UnionToIntersection<InstanceType<T[number]>>> & UnionToIntersection<T[number]> {
  class MixinBase {}
  function copyProperties(target: any, source: any) {
    for (const key of Reflect.ownKeys(source)) {
      const skipProps = ["constructor", "prototype", "name"];
      if (!skipProps.includes(String(key))) {
        const desc = Object.getOwnPropertyDescriptor(source, key);
        if (desc) Object.defineProperty(target, key, desc);
      }
    }
  }
  for (const mixin of mixins) {
    copyProperties(MixinBase, mixin);
    copyProperties(MixinBase.prototype, mixin.prototype);
  }
  return MixinBase as any;
}
Animal.prototype.myName = "animal"; // 需在原型鏈設定預設值,否則下方結果為 undefined

class Unicorn extends Mixin(Animal, Horse, Bird) {
  constructor() {
    super("unicorn");
  }
}

console.log(Unicorn.wings); // 2
const unicorn = new Unicorn();
console.log(unicorn.myName); // animal
unicorn.run(); // I can run
unicorn.fly(); // I can fly
unicorn.speak(); // error

優點:

  • 返回的類的原型鏈乾淨,便於追溯

缺點:

  • 需要額外處理構造器、靜態屬性、同名方法,完備實現較為複雜
  • 需要宣告返回型別
  • 屬性預設值需額外定義在原型鏈上
  • 不支援 super
  • 無法繼承父類中的裝飾器

使用子類工廠函式

下面介紹的方法將使用子類工廠函式來實現多繼承。該方法接受一個基類作為引數,返回繼承這個基類的子類,具體邏輯在該子類中實現。下面將重寫上述需求:

function mixinHorse<T extends Constructor<Animal>>(Cls: T) {
  class Horse extends Cls {
    constructor(...arg: any[]) {
      super(...arg);
      // ...do something
    }
    run() {
      console.log("I can run");
    }
  }
  return Horse;
}
const Horse = mixinHorse(Animal);

function mixinBird<T extends Constructor<Animal>>(Cls: T) {
  class Bird extends Cls {
    @Decorator() // 帶有裝飾器
    fly() {
      console.log("I can fly");
    }
    static wings = 2;
  }
  return Bird;
}
const Bird = mixinBird(Animal);

class Unicorn extends mixinBird(mixinHorse(Animal)) {
  constructor() {
    super("unicorn");
  }
}

console.log(Unicorn.wings); // 2
const unicorn = new Unicorn();
console.log(unicorn.myName); // unicorn
unicorn.run(); // I can run
unicorn.fly(); // I can fly
unicorn.speak(); // error

可以看到,只需改變寫法而無需額外程式碼就能完備實現多繼承功能。 super 和裝飾器功能也正常工作

優點:

  • 實現簡單,執行結果符合預期
  • 自帶型別推導
  • 方便重寫同名方法
  • super 和裝飾器正常工作

缺點:

  • 原型鏈較長,繼承過多會導致原型鏈過於複雜
  • 需改寫子類工廠函式,不便於直接使用中間類
  • 巢狀寫法導致繼承過多時不便閱讀,類似回撥地獄

最佳化寫法

我們先來看下繼承過多的情況:

class NewAnimal extends Mixin5(Mixin4(Mixin3(mixinBird(mixinHorse(Animal))))) {
  // ...do something
}

這裡的多層巢狀像極了 js 中的回撥地獄,既不方便閱讀,也不方便增減。對於這個問題,我們可以最佳化寫法使使用更加方便。程式碼如下:

export function multiExtends(
  extendsBase: Constructor<any>,
  extendsFunctions: ConstructorExtFn<Constructor<any>>[]
) {
  let func: ConstructorExtFn<Constructor<any>>;
  let ans: Constructor<any> = extendsBase;
  while (extendsFunctions.length) {
    func = extendsFunctions.shift()!;
    ans = func(ans);
  }
  return ans;
}

上面 multiExtends 函式接受一個基類 extendsBase 和一個子類工廠函式陣列 extendsFunctions ,返回最終繼承結果。但是這樣就丟失了 ts 的自動型別推導,需要自己修改返回型別:

export function multiExtends<
  B extends Constructor<any>,
  E extends ConstructorExtFn<Constructor<any>>
>(
  extendsBase: B,
  extendsFunctions: E[]
): B & UnionToIntersection<ReturnType<E>> {
  let func: ConstructorExtFn<Constructor<any>>;
  let ans: Constructor<any> = extendsBase;
  while (extendsFunctions.length) {
    func = extendsFunctions.shift()!;
    ans = func(ans);
  }
  return ans as any;
}

返回型別重點是將聯合型別E轉為交叉型別

class Unicorn extends multiExtends(Animal, [
  mixinHorse,
  mixinBird,
  Mixin3,
  Mixin4,
  Mixin5,
]) {
  constructor() {
    super("unicorn");
  }
  // ...do something
}

console.log(Unicorn.wings); // 2
const unicorn = new Unicorn();
console.log(unicorn.myName); // unicorn
unicorn.run(); // I can run
unicorn.fly(); // I can fly
unicorn.speak(); // error

可以看到寫法簡介優雅了不少,且功能完備。

上述功能筆者已整理併發布了 npm 包,以便使用:

npm i multi-extends
import { multiExtends } from "multi-extends";

// ...

相關文章