关于typescript:使用-TypeScript-定义业务字典

39次阅读

共计 7535 个字符,预计需要花费 19 分钟才能阅读完成。

本文作者:htl

业务字典

在业务开发中,咱们经常须要定义一些枚举值。假如咱们正在开发一款音乐利用,咱们须要定义音乐的类型,以便在业务代码中进行业务逻辑判断:

const MUSIC_TYPE = {
  POP: 1,
  ROCK: 2,
  RAP: 3,
  // ...
};

if (data.type === MUSIC_TYPE.POP) {// 当音乐类型为流行音乐时,执行某些逻辑}

随着业务逻辑的扩大,简略的枚举值往往会衍生出许多关联的字典。比方,咱们须要定义一个音乐的类型对应的名称

const MUSIC_TYPE_NAMES = {[MUSIC_TYPE.POP]: '流行音乐',
  [MUSIC_TYPE.ROCK]: '摇滚音乐',
  [MUSIC_TYPE.RAP]: '说唱音乐',
  // ...
};

// 展现音乐类型名称
<div>{MUSIC_TYPE_NAMES[data.type]}</div>

或者须要定义一个音乐类型对应的图标:

const MUSIC_TYPE_ICONS = {[MUSIC_TYPE.POP]: 'pop.svg',
  [MUSIC_TYPE.ROCK]: 'rock.svg',
  [MUSIC_TYPE.RAP]: 'rap.svg',
  // ...
};

// 展现音乐类型图标
<img src={MUSIC_TYPE_ICONS[data.type]} />

在列表场景下,咱们可能须要定义一个数组模式的字典:

const MUSIC_TYPE_LIST = [
  {
    type: MUSIC_TYPE.POP,
    name: '流行音乐',
    icon: 'pop.svg',
  },
  {
    type: MUSIC_TYPE.ROCK,
    name: '摇滚音乐',
    icon: 'rock.svg',
  },
  {
    type: MUSIC_TYPE.RAP,
    name: '说唱音乐',
    icon: 'rap.svg',
  },
  // ...
];

<div>
  {MUSIC_TYPE_LIST.map((item) => (
    <div>
      <img src={item.icon} />
      <span>{item.name}</span>
    </div>
  ))}
</div>;

又或者心愿应用 key-object 模式防止从多个字典取值:

const MUSIC_TYPE_MAP_BY_VALUE = {[MUSIC_TYPE.POP]: {
    name: '流行音乐',
    icon: 'pop.svg',
  },
  [MUSIC_TYPE.ROCK]: {
    name: '摇滚音乐',
    icon: 'rock.svg',
  },
  [MUSIC_TYPE.RAP]: {
    name: '说唱音乐',
    icon: 'rap.svg',
  },
  // ...
};

const musicTypeInfo = MUSIC_TYPE_MAP_BY_VALUE[data.type];

<div>{musicTypeInfo.name}:{musicTypeInfo.icon}</div>;

这些形态各异的业务字典同时存在会给代码带来反复和凌乱。

当咱们须要变更或增删某个类型或者类型中的某个值时,须要同时批改多个字典,很容易呈现脱漏和谬误,尤其是当这些字典定义散布在不同的文件中。

对于使用者来说,散乱的字典定义也是一种累赘。在业务中应用某个字典时,须要先查找已有的字典并了解其定义。如果已有字典不能齐全满足需要,可能会有新的字典被定义,进一步减少业务字典的凌乱水平。

字典工厂函数

咱们能够实现一个工具函数,将一份定义转换成多种格局的字典。

首先思考入参的格局。显然作为原始数据,入参必须可能蕴含残缺的字典信息,包含键,值,所有扩大字段,甚至列表场景中的展现程序。

咱们能够应用对象数组作为入参:

/**
 * list 示例:* [
 *   {
 *    key: 'POP',
 *    value: 1,
 *    name: '流行音乐',
 *   },
 *   {
 *     key: 'ROCK',
 *     value: 2,
 *     name: '摇滚音乐',
 *   },
 *   // ...
 * ]
 */
function defineConstants(list) {// ...}

接下来思考出参的格局。出参应该是一个对象,蕴含多种格局的字典:

const {KV, VK, LIST, MAP_BY_KEY, MAP_BY_VALUE} = defineConstants([
  {
    key: 'POP',
    value: 1,
    name: '流行音乐',
  },
  {
    key: 'ROCK',
    value: 2,
    name: '摇滚音乐',
  },
  // ...
]);

KV; // {POP: 1, ROCK: 2, ...}
VK; // {1: 'POP', 2: 'ROCK', ...}
LIST; // [{key: 'POP', value: 1, name: '流行音乐'}, {key: 'ROCK', value: 2, name: '摇滚音乐'}, ...]
MAP_BY_KEY; // {POP: { key: 'POP', value: 1, name: '流行音乐'}, ROCK: {key: 'ROCK', value: 2, name: '摇滚音乐'}, ... }
MAP_BY_VALUE; // {1: { key: 'POP', value: 1, name: '流行音乐'}, 2: {key: 'ROCK', value: 2, name: '摇滚音乐'}, ... }

在理论业务中,咱们会为不同的资源定义字典,因而咱们须要为工具函数提供命名空间。应用第二个入参为出参中的 key 减少前缀:

const {
  MUSIC_TYPE_KV,
  MUSIC_TYPE_VK,
  MUSIC_TYPE_LIST,
  MUSIC_TYPE_MAP_BY_KEY,
  MUSIC_TYPE_MAP_BY_VALUE,
} = defineConstants(
  [
    {
      key: 'POP',
      value: 1,
      name: '流行音乐',
    },
    {
      key: 'ROCK',
      value: 2,
      name: '摇滚音乐',
    },
    // ...
  ],
  'MUSIC_TYPE',
);

至此,咱们实现了字典工厂函数的设计。这个函数的 JavaScript 实现并不简单,你可能曾经在一些我的项目中过见过相似的工具函数,然而理论应用时会发现一个问题。

应用 TypeScript 实现类型提醒

应用字典工厂定义业务字典能够让代码更简洁并且标准字典数据格式。然而,相比间接定义,字典工厂的毛病是无奈提供类型提醒。

这给开发者在两个层面带来了不便,一是在定义字典时须要对工具函数的应用和实现有肯定理解,这样能力正确传入参数和解构返回值;二是在应用字典时无奈取得类型提醒,应用字典的开发者须要回来查看定义了哪些字段和值,同时还须要理解工具函数的应用形式。

为了解决这个问题,咱们能够应用 TypeScript 来实现字典工厂函数。以下内容波及 TypeScript 类型零碎的一些个性和一些技巧。

LIST 字典的实现

首先实现最简略的 LIST 字典,因为它和入参截然不同:

interface IBaseDef {
  key: PropertyKey;
  value: string | number;
}

function defineConstants<T extends IBaseDef[], N extends string>(
  defs: T,
  namespace?: N,
) {const prefix = namespace ? `${namespace}_` : '';
  return {[`${prefix}LIST`]: defs,
  };
}

咱们用 IBaseDef 来标准入参中字典项的类型,它蕴含 keyvalue 两个字段。key 的类型是 PropertyKey,它是 string | number | symbol 的联结类型,即 key 的值能够是这三种类型中的任意一种。value 的类型是 string | number,之所以没有 symbol 是因为业务中 value 的值可能会从内部获取,而 key 的值能够是运行时产生的。这两个字段是定义字典必须的,其余字段能够依据业务须要任意增加。

defineConstants 函数中,咱们应用范型来别离示意两个入参的类型并且应用 extends 关键字来束缚范型的类型。T 的类型是 IBaseDef[],保障入参 defs 的格局合乎字典项数组。N 的类型是 string,保障入参 namespace 是一个字符串。

namespace 参数是可选的,如果定义字典时未传入,那么返回的字典 Key 也不会有前缀。因而咱们须要创立一个 prefix 变量并依据 namespace 是否存在来决定它的值。

而后咱们返回一个只有 LIST 字典的对象,它的 Key 由 prefixLIST 拼接而成,值就是入参 defs

这段代码的运行逻辑没有问题,然而它短少了返回值的类型定义,通过 IDE 的代码提醒并不能获取到正确的字典 key:

当你在 IDE 中查看 dicts 的类型时,IDE 并不会真的去执行 JavaScript 代码,而是通过 TypeScript 的类型零碎来生成类型。

因而,咱们须要应用类型零碎定义 defineConstants 的返回类型。

type ToProperty<
  Property extends string,
  N extends string = '',
> = N extends '' ? Property : `${N}_${Property}`;

这里咱们定义了一个类型用于生成字典的 Key。它接管两个范型参数,Property 示意字典的属性,N 示意字典的命名空间。如果 N 为空字符串,那么返回的 Key 就是 Property,否则就是 ${N}_${Property}

这段代码中有一些 JavaScript 语法的影子,比方字符串,默认参数值,三元运算符,模板字符串等。然而这些都是在 TypeScript 类型零碎中运行的,能够看作是一套独立的语言。例如它并没有 if…else 语句,这里的三元运算理论是条件类型(Conditional Types)的语法,当 N 的类型合乎 '' 时,返回 Property,否则返回 ${N}_${Property}

你能够把这样的类型定义看作类型零碎中的「函数」。不同于 JavaScript 函数通过入参接管值并且返回新的值,它通过范型接管类型并且返回新的类型。

当初咱们能够应用 ToProperty 来生成字典的 Key 的类型:

接下来应用 ToProperty 联合映射类型 (Mapped Types)和类型断言(Type Assertions)指定 defineConstants 的返回类型:

function defineConstants<T extends IBaseDef[], N extends string>(
  defs: T,
  namespace?: N,
) {const prefix = namespace ? `${namespace}_` : '';
  return {[`${prefix}LIST`]: defs,
  } as {[Key in ToProperty<'LIST', N>]: T;
  };
}

as 关键字在类型零碎中示意类型断言,是一种手动指定类型的办法。它容许你通知编译器一个变量或值的类型是什么,而不是让编译器主动推断。

而类型映射是一种将已有类型转换为具备指定键值的新类型的办法。咱们生成了一个新的对象类型,它的键是 ToProperty<'LIST', N>,值是 T

将这些联合起来,defineConstants 函数终于能够返回一个反对类型提醒的字典了:

KV 字典的实现

接下来减少 KV 字典,它是一个键值对,键和值别离来自入参字典项中的 keyvalue 属性。

function defineConstants<T extends readonly IBaseDef[], N extends string>(
  defs: T,
  namespace?: N,
) {const prefix = namespace ? `${namespace}_` : '';
  return {[`${prefix}LIST`]: defs,
    [`${prefix}KV`]: defs.reduce((map, item) => ({
        ...map,
        [item.key]: item.value,
      }),
      {},),
  } as MergeIntersection<
    {[Key in ToProperty<'LIST', N>]: T;
    } & {[Key in ToProperty<'KV', N>]: {[Key in ToProperty<'KV', N>]: ToKeyValue<T>;
      };
    }
  >;
}

这段代码减少了 MergeIntersectionToSingleKeyValueToKeyValue 三个类型转换「函数」,并且将范型 T 进一步束缚为 readonly。接下来将一一解释这些类型转换的作用和实现以及为什么 T 必须是 readonly。

MergeIntersection 用于合并穿插类型。

因为咱们的实现中不同字典类型是通过映射类型生成的,咱们须要应用穿插类型(Intersection Types)将它们合并,当合并多个类型后会变得难以浏览。

应用 MergeIntersection 能够将穿插类型合并为一个类型,在视觉上更加清晰,也便于后续解决:

MergeIntersection 的实现:

type MergeIntersection<A> = A extends infer T
  ? {[Key in keyof T]: T[Key] }
  : never;

这里咱们再次应用了条件类型和映射类型。而 infer 关键字则是类型推断(Type Inference)的语法,它能够让咱们在条件类型中获取类型变量的具体类型并用于后续的映射类型。

因为 infer 总能推断出一个类型,所以条件类型的第二个后果永远不会呈现,因而咱们能够应用 never 类型。

ToSingleKeyValue 用于将单个字典项转换为键值对:

ToSingleKeyValue 的实现:

type ToSingleKeyValue<T> = T extends {
  readonly key: infer K;
  readonly value: infer V;
}
  ? K extends PropertyKey
    ? {[Key in K]: V;
      }
    : never
  : never;

咱们应用 infer 关键字获取 keyvalue 的具体类型并且在一个条件类型应用他们。而后在第二个条件类型中明确 key 的类型是 PropertyKey,因而能够用于映射类型。最初指定映射类型中的键和值。

ToKeyValue 用于将字典项数组转换为键值对:

ToKeyValue 的实现:

type ToKeyValue<T> = T extends readonly [infer A, ...infer B]
  ? B['length'] extends 0
    ? ToSingleKeyValue<A>
    : MergeIntersection<ToSingleKeyValue<A> & ToKeyValue<B>>
  : [];

这个实现的关键点是应用类型推断联合开展语法和递归个性实现数组类型的解决。

咱们在第一个条件类型中获取数组的第一个元素和残余元素,而后在第二个条件类型中判断残余元素的长度是否为 0。如果为 0,阐明数组只有一个元素,咱们能够间接应用 ToSingleKeyValue 进行类型转换。否则转换第一个元素并递归应用 ToKeyValue 转换残余局部,最初应用 MergeIntersection 将后果合并。

defineConstants 和这些类型转换函数中应用了 readonly 关键字,这实际上源于 defineConstants 的一个应用限度:在应用 defineConstants 时,必须应用 [const 断言(const
assertions)](https://www.typescriptlang.or…),即在字典项数组前面加上 as const

defineConstants([
  {
    key: 'POP',
    value: 1,
    name: '流行音乐',
  },
  {
    key: 'ROCK',
    value: 2,
    name: '摇滚音乐',
  },
] as const, 'MUSIC_TYPE');

对于代码中的常量定义,TypeScript 会主动推断变量类型而抹去具体的值。这在通常状况下是正当的,然而对于 defineConstants 类型提醒的实现是很大的妨碍。如果入参字典项中的值信息失落,咱们也就无奈通过类型零碎进行类型转换生成字典的类型定义。

比照是否应用 as const 的区别:

而应用 const 断言同时也会将字典项的属性在类型零碎中变成只读,这也是咱们在函数中应用 readonly 关键字的起因。

以上内容基本上笼罩了残余字典类型转换所需的全副语法和技巧,例如 VK 格局只是将键值对换,MAP_BY_KEY 只是将值替换为字典项的类型,因而不再赘述。残缺的实现能够在 Github Gist 获取,也能够间接在这个 CodeSandbox 示例中尝试应用成果。

至此咱们曾经应用 TypeScript 实现了能够生成带有反对类型提醒的业务字典工厂函数,通过这个函数定义和应用业务字典能够在各处获取类型提醒。

定义字典时:

应用字典时:

缺点和有余

这个工具给作者自己在我的项目中带来很大的帮忙,但还是存在一些缺点和有余:

  1. 只能在 TypeScript 我的项目中应用,并且在定义字典时须要应用 as const 关键字。

通常来说一个工具函数以 TypeScript 实现,只有提供良好的类型定义就能够在 JavaScript 我的项目中不便地应用。

然而因为 JavaScript 无奈反对 const 断言或相似性能,这个工具只能在 TypeScript 中应用。

  1. 使用者无奈在类型提醒中获取正文

当咱们定义一个枚举值时,可能会减少一些正文:

enum MusicTypes {
  /**
   * 风行
   */
  POP: 1,
}

开发者在应用这个枚举值时,能够通过 IDE 获取正文内容。然而通过字典工厂函数生成的字典通过转换曾经失落了这些信息。

  1. 无奈同时导出类型定义

defineConstants 返回的是字典值,当上游须要援用字典类型时,还须要须要额定导出类型定义:

export const {MUSIC_TYPE_VALUES} = defineConstants([...], 'MUSIC_TYPE')

// 导出字典类型
export type MUSIC_TYPE = MUSIC_TYPE_VALUES[number]

// 上游类型定义
import {MUSIC_TYPE} from './constants'

interface Music {
  type: MUSIC_TYPE;
  // ...
}

总结

本文针对业务字典定义的场景,应用 TypeScript 实现了一个工具函数,用于生成各种模式且带有类型提醒的业务字典。同时指出了这个工具函数的一些应用限度和不足之处。

本文公布自网易云音乐技术团队,文章未经受权禁止任何模式的转载。咱们长年招收各类技术岗位,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!

正文完
 0