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

Untitled


初识

声明合并(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

Untitled

然而 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?
  • 给插件系统加上类型检查