typescript在实际项目中的运用

2,876 阅读9分钟

最近在公司技术群里发现一个 typescript体操题库,之前虽然也用 ts但只是当成一个纯粹的类型注释来用,也听说过 ts的类型可编程性,但一直不解其意,直到我看到这个库后大感震撼,零零碎碎刷了大半题目之后,这才对于 ts有了完全不一样的认知

当然了,那个题库里题目所用到的体操技巧,对于写业务代码的人来说很难用上几个,关于 ts的基本语法看官方文档即可,我就不浪费资源再去 repeat了,本文分享几个我认为在实际写业务代码的时候可能用上的 ts写法

Const Assertions

TypeScript 3.4 提供的一个功能 const 断言,特性描述:

  1. 文字表达式中的文字类型都不会扩展(即不能重新赋值)
  2. 对象属性只读
  3. 数组变成只读的元组

举个例子

const arr = [1, 2, 3] as const;
arr.push(4);

这个时候,编辑器会报个错

t_1.jpg

因为此时 arr 不再是纯粹的数组了,而是 元组,其类型也由 number[] 变成了 readonly [1, 2, 3] 再来个例子:

const obj1 = {
  a: 1,
  b: 2
}
obj1.a // number

const obj2 = {
  a: 1,
  b: 2
}
obj2.a // 1

不加 const修饰,则 obj1.a的类型就是 number;加了之后,因为 obj2.areadonly的,所以编辑器可以很确定地告诉你 obj2.a 的值和类型都是 1

明确对象属性的值及其类型,会带来一个比较方便的类型定义用法

const obj = {
  a: 1
}
type TSome = {
  [obj.a]: number // 不行
}

上述这个写法,编辑器会直接给你报个错

t_5.jpg

obj.a的类型是 number,是一个并不具体的类型,无法作为 computed property name

所以上面的写法不行,但是你多给 obj 后面加个 const 修饰之后,obj.a的类型就是 1,这是个具体的类型,那就行

const obj = {
  a: 1
} as const
type TSome = {
  [obj.a]: number // 行
}

在实际项目中,此特性主要用于定义不可变的对象变量

t_2.jpg

另外,如果对 enum使用 const 修饰也会产生特别的效果

enum X {
  A,
  B
}
const item = X.A
// 将被编译成
var X;
(function (X) {
    X[X["A"] = 0] = "A";
    X[X["B"] = 1] = "B";
})(X || (X = {}));
var item = X.A;

加上 const 修饰之后

const enum X {
  A,
  B
}
const item = X.A
// 将被编译成
var item = 0 /* A */;

enum经过 const修饰之后,enum对应的变量会直接从编译结果中被删掉,从 enum 中取的值都会被编译成对应的常量,理论上来说代码体积会小一点,性能会更高一点,毕竟少了一个变量也少了从变量取值的过程

交叉类型 &

实际业务中,主要用于合并第三方库提供的类型

例如,如果项目是 react 技术栈并且使用到了 react-router 的话,页面组件的 props上基本上都会有 router相关的方法和属性,如果是一个个手动在 props上添加这些router属性未免太过麻烦及繁琐,好在 react-router 已经给我们提供了相关类型

import { RouteComponentProps } from 'react-router-dom';

function About(props: RouteComponentProps & {
  title: string;
}) {
  props.history.push('/home');
}

t_4.jpg

keyof、typeof

keyof: 将一个类型映射为它所有成员名称的联合类型 typeof: 用于获取变量的类型

我一般主要用这两个来关联数组与对象,例如数组的项是对象的key,或对象的 key 是数组的项

const tabList = ['home', 'about'] as const
// const tabMap = tabList.reduce((t, v) => (t[v] = '/mobile/' + v, t), {} as Record<string, string>)
// const tabMap = tabList.reduce((t, v) => (t[v] = '/mobile/' + v, t), {} as any)

我希望希望 tabMap的类型是{ home: string; about: string },这样我就能在编辑器里 . 出来这两个属性而不是我自己去看有哪些属性,但上述代码中两种写法分别是 Record<string, string>any,当然可以通过显式声明的方式来断言 tabMap的类型:

const tabMap = tabList.reduce((t, v) => (t[v] = '/mobile/' + v, t), {} as { home: string, about: string })

但后续如果我修改了 tabList的值,就得同时修改 tabMap的显式类型了,明显是存在耦合的,那么借助 typeof换个写法:

const tabMap = tabList.reduce((t, v) => (t[v] = '/mobile/' + v, t), {} as Record<(typeof tabList)[number], string>)

现在,无论 tabList的值怎么改,tabMap的类型都能自动保持一致了

再来一个例子

const vehicleTypeMap = {
  car: 1,
  bus: 2,
  plane: 3,
  ship: 4
}
function fn(vehicleType) {}

对于 fn方法的 vehicleType参数,只接收值为 vehicleTypeMap中的属性值,即 1234

你可能说把 vehicleTypeMap定义成一个 enum不就行了吗?在本例子中当然是可以的,但实际场景中,vehicleTypeMapkey可能都是有其他作用的,比如 key都是具有实际意义的,可能需要遍历这些 key,所以不能直接定义成一个 enum,但又希望取出这些 key的值,那么可以借助 keyoftypeof

const vehicleTypeMap = {
  car: 1,
  bus: 2,
  plane: 3,
  ship: 4
} as const
type TVehicleType = (typeof vehicleTypeMap)[keyof typeof vehicleTypeMap] // type => 1 | 2 | 3 | 4
function fn(vehicleType: TVehicleType) {}

模板字符串类型

这是 Type 4.1 的特性,可用于收窄字符串相关类型

例如,你写了一个 fetchAuth方法,其中包含了一些特殊逻辑,只给鉴权相关接口使用,而鉴权相关接口的 path上都会带有 /auth的前缀,那么就可以通过控制传入的接口path来限制只允许鉴权相关的接口路径被传入:

function fetchAuth(path: `/auth/${string}`) {}

fetchAuth('/auth/login') // ok
fetchAuth('/api/home') // Argument of type '"/api/home"' is not assignable to parameter of type '`/auth/${string}`'.

对于如下代码:

const str1 = 'a'
const str2 = 'b'
const str3 = `${str1}${str2}`

你会发现 str1的类型是 astr2的类型是 b,但是 str3的类型却是 string,但是我们都知道 str3的值一定是 ab,所以它的类型一定是 ab,我想让ts自动推导 str3的类型是 ab 而不是string该怎么做呢?

你可以做个体操,但实际上不用那么麻烦,借助上面说过的 as const即可

const str1 = 'a'
const str2 = 'b'
const str3 = `${str1}${str2}` as const

现在 str3的类型就是 ab

Pick

从一个复合类型中,取出几个想要的类型的组合

interface IApiData {
  code: number
  data: {
    name: string
    age: number
    gender: number
  }
  msg: string
}

type TData = Pick<IApiData, "data">

如上,从 IApiData取出 data 的类型

不过,我想说的不是这个

对于上面的例子,实际工作场景中,绝大部分情况下想得到的类型是:

type Data = {
  name: string
  age: number
  gender: number
}

而不是 Pick<IApiData, "data">的结果:

type Data = {
  data: {
    name: string;
    age: number;
    gender: number;
  };
}

那么怎么办呢?其实很简单,根本不用 Pick

type TData = IApiData["data"]

以我的经验看,这比 Pick 使用的场景更加广泛和常见,例如传递 history:

import { RouteComponentProps } from 'react-router-dom';

function Child(props: { text: string; history: RouteComponentProps["history"] }) {
  // ...
}

function About(props: RouteComponentProps) {
  return <Child text="child" history={props.history}>
}

泛型约束 extends

主要是利用其可收窄类型范围的特性,让类型更加精确

比如,对于一个函数 fn,其接受一个参数,这个参数至少包含一个 name 属性,如果你这么写可能是无法覆盖所有场景的:

function fn(param: { name: string }) {}

上述写法意味着 fn的参数param只能有一个 name属性,多了少了都不行

t_3.jpg

你可能想到可以这么写:

function fn(param: { name: string, [propName: string]: any }) {}
fn({ name: 'foo', age: 18 }) // ok

但如果借助泛型约束的话会更直观:

function fn<T extends { name: string }>(param: T) {}
fn({ name: 'foo', age: 18 }) // ok

再来个例子,函数接收一个stringnumber 类型的参数,返回值的类型与参数类型保持一致,使用联合类型或者单纯的泛型都是不够的:

function fn1(value: string | number) {
  return value
}
fn1(1) // type => string | number

function fn2<T extends string | number>(value: T) {
  if (typeof value === 'number') {
    return value * 10
  }
  return value + ' world'
}
fn2(1) // type => string | number

这个时候就需要泛型约束了:

function fn3<T extends string | number>(value: T) {
  let result: unknown
  if (typeof value === 'number') {
    result = value * 10
  } else {
    result = value + ' world'
  }
  return result as T extends string ? string : number
}
fn3(1) // type => number
fn3('') // type => string

类似这种使用三元表达式计算类型的场景(即形如Y extends X ? A : B),也叫做条件类型,一般都是 三元表达式 + extends的格式 条件类型在结合联合类型使用时(只针对 extends左边的联合类型),条件类型会被自动分发成联合类型,这种条件类型也称为分布式条件类型

(string | number) extends T ? A : B
// 相当于
(string extends T ? A : B) | (number extends T ? A : B)

如果不了解这个特性的话,对于上面的 fn3,可能会觉得是有问题的:

function fn4(value: string | number) {
  fn3(value)
}

如果条件类型没有分发的特性,那么对于上述例子中 fn3(value) 来说,由于 (string | number) extends string ? string : number 的结果是 number,所以 fn3(value)的类型就是 number,显然这个类型是不对的,但实际上由于分发特性的存在,fn3(value)的类型是 string | number,这是符合预期的

分布式条件类型也是有条件的,待检查的类型(即extends左边的类型)必须是裸类型(naked type parameter)。即没有被诸如数组,元组或者函数包裹

// 以下并不会发生类型分发
Array<string | number> extends T ? A : B

类型的命名

通常情况下,我定义一个 interface类型,类型的名称会以 I开头,例如 IApiData;定义的 type 类型,类型的名称会以 T开头,例如 TData;同理,enum类型就以 E开头。某种编程语言好像是有这种约定俗成的规定,我忘记是哪种语言了(C#?)

在我看来,这种写法有两种好处

  1. 直观,一眼看上去我就知道这是个类型而不是变量
import { selectTypeList, verifyPathMap, CONTROL_TYPES, selectTypeItem, controlType } from 'conf'

以上这行导入,不点进去看谁能知道哪个是变量哪个是类型? 如果换成:

import { selectTypeList, verifyPathMap, CONTROL_TYPES, TSelectTypeItem, IControlType } from '../conf'

那就很清楚了

  1. 变量命名困难症福音

我有个变量叫 selectTypeItem,我想给它定义一个类型,总得给类型起个名字吧,这个名字不能是 selectTypeItem,但最好又要让人一眼看上去就知道跟 selectTypeItem有关联,叫啥好呢,就在 selectTypeItem前面加个 I/T 岂不美哉?

小结

从我的经验来看,typescript毫无疑问可以提升项目代码的可维护性,但必须确保你能把它用好,如果到处都是 as anytypescript可能就变成了一种负担