聊聊 Typescript 中如何更好地实现多继承与类型推断

5,660 阅读6分钟

前言

最近由于在设计一个sdk,想用搭积木的方式,把不同功能拆分模块,在使用的时候让一个class 通过混合的方式获得不同的功能,于是就开始了对js 多继承的研究。

在实现了多继承之后,又因为用了 typescript 的代码,发现各种语法错误的提醒,所以,在一步步的实践过程中,发现了很多关于多继承的有意思的知识,因此想在这里聊聊关于自己对于多继承的一些认识。


实现

在javascript 中,并不存在真正意义上的多继承,本质上是将需要继承的类合并为一个,且无论是es 语法或者typescirpt 都尚未有相关实现。为了实现多继承,我们可以通过链式继承或者Mixin 方式实现。

在开始介绍实现方式之前,为了方便class 的声明,我们首先需要定义一个表示构造器的type 用于后续的使用:

type Constructor<T = Record<string, any>> = new (...args: any[]) => T;
  • 链式继承

链式继承的逻辑是从基类开始,根据顺序一个个去继承,最终组合返回成一个类,这个类由最外层也就是最后调用的继承函数所决定,实现的源码如下:

  class Base  {
    constructor(...args) {
      console.log('base')
    }
    baseFn() {
      console.log('base')
    }
  }

  const AExtends = (SuperClass: T)<T extends Constructor> =>
    class extends SuperClass {
      constructor(...args) {
        super(...args)
        console.log('a ctor')
      }
      aFn() {
        console.log('a');
      }
    };

  const BExtends = (SuperClass: T)<T extends Constructor> =>
    class extends SuperClass {
      constructor(...args) {
        super(...args)
        console.log('b ctor')
      }
      bFn() {
        console.log('b');
      }
    };

  class Test extends BExtends(AExtends(Base)) {
    constructor() {
      super();
      console.log('test ctor');
      this.aFn();
      this.bFn();
      
      this.test(); // 方法并不存在,但typescript 却不会报错
    }
  }

  const test = new Test();
  // base
  // a ctor
  // b ctor
  // test ctor
  // a
  // b

根据上面的代码的输出结果可以看出,constructor 的调用顺序会按照嵌套的顺序从里到外的执行,看起来很完美,不是吗?

并不,这种方式实际上会把类的原型链的层级变得复杂,当需要混入的类较多的时候,追溯和更改继承的顺序都将是一场灾难。

同时,在使用typescript 的时候,如果不小心使用了一个不存在的方法,由于类型推导我们返回的是一个匿名类,并不知道存在哪些方法,所以不会触发语法错误提示,也就使得类型不再安全。

解决的方法也是有的,就是需要在每个继承函数上声明类的接口

const AExtends = <T extends Constructor>(SuperClass: T): T & Constructor<{ aFn: ()=> void}> => // ...

但是这样一来我们需要的工作也变得重复和繁琐,因此,我们可以尝试用接下来的方式来实现

  • Mixin 混合

这种方式是参考TypeScript mixins 的内容 而来的,实现的逻辑是将多个类的原型方法拷贝到一个空类上面然后返回,实现的源码如下:

  function Mixin<T extends Constructor[]>(
    ...mixins: T
  ) {
    class Mix {}
    function copyProperties(target, source) {
      for (let key of Reflect.ownKeys(source)) {
        // 这些属性会影响继承的基类,避开不继承
        if (key !== 'constructor' && key !== 'prototype' && key !== 'name') {
          let desc = Object.getOwnPropertyDescriptor(source, key);
          Object.defineProperty(target, key, desc);
        }
      }
    }
    for (let mixin of mixins) {
      copyProperties(Mix, mixin); // 拷贝静态属性

      copyProperties(Mix.prototype, mixin.prototype); // 拷贝原型属性
    }
    return Mix;
  }

  class Base {
    baseFn() {
      console.log('base')
    }
  }

  class A {
    aFn() {
      console.log('a')
    }
  }

  class B {
    bFn() {
      console.log('b')
    }
  }

  class Test extends Mixin(Base, A, B) {
    constructor() {
      super();
      this.aFn();
      this.bFn();
      this.baseFn();
    }
  }

  const test = new Test();

  // a
  // b
  // base

这种方式最终得到的继承链就会比前一种方式简短得多,因为只会有Mix这一层,但是这种方式会丢弃掉所有继承类的 constructor,同时,由于返回的是Mix这个类,typescript 并不能认出原型链上的属性,所以会在使用继承的方法的时候提示语法错误。为了解决这个问题,我们需要为这个函数声明返回类型:

  type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
    k: infer I
  ) => void
    ? I
    : never;

  function Mixin<T extends Constructor[]>(
  ...mixins: T
  ): Constructor<UnionToIntersection<InstanceType<T[number]>>>;

  function Mixin<T extends Constructor[]>(
    ...mixins: T
  ) {
    //...
    return Mix;
  }

首先我们定义了一个交集类型,把所有需要继承的类通过InstanceType 获取构造函数的实例类型并合并起来。

这里有个需要注意的地方是,入参的类型是 T 而不是 T[] ,且T 是继承自Contructor[]的,这样一来,我们才能通过 T[number] 的方式获取每个参数来进行推导。

到此,我们已经可以得到一个基础版本的混入了,但是,如果我们需要继承的类也是继承了其他父类的情况下,像下面这种情况,就会出现找不到祖父类上的方法的问题

  class Parent {
    pFn() {
      console.log('parent')
    }
  }

  class A extends Parent {
    //...
  }

  class B extends Parent {
    //...
  }
  //...
  class Test extends Mixin(Base, A, B) {
  constructor() {
      super();
      this.aFn();
      this.bFn();
      this.baseFn();

      this.pFn(); // this.pFn is not a function
    }
  }

为了解决这个问题,我们还需要在拷贝属性时,多加一步对继承类的拷贝,

  function Mixin<T extends Constructor[]>(...mixins: T) {
    //...
    for (let mixin of mixins) {

      //...

      copyProperties(Mix.prototype, mixin.prototype.__proto__); // 拷贝继承的原型属性
    }
    //...
  }

再次运行,我们就能得到 a b base parent 这个正确的结果了。

到这里我们看起来得到了一个比第一种方法看起来更完美的多继承解决方案了,不是吗?

但是……

这种方案其实存在两个说大不大说小不小的缺点:

1. 实例属性缺失

通过以下的代码我们可以看到,我们无法直接获取到以等式形式写入到类上的实例属性

class A {
  aProps = 123;
  aFn() {}
}
Reflect.ownKeys(A) // [ 'constructor', 'aFn' ]

为了遍历到实例属性,我们只能把属性定义到class的prototype 上,才能拷贝到继承的子类上,而且子类获取时只会拿到prototype 上定义的值,本身的值会被忽略。

问题是解决了,但是意味着我们的属性都需要定义在外部,不够优雅和直觉。

class B {
  bProps;
  bFn() {}
}
B.prototype.bProps = 123;
Reflect.ownKeys(B) // [ 'constructor', 'bFn', 'bProps' ]

2. 重名方法覆盖

试想一下,下面的代码会输出什么?

class Base {
  same() {
    console.log('base same')
  }
}
class A {
  same() {
    console.log('a same')
  }
}
class B {
  same() {
    console.log('b same')
  }
}
class Test extends Mixin(Base, A, B) {
  constructor() {
    super();
    this.same();
  }
}

答案是b same,也就是最后一个拷贝的class 上的same 方法,覆盖了前面的same 方法。

为了解决这个问题,我们可以通过将同名的方法按组合顺序合并为一个方法,然后在调用这个组合方法的时候依次执行这些同名方法。另外,执行的上下文也是需要注意改写的

function Mixin<T extends Constructor[]>(
  ...mixins: T
) {
  //...
  const mergeDesc = {};
  const allowMergeKeys = ['init', 'same'];
  function copyProperties(target, source) {
    for (let key of Reflect.ownKeys(source)) {
      if (key !== 'constructor' && key !== 'prototype' && key !== 'name') {
        let desc = Object.getOwnPropertyDescriptor(source, key);
        if (allowMergeKeys.includes(key as string)) {
          mergeDesc[key] = mergeDesc[key] || [];
          mergeDesc[key].push(desc.value);
        } else {
          Object.defineProperty(target, key, desc);
        }
      }
    }
  }
  //...
  for (const key in mergeDesc) {
    const fns = mergeDesc[key];
    Object.defineProperty(Mix.prototype, key, {
      configurable: true,
      enumerable: true,
      writable: true,
      value(...args) {
        const context = this;
        fns.forEach(function (fn) {
          fn.call(context, ...args);
        });
      },
    });
  }
  //...
}

总结

虽然我们已经可以实现了多继承,并完成了类型推断,但其实上述的两种方法都各有缺点:第一种方法可以解决构造器和重复调用的问题,但是对typescript 的类型推断不友好;第二种方式对typescript 更友好,但是却不方便多层继承和重名属性拓展;因此,希望未来的ES 中有更好的对多继承的实现支持。

同时,如果各位大大有更好的实现方式或者对上面代码存在的问题有疑问或者建议,欢迎指教,感谢