likes
comments
collection
share

TypeScript 接口合并, 你不知道的妙用

作者站长头像
站长
· 阅读数 18

初识

声明合并(Declaration Merging)Typescript 的一个高级特性,顾名思义,声明合并就是将相同名称的一个或多个声明合并为单个定义。

例如:

interface Box {
  height: number;
  width: number;
}
interface Box {
  scale: number;
}
let box: Box = { height: 5, width: 6, scale: 10 };

interface Cloner {
  clone(animal: Animal): Animal;
}
interface Cloner {
  clone(animal: Sheep): Sheep;
}
interface Cloner {
  clone(animal: Dog): Dog;
  clone(animal: Cat): Cat;
}

// Cloner 将合并为
//interface Cloner {
//  clone(animal: Dog): Dog;
//  clone(animal: Cat): Cat;
//  clone(animal: Sheep): Sheep;
//  clone(animal: Animal): Animal;
//}

声明合并最初的设计目的是为了解决早期 JavaScript 模块化开发中的类型定义问题。

  • 早期的 JavaScript 库基本都使用全局的命名空间,比如 jQuery 使用 $, lodash 使用 _。这些库通常还允许对命名空间进行扩展,比如 jQuery 很多插件就是扩展 $ 的原型方法
  • 早期很多 Javascript 库也会去扩展或覆盖 JavaScript 内置对象的原型。比如古早的 RxJS 就会去 「Monkey Patching」 JavaScript 的 Array、Function 等内置原型对象。

尽管这些方案在当今已经属于「反模式」了,但是在 Typescript 2012 年发布那个年代, jQuery 还是王者。

Typescript 通过类型合并这种机制,支持将分散到不同的文件中的命名空间的类型定义合并起来,避免编译错误。

现在是 ES Module 当道, 命名空间的模式已经不再流行。但是不妨碍 声明合并 继续发光发热,本文就讲讲它几个有趣的使用场景。

JSX 内置组件声明

Typescript 下,内置的组件(Host Components) 都挂载在 JSX 命名空间下的 IntrinsicElements 接口中。例如 Vue 的 JSX 声明:

// somehow we have to copy=pase the jsx-runtime types here to make TypeScript happy
import type {
  VNode,
  IntrinsicElementAttributes,
  ReservedProps,
  NativeElements
} from '@vue/runtime-dom'

// 🔴 全局作用域
declare global {
  namespace JSX {
    export interface Element extends VNode {}
    export interface ElementClass {
      $props: {}
    }
    export interface ElementAttributesProperty {
      $props: {}
    }
    
    // 🔴 内置组件定义
    export interface IntrinsicElements extends NativeElements {
      // allow arbitrary elements
      // @ts-ignore suppress ts:2374 = Duplicate string index signature.
      [name: string]: any
    }

    export interface IntrinsicAttributes extends ReservedProps {}
  }
}

我们也可以随意地扩展 IntrinsicElements,举个例子,我们开发了一些 Web Component 组件:

declare global {
  namespace JSX {
    export interface IntrinsicElements {
      'wkc-header': {
        // props 定义
        title?: string;
      };
    }
  }
}

💡 上面例子中 JSX 是放在 global 空间下的,某些极端的场景下,比如有多个库都扩展了它,或者你即用了 Vue 又用了 React, 那么就会互相污染。 现在 Typescript 也支持 JSX 定义的局部化,配合 jsxImportSource 选项来开启, 参考 Vue 的实现

Vue 全局组件声明

和 JSX 类似, Vue 全局组件、全局属性等声明也通过接口合并来实现。下面是 vue-router 的代码示例:

declare module '@vue/runtime-core' {
  // Optional API 扩展
  export interface ComponentCustomOptions {
    beforeRouteEnter?: TypesConfig extends Record<'beforeRouteEnter', infer T>
      ? T
      : NavigationGuardWithThis<undefined>
    beforeRouteUpdate?: TypesConfig extends Record<'beforeRouteUpdate', infer T>
      ? T
      : NavigationGuard
    beforeRouteLeave?: TypesConfig extends Record<'beforeRouteLeave', infer T>
      ? T
      : NavigationGuard
  }

  // 组件实例属性
  export interface ComponentCustomProperties {
    $route: TypesConfig extends Record<'$route', infer T>
      ? T
      : RouteLocationNormalizedLoaded
    $router: TypesConfig extends Record<'$router', infer T> ? T : Router
  }

  // 🔴 全局组件
  export interface GlobalComponents {
    RouterView: TypesConfig extends Record<'RouterView', infer T>
      ? T
      : typeof RouterView
    RouterLink: TypesConfig extends Record<'RouterLink', infer T>
      ? T
      : typeof RouterLink
  }
}

上面我们见识了 JSX 使用 declare global 来挂载全局作用域,而 declare module * 则可以挂载到具体模块的作用域中。

另外,我们在定义 Vue Route 时,通常会使用 meta 来定义一些路由元数据,比如标题、权限信息等, 也可以通过上面的方式来实现:

declare module 'vue-router' {
  interface RouteMeta {
    /**
     * 是否显示面包屑, 默认 false
     */
    breadcrumb?: boolean
    
    /**
     * 标题
     */
    title?: string

    /**
     * 所需权限
     */
    permissions?: string[]
  }
}
export const routes: RouteRecordRaw[] = [ 
  {
    path: '/club/plugins',
    name: 'custom-club-plugins',
    component: () => import('./plugins'),
    // 现在 meta 就支持类型检查了
    meta: {
      breadcrumb: true,
    },
  },
  // ...
]

依赖注入:实现标识符和类型信息绑定

还有一个比较有趣的使用场景,即依赖注入。我们在使用 [InversifyJS](https://github.com/inversify/InversifyJS) 这里依赖注入库时,通常都会使用字符串或者 Symbol 来作为依赖注入的标识符

// inversify 示例
// 定义标识符
const TYPES = {
    Warrior: Symbol.for("Warrior"),
    Weapon: Symbol.for("Weapon"),
    ThrowableWeapon: Symbol.for("ThrowableWeapon")
};

@injectable()
class Ninja implements Warrior {
    @inject(TYPES.Weapon) private _katana: Weapon;
    @inject(TYPES.ThrowableWeapon) private _shuriken: ThrowableWeapon;
    public fight() { return this._katana.hit(); }
    public sneak() { return this._shuriken.throw(); }
}

但是这种标识符没有关联任何类型信息,无法进行类型检查和推断。

于是,笔者就想到了接口合并。能不能利用它来实现标识符和类型之间的绑定?答案是可以的:

我们可以声明一个全局的 DIMapper 接口。这个接口的 key 为依赖注入的标识符,value 为依赖注入绑定的类型信息。

declare global {
  interface DIMapper {}
}

接下来,依赖注入的『供应商』,就可以用来声明标识符和注入类型的绑定关系:

interface IPhone {
  /**
   * 打电话
   */
  call(num: string): void

  /**
   * 发短信
   */
  sendMessage(num: string, message: string): void
}

// 表示 DI.IPhone 这个标识符关联的就是 IPhone 接口类型
declare global {
  interface DIMapper {
    'DI.IPhone': IPhone
  }
}

我们稍微改造一下依赖注入相关方法的实现:

/**
 * 获取所有依赖注入标识符
 */
export type DIIdentifier = keyof DIMapper;

/**
 * 计算依赖注入值类型
 */
export type DIValue<T extends DIIdentifier> = DIMapper[T];

/**
 * 注册依赖
 */
export function registerClass<I extends DIIdentifier, T extends DIValue<I>>(
  identifier: I,
  target: new (...args: never[]) => T,
): void

/**
 * 获取依赖
 */
export function useInject<I extends DIIdentifier, T extends DIValue<I>>(
  identifier: I,
  defaultValue?: T,
): T 

使用方法:

class Foo {}
class MI {
  call(num: string) {}
  sendMessage(num: string, message: string) {}
}

registerClass('DI.IPhone', Foo) // ❌ 这个会报错,Foo 不符合 IPhone 接口
registerClass('DI.IPhone', MI) // ✅ OK!

const phone = useInject('DI.IPhone') // phone 自动推断为 IPhone 类型

💡 对于依赖注入,我在 全新 JavaScript 装饰器实战下篇:实现依赖注入, 介绍了另外一种更加严格和友好的方式。

事件订阅

同样的办法也可以用于事件订阅

declare global {
  /**
   * 声明 事件 标识符和类型的映射关系
   * @example 扩展定义
   * declare global {
   *   interface EventMapper {
   *     'Event.foo.success': ISuccessMessage
   *   }
   * }
   */
  interface EventMapper {}
}

/**
 * 事件名称
 */
export type EventName = keyof EventMapper;

/**
 * 事件参数
 */
export type EventArgument<T extends EventName> = EventMapper[T];

EventBus 实现:

export class EventBus {

  /**
   * 监听事件
   */
  on<N extends EventName, A extends EventArgument<N>>(event: N, callback: (arg: A) => void) {}

  /**
   * 触发事件

   */
  emit<N extends EventName, A extends EventArgument<N>>(event: N, arg: A) {}
}

动态类型插槽

还有一个比较脑洞的例子,我之前封装过一个 Vue i18n 库,因为 Vue 2/3 差异有点大,所以我就拆了两个库来实现,如下图。i18n 用于 Vue 3 + vue-i18n@>=9, i18n-legacy 用于 Vue 2 + vue-i18n@8

但是两个库大部分的实现是一致的,这些共性部分就提取到 i18n-shared

TypeScript 接口合并, 你不知道的妙用

然而 i18n-shared 并不耦合 Vuevue-i18n 的版本,也不可能将它们声明为依赖项, 那么它相关 API 的类型怎么办呢?

// i18n-shared 代码片段
export interface I18nInstance {
  /**
   * vue 插件安装
   * 🔴 VueApp 是 Vue App 的实例
   */
  install(app: VueApp): void;

  // 🔴 vue-i18n 的实例
  i18n: VueI18nInstance;

  // ...

/**
 * 获取全局实例
 * @returns
 */
export function getGlobalInstance(): I18nInstance {
  if (globalInstance == null) {
    throw new Error(`请先使用 createI18n 创建实例`);
  }
  return globalInstance;
}

/**
 * 获取全局 vue i18n 实例
 */
export function getGlobalI18n(): I18nInstance['i18n'] {
  return getGlobalInstance().i18n;
}

这里用泛型也解决不了问题。

一些奇巧淫技还得是类型合并。我在这里就巧妙地使用了类型合并来创建类型插槽。

首先在 i18n-shared 下预定义一个接口:

/**
 * 🔴 供子模块详细定义类型参数
 */
export interface I18nSharedTypeParams {
  // VueI18nInstance: vue i18n 实例类型
  // FallbackLocale
  // VueApp 应用类型
}

// 提取参数
// @ts-expect-error
type ExtraParams<T, V = I18nSharedTypeParams[T]> = V;

export type VueApp = ExtraParams<'VueApp'>;
export type VueI18nInstance = ExtraParams<'VueI18nInstance'>;

定义了一个接口 I18nSharedTypeParams它具体的类型由下级的库来注入,我尚且把它命名为 “动态类型插槽” 吧。

现在 i18ni18n-legacy 就可以根据自己的依赖环境来配置它了:

i18n-legacy:

import VueI18n from 'vue-i18n'; // vue-i18n@8
import Vue from 'vue'; // vue@2

declare module 'i18n-shared' {
  export interface I18nSharedTypeParams {
    VueI18nInstance: VueI18n;
    VueApp: typeof Vue;
  }
}

i18n:

import { VueI18n, Composer } from 'vue-i18n'; // vue-i18n@9+
import { App } from 'vue'; // vue@3

declare module 'i18n-shared' {
  interface I18nSharedTypeParams {
    VueI18nInstance: VueI18n<any, any, any> | Composer<any, any, any>;
    VueApp: App;
  }
}

💡 源码可以看这里

更多

当你深入了解了类型合并之后,你可能会在越来越多的地方发现它的身影。这毕竟是 TypeScript 为数不多,支持动态去扩展类型的特性。

更多的场景,读者可以开开脑洞,比如:

  • unplugin-vue-components Vue 组件自动导入是如何支持类型检查的?
  • unplugin-vue-router 如何实现支持类型检查的 vue-router?
  • 给插件系统加上类型检查
转载自:https://juejin.cn/post/7256594892721373245
评论
请登录