TypeScript实践中一些的奇妙的应用

375 阅读18分钟

前言

嗨喽小伙伴们好,本篇总结了typescript实践中关于基础类型、函数this与重载、类、接口、类型别名、联合类型、交叉类型、枚举、泛型、类型守卫等知识点的总结,区别、以及妙用。

基础类型never的妙用

never 表示永远不会发生值的类型。

never 是所有类型的子类型,它可以给所有类型赋值,如下代码所示。

let Unreachable: never = 1; // ts(2322)
Unreachable = 'string'; // ts(2322)
Unreachable = true; // ts(2322)
let num: number = Unreachable; // ok
let str: string = Unreachable// ok
let bool: boolean = Unreachable// ok

但是反过来,除了 never 自身以外,其他类型(包括 any 在内的类型)都不能为 never 类型赋值

可以把 never 作为接口类型下的属性类型,用来禁止写接口下特定的属性,示例代码如下:

const props: {
id: number,
name?: never
} = {
id: 1
}
props.name = null; // ts(2322))
props.name = 'str'; // ts(2322)
props.name = 1; // ts(2322)

此时,无论我们给 props.name 赋什么类型的值,它都会提示类型错误,实际效果等同于 name 只读 。

函数 this 的定义使用

在 TypeScript 中,我们只需要在函数的第一个参数中声明 this 指代的对象(即函数被调用的方式)即可。

比如最简单的作为对象的方法的 this 指向,如下代码所示:

function say(this: Window, name: string) {
console.log(this.name);
}

window.say = say;
window.say('hi');

const obj = { say };
obj.say('hi'); // ts(2684) The 'this' context of type '{ say: (this: Window, name: string) => void; }' is not assignable to method's 'this' of type 'Window'.

在上述代码中,我们在 window 对象上增加 say 的属性为函数 say。那么调用window.say()时,this 指向即为 window 对象。

调用 obj.say() 后,此时 TypeScript 检测到 this 的指向不是 window,于是抛出了如下所示的一个 ts(2684) 错误。

say('captain'); // ts(2684) The 'this' context of type 'void' is not assignable to method's 'this' of type 'Window'

需要注意的是,如果我们直接调用 say(),this 实际上应该指向全局变量 window,但是因为 TypeScript 无法确定 say 函数被谁调用,所以将 this 的指向默认为 void,也就提示了一个 ts(2684) 错误。

此时,我们可以通过调用 window.say() 来避免这个错误,这也是一个安全的设计。因为在 JavaScript 的严格模式下,全局作用域函数中 this 的指向是 undefined。

注意: 显式注解函数中的 this 类型,它表面上占据了第一个形参的位置,但并不意味着函数真的多了一个参数,因为 TypeScript 转译为 JavaScript 后,“伪形参” this 会被抹掉,这算是 TypeScript 为数不多的特有语法。

函数重载

在 TypeScript 中,可以表达不同类型的参数和返回值的函数定义。

为了更精确地描述参数与返回值类型约束关系的函数类型。这里就要用到函数重载(Function Overload)

如下示例中 1~3 行定义了三种各不相同的函数类型列表,并描述了不同的参数类型对应不同的返回值类型,而从第 4 行开始才是函数的实现。

function convert(x: string): number;
function convert(x: number): string;
function convert(x: null): -1;
function convert(x: string | number | null): any {
if (typeof x === 'string') {
return Number(x);
}
if (typeof x === 'number') {
return String(x);
}
return -1;
}
const x1 = convert('1'); // => number
const x2 = convert(1); // => string
const x3 = convert(null); // -1

注意: 函数重载列表的各个成员(即示例中的 1 ~ 3 行)必须是函数实现(即示例中的第 4 行)的子集,** 例如 “function convert(x: string): number” 是 “function convert(x: string | number | null): any” 的子集。

为了方便理解这部分内容, 下面通过以下一个示例进行具体说明。

interface P1 {
name: string;
}

interface P2 extends P1 {
age: number;
}

function convert(x: P1): number;
function convert(x: P2): string;
function convert(x: P1 | P2): any {}
const x1 = convert({ name: "" } as P1); // => number
const x2 = convert({ name: "", age: 18 } as P2); // number

因为 P2 继承自 P1,所以类型为 P2 的参数会和类型为 P1 的参数一样匹配到第一个函数重载,此时 x1、x2 的返回值都是 number。

而我们只需要将函数重载列表的顺序调换一下,类型为 P2 和 P1 的参数就可以分别匹配到正确的函数重载了,例如第 5 行匹配到第 2 行,第 6 行匹配到第 1 行。

function convert(x: P2): string;
function convert(x: P1): number;
function convert(x: P1 | P2): any { }
const x1 = convert({ name: '' } as P1); // => number
const x2 = convert({ name: '', age: 18 } as P2); // => string

总结下,函数类型重载,从上往下依次匹配,所有想优先匹配的类型,需要放到最前面,这是比较好理解的设计。

类类型的使用

关于super()

super 函数会调用基类的构造函数。

所以,在基类与继承的派生类中,如果派生类包含一个构造函数,则必须在构造函数中调用 super() 方法,这是 TypeScript 强制执行的一条重要规则。

如下代码所示:

class Animal {
     weight: number;
     type = 'Animal';
     constructor(weight: number) {
       this.weight = weight;
    }
     say(name: string) {
       console.log(`I'm ${name}!`);
    }
}

class Dog extends Animal {
     name: string;
     constructor(name: string) {
       super(); // ts(2554) Expected 1 arguments, but got 0.
       this.name = name;
    }
     bark() {
       console.log('Woof! Woof!');
    }
}

将鼠标放到第 15 行 Dog 类构造函数调用的 super 函数上,我们可以看到一个提示,它的类型是基类 Animal 的构造函数:constructor Animal(weight: number): Animal 。

这是因为 Animal 类的构造函数要求必须传入一个数字类型的 weight 参数,而第 15 行实际入参为空,所以提示了一个 ts(2554) 的错误。

如果我们显式地给 super 函数传入一个 number 类型的值,比如说 super(20),则不会再提示错误了。

访问修饰符 public、private、protected 的区别

  • public 修饰的是在任何地方可见、公有的属性或方法;
  • private 修饰的是仅在类自身可见、私有的属性或方法;
  • protected 修饰的是仅在类自身及子类中可见、受保护的属性或方法。

静态属性 static

可以给类定义静态属性和方法,不需要实例化,直接访问类上的静态属性和方法。

基于静态属性的特性,我们往往会把与类相关的常量、不依赖实例 this 上下文的属性和方法定义为静态属性,从而避免数据冗余,进而提升运行性能。

只读修饰符 readonly

在下面例子中,Son 类 public 修饰的属性既公开可见,又可以更改值,如果我们不希望类的属性被更改,则可以使用 readonly 只读修饰符声明类的属性,如下代码所示:

class Son {
    public readonly firstName: string;
    constructor(firstName: string) {
    this.firstName = firstName;
    }
}

const son = new Son('Tony');
son.firstName = 'Jack'; // ts(2540) Cannot assign to 'firstName' because it is a read-only property.

在第 2 行,我们给公开可见属性 firstName 指定了只读修饰符,这个时候如果再更改 firstName 属性的值,TypeScript 就会提示一个 ts(2540) 的错误(参见第 9 行)。这是因为只读属性修饰符保证了该属性只能被读取,而不能被修改。

注意: 如果只读修饰符和可见性修饰符同时出现,我们需要将只读修饰符写在可见修饰符后面。

接口类型与类型别名的区别

  • 接口类型可以继承和被继承,支持重复定义,它的属性会叠加。只能声明对象。
  • 类型别名不可以继承和被继承,不支持重复定义。可以声明元组、联合类型、交叉类型、原始类型,也包括对象。

联合类型的类型缩减

联合类型(Unions)用来表示变量、参数的类型不是单一原子类型,而可能是多种不同的类型的组合。

我们主要通过“|”操作符分隔类型的语法来表示联合类型。

如果将 string 原始类型和“string字面量类型”组合成联合类型会是什么效果?

效果就是类型缩减成 string 了。

同样,对于 number、boolean(其实还有枚举类型)也是一样的缩减逻辑,如下所示示例:

type URStr = 'string' | string; // 类型是 string
type URNum = 2 | number; // 类型是 number
type URBoolen = true | boolean; // 类型是 boolean
enum EnumUR {
ONE,
TWO
}
type URE = EnumUR.ONE | EnumUR; // 类型是 EnumUR

TypeScript 对这样的场景做了缩减,它把字面量类型、枚举成员类型缩减掉,只保留原始类型、枚举类型等父类型,这是合理的“优化”。

可是这个缩减,却极大地削弱了 IDE 自动提示的能力,如下代码所示:

type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string; // 类型缩减成 string

在上述代码中,我们希望 IDE 能自动提示显示注解的字符串字面量,但是因为类型被缩减成 string,所有的字符串字面量 black、red 等都无法自动提示出来了。

不要慌,TypeScript 官方其实还提供了一个黑魔法,它可以让类型缩减被控制。如下代码所示,我们只需要给父类型添加“& {}” 即可。

image.png

如何定义如下所示 age 属性是数字类型,而其他不确定的属性是字符串类型的数据结构的对象?

{ 
    age: 1, // 数字类型 
    anyProperty: 'str', // 其他不确定的属性都是字符串类型
    ... 
}

我们肯定要用到两个接口的联合类型及类型缩减,这个问题的核心在于找到一个既是 number 的子类型,这样 age 类型缩减之后的类型就是 number;同时也是 string 的子类型,这样才能满足属性和 string 索引类型的约束关系。

哪个类型满足这个条件呢?我们一起回忆一下特殊类型 never。

never 有一个特性是它是所有类型的子类型,自然也是 number 和 string 的子类型,所以答案如下代码所示:

type UnionInterce = 
{
age: number 
} 
| ({ 
age: never; 
[key: string]: string;
});

const O: UnionInterce = {
age: 2,
string: 'string'
};

交叉类型

在 TypeScript 中,交叉类型(Intersection Type)是种类似逻辑与行为的类型,它可以把多个类型合并成一个类型,合并后的类型将拥有所有成员类型的特性。

在 TypeScript 中,我们可以使用“&”操作符来声明交叉类型,如下代码所示:

{
  type Useless = string & number;
}

很显然,如果我们仅仅把原始类型、字面量类型、函数类型等原子类型合并成交叉类型,是没有任何用处的,因为任何类型都不能满足同时属于多种原子类型。

比如既是 string 类型又是 number 类型。因此,在上述的代码中,类型别名 Useless 的类型就是个 never。

合并接口类型

联合类型真正的用武之地就是将多个接口类型合并成一个类型,从而实现等同接口继承的效果,也就是所谓的合并接口类型。

如下代码所示:

  type IntersectionType = { id: number; name: string; } 
    & { age: number };
  const mixed: IntersectionType = {
    id: 1,
    name: 'name',
    age: 18
  }

在上述示例中,我们通过交叉类型,使得 IntersectionType 同时拥有了 id、name、age 所有属性,这里我们可以试着将合并接口类型理解为求并集。

如果合并的多个接口类型存在同名属性会是什么效果呢?

此时,我们可以根据同名属性的类型是否兼容来看。

如果同名属性的类型不兼容,比如上面示例中两个接口类型同名的 name 属性类型一个是 number,另一个是 string,合并后,name 属性的类型就是 number 和 string 两个原子类型的交叉类型,即 never,如下代码所示:

  type IntersectionTypeConfict = { id: number; name: string; } 
    & { age: number; name: number; };
  const mixedConflict: IntersectionTypeConfict = {
    id: 1,
    name: 2, // ts(2322) 错误,'number' 类型不能赋给 'never' 类型
    age: 2
  };

此时,我们赋予 mixedConflict 任意类型的 name 属性值都会提示类型错误。而如果我们不设置 name 属性,又会提示一个缺少必选的 name 属性的错误。在这种情况下,就意味着上述代码中交叉出来的 IntersectionTypeConfict 类型是一个无用类型。

如果同名属性的类型兼容,比如一个是 number,另一个是 number 的子类型、数字字面量类型,合并后 name 属性的类型就是两者中的子类型

如下所示示例中 name 属性的类型就是数字字面量类型 2,因此,我们不能把任何非 2 之外的值赋予 name 属性。

  type IntersectionTypeConfict = { id: number; name: 2; } 
  & { age: number; name: number; };
  let mixedConflict: IntersectionTypeConfict = {
    id: 1,
    name: 2, // ok
    age: 2
  };
  mixedConflict = {
    id: 1,
    name: 22, // '22' 类型不能赋给 '2' 类型
    age: 2
  };

枚举类型

TypeScript 支持数字、字符两种常量值的枚举类型。

数字枚举

在仅仅指定常量命名的情况下,我们定义的就是一个默认从 0 开始递增的数字集合,称之为数字枚举。

如果希望枚举值从其他值开始递增,则可以通过“常量命名 = 数值” 的格式显示指定枚举成员的初始值,如下代码所示:

enum Day { 
    SUNDAY = 1, 
    MONDAY, 
    TUESDAY, 
    WEDNESDAY, 
    THURSDAY, 
    FRIDAY, 
    SATURDAY 
}

字符串枚举

我们将定义值是字符串字面量的枚举称之为字符串枚举,字符串枚举转译为 JavaScript 之后也将保持这些值。

enum Day { 
    SUNDAY = 'SUNDAY', 
    MONDAY = 'MONDAY', 
    ... 
}

外部枚举

待写。。。

泛型

泛型指的是类型参数化,即将原来某种具体的类型进行参数化。和定义函数参数一样,我们可以给泛型定义若干个类型参数,并在调用时给泛型传入明确的类型参数。

设计泛型的目的在于有效约束类型成员之间的关系,比如函数参数和返回值、类或者接口成员和方法之间的关系。

函数参数的类型

我们可以通过尖括号 <> 语法给泛型参数 P 显式地传入一个明确的类型。

function reflect<P>(param: P):P {
  return param;
}

然后在调用函数时,我们也通过 <> 语法指定了如下所示的 string、number 类型入参,相应地,reflectStr 的类型是 string,reflectNum 的类型是 number。

const reflectStr = reflect<string>('string'); // str 类型是 string
const reflectNum = reflect<number>(1); // num 类型 number

另外,如果调用泛型函数时受泛型约束的参数有传值,泛型参数的入参可以从参数的类型中进行推断,而无须再显式指定类型(可缺省),因此上边的示例可以简写为如下示例:

const reflectStr2 = reflect('string'); // str 类型是 string
const reflectNum2 = reflect(1); // num 类型 number

我们可以给函数定义任何个数的泛型入参,如下代码所示:

function reflectExtraParams<P, Q>(p1: P, p2: Q): [P, Q] {
  return [p1, p2];
}

注意: 函数的泛型入参必须和参数/参数成员建立有效的约束关系才有实际意义。

泛型类

在类的定义中,我们还可以使用泛型用来约束构造函数、属性、方法的类型,如下代码所示:

class Memory<S> {
  store: S;
  constructor(store: S) {
    this.store = store;
  }
  set(store: S) {
    this.store = store;
  }
  get() {
    return this.store;
  }
}
const numMemory = new Memory<number>(1); // <number> 可缺省
const getNumMemory = numMemory.get(); // 类型是 number
numMemory.set(2); // 只能写入 number 类型
const strMemory = new Memory(''); // 缺省 <string>
const getStrMemory = strMemory.get(); // 类型是 string
strMemory.set('string'); // 只能写入 string 类型

泛型类型

在 TypeScript 中,类型本身就可以被定义为拥有不明确的类型参数的泛型,并且可以接收明确类型作为入参。

如下代码所示:

type ReflectFuncton = <P>(param: P) => P;
interface IReflectFuncton {
  <P>(param: P): P
}
const reflectFn2: ReflectFuncton = reflect;
const reflectFn3: IReflectFuncton = reflect;

将类型入参的定义移动到类型别名或接口名称后,此时定义的一个接收具体类型入参后返回一个新类型的类型就是泛型类型。

如下示例中,我们定义了两个可以接收入参 P 的泛型类型(GenericReflectFunction 和 IGenericReflectFunction )。

type GenericReflectFunction<P> = (param: P) => P;
interface IGenericReflectFunction<P> {
  (param: P): P;
}
const reflectFn4: GenericReflectFunction<string> = reflect; // 具象化泛型
const reflectFn5: IGenericReflectFunction<number> = reflect; // 具象化泛型
const reflectFn3Return = reflectFn4('string'); // 入参和返回值都必须是 string 类型
const reflectFn4Return = reflectFn5(1); //  入参和返回值都必须是 number 类型

在泛型定义中,我们甚至可以使用一些类型操作符进行运算表达,使得泛型可以根据入参的类型衍生出各异的类型,如下代码所示:

type StringOrNumberArray<E> = E extends string | number ? E[] : E;
type StringArray = StringOrNumberArray<string>; // 类型是 string[]
type NumberArray = StringOrNumberArray<number>; // 类型是 number[]
type NeverGot = StringOrNumberArray<boolean>; // 类型是 boolean

这里我们定义了一个泛型,如果入参是 number | string 就会生成一个数组类型,否则就生成入参类型。而且,我们还使用了与 JavaScript 三元表达式完全一致的语法来表达类型运算的逻辑关系。

利用泛型,我们可以抽象封装出很多有用、复杂的类型约束。

比如在 Redux Model 中约束 State 和 Reducers 的类型定义关系,我们可以通过如下所示代码定义了一个既能接受 State 类型入参,又包含 state 和 reducers 这两个属性的接口类型泛型,并通过 State 入参约束了泛型的 state 属性和 reducers 属性下 action 索引属性的类型关系。

interface ReduxModel<State> {
  state: State,
  reducers: {
    [action: string]: (state: State, action: any) => State
  }
}

注意: 枚举类型不支持泛型。

泛型约束

泛型就像是类型的函数,它可以抽象、封装并接收(类型)入参,而泛型的入参也拥有类似函数入参的特性。因此,我们可以把泛型入参限定在一个相对更明确的集合内,以便对入参进行约束。

我们希望把接收参数的类型限定在几种原始类型的集合中,此时就可以使用“泛型入参名 extends 类型”语法达到这个目的,如下代码所示:

function reflectSpecified<P extends number | string | boolean>(param: P):P {
  return param;
}
reflectSpecified('string'); // ok
reflectSpecified(1); // ok
reflectSpecified(true); // ok
reflectSpecified(null); // ts(2345) 'null' 不能赋予类型 'number | string | boolean'

在上述示例中,我们限定了泛型入参只能是 number | string | boolean 的子集。

同样,我们也可以把接口泛型入参约束在特定的范围内,如下代码所示:

interface ReduxModelSpecified<State extends { id: number; name: string }> {
  state: State
}
type ComputedReduxModel1 = ReduxModelSpecified<{ id: number; name: string; }>; // ok
type ComputedReduxModel2 = ReduxModelSpecified<{ id: number; name: string; age: number; }>; // ok
type ComputedReduxModel3 = ReduxModelSpecified<{ id: string; name: number; }>; // ts(2344)
type ComputedReduxModel4 = ReduxModelSpecified<{ id: number;}>; // ts(2344)

在上述示例中,ReduxModelSpecified 泛型仅接收 { id: number; name: string } 接口类型的子类型作为入参。

我们还可以在多个不同的泛型入参之间设置约束关系,如下代码所示:

interface ObjSetter {
  <O extends {}, K extends keyof O, V extends O[K]>(obj: O, key: K, value: V): V; 
}
const setValueOfObj: ObjSetter = (obj, key, value) => (obj[key] = value);
setValueOfObj({ id: 1, name: 'name' }, 'id', 2); // ok
setValueOfObj({ id: 1, name: 'name' }, 'name', 'new name'); // ok
setValueOfObj({ id: 1, name: 'name' }, 'age', 2); // ts(2345)
setValueOfObj({ id: 1, name: 'name' }, 'id', '2'); // ts(2345)

泛型的应用

react render props 的复用逻辑

interface ConsumerProps<T> {
  children: (value: T) => React.ReactNode
}

interface ICheck {
  name: string
}

const Check = (props: ConsumerProps<ICheck>) => {
  return <>{props.children({ ...props, name: '牛啊' })}</>
}

 <Check>
     {(props) => {
         return props.name
     }}
</Check>

类型守卫

类型守卫的作用在于触发类型缩小。实际上,它还可以用来区分类型集合中的不同成员。

常用的类型守卫包括switch、字面量恒等、typeof、instanceof、in 和自定义类型守卫这几种。

switch

往往会使用 switch 类型守卫来处理联合类型中成员或者成员属性可枚举的场景,即字面量值的集合,如以下示例:

const convert = (c: 'a' | 1) => {
    switch (c) {
    case 1:
    return c.toFixed(); // c is 1
    case 'a':
    return c.toLowerCase(); // c is 'a'
    }
}

字面量恒等

switch 适用的场景往往也可以直接使用字面量恒等比较进行替换,比如前边的 convert 函数可以改造成以下示例:

const convert = (c: 'a' | 1) => { 
    if (c === 1) {
    return c.toFixed(); // c is 1
    } else if (c === 'a') {
    return c.toLowerCase(); // c is 'a'
    }
}

在以上示例中,第 3 行、第 5 行的类型相应都缩小为了字面量 1 和 'a'。

建议:一般来说,如果可枚举的值和条件分支越多,那么使用 switch 就会让代码逻辑更简洁、更清晰;反之,则推荐使用字面量恒等进行判断。

typeof

当联合类型的成员不可枚举,比如说是字符串、数字等原子类型组成的集合,这个时候就需要使用 typeof。

const convert = (c: 'a' | 1) => {
if (typeof c === 'number') {
return c.toFixed(); // c is 1
} else if (typeof c === 'string') {
return c.toLowerCase(); // c is 'a'
}
}

instanceof

联合类型的成员还可以是类。我们使用了 instanceof 来判断 param 是 Dog 还是 Cat 类。

class Dog {
wang = 'wangwang';
}
class Cat {
miao = 'miaomiao';
}

const getName = (animal: Dog | Cat) => {
    if (animal instanceof Dog) {
    return animal.wang;
    } else if (animal instanceof Cat) {
    return animal.miao;
    }
}

这里我们可以看到 animal 的类型也缩小为 Dog、Cat 了。

in

当联合类型的成员包含接口类型(对象),并且接口之间的属性不同,如下示例中的接口类型 Dog、Cat,我们不能直接通过“ . ”操作符获取 param 的 wang、miao 属性,从而区分它是 Dog 还是 Cat。

{
interface Dog {
wang: string;
}
interface Cat {
miao: string;
}

const getName = (animal: Dog | Cat) => {
    if ('wang' in animal) { // ok
    return animal.wang; // ok
    } else if ('miao' in animal) { // ok
    return animal.miao; // ok
    }
}
}

自定义类型守卫

使用类型谓词 is,比如封装一个 isDog 函数来区分 Dog 和 Cat,如下代码所示:

const isDog = function (animal: Dog | Cat): animal is Dog {
    return 'wang' in animal;
}

const getName = (animal: Dog | Cat) => {
    if (isDog(animal)) {
    return animal.wang;
    }
}

这里我们在 getName 函数第 5 行的条件判断中使用了 isDog 将 animal 的类型缩小为 Dog,这样第 6 行就可以直接获取 wang 属性了,而不会提示一个 ts(2339) 的错误。

以上就是总结的内容,完结✿✿ヽ(°▽°)ノ✿。