likes
comments
collection
share

一篇 TypeScript 实践指南(万字长文)

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

前言

一直想写这篇文章。但是碍于前期准备不足,一直修修补补的,到现在才发布。本文是我在使用 typescript 一年多的一些实践经历,在这一年的时间里我逐渐深入理解并爱上了这门语言。同大多数人一样,我也是从一开始的anyScript开始,后来开始慢慢改变,从粗暴地标识类型,再到使用类型推断与泛型,然后到可以自己写一些类型工具函数。本文就来详细讲讲我在其中遇到了哪些坑和"面向类型编程"的一些实践操作。

基础篇

本篇虽然是基础篇,但并不是说这篇讲的是 typescript 的基础部分。相反,这些东西可能初学者可能或相对少用,先确保你已经初步了解过这些内容,下面只是对日常开发中会经常用到东西做一个提示或补充。

keyof、in 与 [] 符号对索引类型的操作

下面三个操作符都是针对具有索引的类型操作的,并且大多只能在 type 定义类型时使用(索引类型的[]除外)。 首先先对这三个符号做一个解释:

  • []索引访问操作符。可以单独使用相当于是对象书写的[]形式,也可以与keyofin操作符一起使用,先简单讲一下单独使用的效果:

    interface Foo {
      a: number
      b: string
      c: boolean
    }
    // 会有提示的
    type FooKey = Foo['a'] // number
    

    就是这么简单,[]操作符可以让我们直接获取到某个索引的类型。同时,它还支持我们传入联合类型:

    interface Foo {
      a: number
      b: string
      c: boolean
    }
    // string | number | boolean
    type FooKey = Foo['a' | 'b' | 'c']
    // 只要是符合上面属性的类型都可以联合
    

    返回的结果也是所有索引值类型组成的联合类型。和其他操作符配合使用请继续往下看。

  • keyof索引类型查询操作符。假设 T 是一个类型(这个类型一般是一个对象类型,或者说可以用interface书写的类型),那么 keyof T 产生的类型是 T 的属性名称字符串字面量类型构成的联合类型。举个例子:

    interface Foo {
      a: number
      b: string
      c: boolean
    }
    // 'a' | 'b' | 'c'
    type FooKey = keyof Foo
    

    结合上面的[]操作符:

    interface Foo {
      a: number
      b: string
      c: boolean
    }
    // string | number | boolean
    type FooKey = Foo[keyof Foo]
    

    怎么样,是不是感觉更简便了呢。

    注意: 虽然我们可以像下面这样使用:

    type Bar = 'a' | 'b' | 'c'
    type BarKey = keyof Bar
    

    但是得到的答案肯定不是符合你预期的,Bar会被识别为字符串类型,而keyof就会遍历所有字符串的属性和方法,这显然不是我们想要的答案,所以我才会强调这些操作符都是针对索引的,使用者应该是有索引的对象或数组

  • in索引遍历操作符。算是 typescript 在类型上对于in操作符的一个补充,同执行代码一样,in操作符可以让我们遍历联合类型的每一项并将其单独抽离为一个索引:

    type Foo = 'a' | 'b' | 'c'
    
    /*
        type FooObj = {
            a: any;
            b: any;
            c: any;
        }
    */
    type FooObj = {
      [K in Foo]: any
    }
    

    可以看到,in操作符能够将遍历后的联合类型生成一个新的对象类型。

    这就很有意思了,配合之前的keyof操作符,我们就能把它们结合起来把一个类型映射为另一个类型:

    // 将 T 中的索引全部变为可读类型
    type Partial<T> = {
      [P in keyof T]?: T[P]
    }
    

实际上,在 typescript 中很多内置的工具类型都会使用到这些映射相关的操作符,并且我们下面的很多例子也都用到了这些特性,请牢记它们的用法。

const 类型断言

在 typeScript 3.4 中引入了一种用于文字值的新构造,称为const断言。它的语法是类型断言,const代替类型名(例如123 as const)。当我们使用const断言构造新的文字表达式时,我们可以向语言发出信号:

  • 该表达式中的任何文字类型都不应扩展
  • 对象文字获取readonly属性
  • 数组文字变成readonly元组
// Type '"hello"'
let x = 'hello' as const // 类型为 'hello',不能变为 string
// 上面这样其实同 const a = 'hello'

// 一般 as const 会对数组或者对象使用
// Type 'readonly [10, 20]'
const y = [10, 20] as const

// Type '{ readonly text: "hello" }'
const z = { text: 'hello' } as const

const断言可以帮助我们在减少一些比较复杂的类型书写,举个例子:

// Type: function Foo(): (number | (() => void))[]
function Foo() {
  let count = 0
  const setCount = () => {
    count++
  }
  return [count, setCount]
}

上面函数分返回值在不加as const时是一个联合类型的数组,这明显不符合我们的要求,在这种情况下我们需要手动在函数返回值处标明。当然,更简单的用法还是加上as const断言

// Type: function Foo(): readonly [number, () => void]
function Foo() {
  let count = 0
  const setCount = () => {
    count++
  }
  return [count, setCount] as const
}

这样,在调用时才能得到正确的元组类型。

注意:

  • const断言只能立即应用于简单的文字表达式。
    // Error! A 'const' assertion can only be applied to a
    // to a string, number, boolean, array, or object literal.
    let a = (Math.random() < 0.5 ? 0 : 1) as const
    
    // Works!
    let b = Math.random() < 0.5 ? (0 as const) : (1 as const)
    
  • const上下文不会立即将表达式转换为完全不可变的(只是浅转换)。
    const arr = [1, 2, 3, 4]
    
    let foo = {
      name: 'foo',
      contents: arr
    } as const
    
    foo.name = 'bar' // error!
    foo.contents = [] // error!
    
    foo.contents.push(5) // ...works!
    // 如果要限制这里,需要 const arr = [1, 2, 3, 4] as const
    

interface 的声明合并

我们知道定义一个类型的方法有interfacetype两种,interface有一个很特殊的特性,就是可以进行类型合并,如下: 一篇 TypeScript 实践指南(万字长文) 一篇 TypeScript 实践指南(万字长文)

所以说,使用interface定义的类型是可以在之后进行修改的。而使用type定义的类型没有类型合并的特性,定义后就不能够再次修改,只能作为别名使用。

基于这种特性,我们或许可以解释下面这个问题:

export interface IFoo {
  id: string
}
export interface IFoo2 {
  id: string
  [key: string]: string
}
type TFoo = {
  id: string
}

function foo(payload: Record<string, string>) {}

const iPayload: IFoo = {
  id: 'payload'
}

const iPayload2: IFoo2 = {
  id: 'payload'
}

const tPayload: TFoo = {
  id: 'payload'
}

/*
类型“IFoo”的参数不能赋给类型“Record<string, string>”的参数。
类型“IFoo”中缺少索引签名。
*/
foo(iPayload)
// 正常执行
foo(iPayload2)
// 正常执行
foo(tPayload)

在上面这个例子中,只有设置了索引类型的interfacetype定义的类型可以传入到foo函数的参数中,正是因为有interface的类型合并的能力,所以我们不能够保证在之后IFoo的类型是否会发生改变,但在加了索引类型后,就规定了索引类型的值必须为string类型,无论怎么改变都会与函数的参数类型匹配,也就通过了 typescript 的检测。

正如上面所说,当我们的foo函数的类型定义为:

function foo(payload: Record<string, any>) {}

的时候,上面创建的三个类型都是可以使用的,因为索引值为any可以兼容到任何的类型,也就不用担心interface的类型合并问题了。

泛型的基本用法

注意了! 泛型在 typescript 中作用是非常大的,基本也是我们日常最常用的特性,所以能熟练使用泛型参数我认为是非常重要的。

泛型可在类、函数或类型创建时声明,我们可以在使用上述结构时传入泛型的参数值(在大多数使用场景中我们都不会手动传入,而是利用 typescript 的自动推断功能,这个在之后会提高篇细说),从而动态限制上述结构的使用类型。

这里就简单列举一下三种结构对应的书写格式吧:

  • 函数:
    function foo<T>(bar: T): T {
      return bar
    }
    
    foo<number>(1)
    
    调用时传入泛型参数,限制传入的参数类型(不写 number 也是可以的,写了会手动限制传入 foo 的类型必须是 number 类型)。
  • 类:
    // 在这定义泛型参数
    class Foo<T> {
      // 借用泛型参数约束方法类型
      add(value: T): void {
      }
    }
    // 通过泛型实现类不同变量类型的内部算法,比 any 类型效率更高
    const foo1 = new Foo<number>() 
    foo1.add(1) // 只能传入 numbwr 类型
    
    const foo2 = new Foo<string>()
    min2.add('a') // 只能传入 string 类型
    
  • 类型:
    /*
        type Foo<T> = {
          value: T
          keys: (keyof T)[]
        }
    */
    interface Foo<T> {
      value: T
      keys: (keyof T)[]
    }
    
    // 识别可赋值类型
    const foo: Foo<{ a: number; b: string }> = {
      value: {
        a: 1,
        b: '1'
      },
      keys: ['a', 'b']
    }
    

同时,泛型可以使用extends关键字进行约束:

// T extends string 代表 T 的类型是继承子 string 类型的,或者说 T 的类型是 string 类型的子集
function foo<T extends string>(bar: T): T {
    return bar
}

foo<number>(1) // error!

在这里面,因为我们限定了T的类型必须是string类型的子集,所有传入number类型就会报错了。

函数重载

使用过其他类似Java、C++这类语言的同学一定对函数重载这个名字有所了解,不过 typescript 中的函数重载完全不同Java、C++这类语言,它的函数重载并不是在编译时overload,因为它需要和 javascript 具有互操作性,无法生成多个函数,所以仅仅只是函数签名重载,至于相关的条件判断只能我们在真正的函数体内部自行判断了。

下面是简单的用法:

// 前两个是重载签名,后一个是真正的函数实现,前面两个才是真正的函数类型,最后的实现函数的相关参数和返回值的类型必须要同时兼容上面所有的签名
function fun(name: string): string
function fun(age: number): string
function fun(payload: string | number): string {
  if (typeof payload === 'string') {
    return 'name: ' + payload
  } else {
    return 'age: ' + payload
  }
}
// fun 函数能传入的参数只能是 string 和 number ,传入其他参数会报错
console.log(fun('foo'))
console.log(fun(18))
console.log(fun(true)) // error

使用场景

函数重载主要是针对函数有多种固定的参数或返回值时才使用,比如我们要实现一个同时支持回调函数和Promise的函数

function add(a: number, b: number, cb: (res: number) => void): void
function add(a: number, b: number): Promise<number>
function add(
  a: number,
  b: number,
  cb?: (res: number) => void
): Promise<number> | void {
  const res = a + b
  if (cb) {
    cb(res)
  } else {
    return Promise.resolve(res)
  }
}

// 下面两种方法都是有提示的
add(1, 2, (res) => console.log(res))
add(1, 2).then((res) => console.log(res))
// 这种就会报错了,只能二选一
add(1, 2, (res) => console.log(res)).then((res) => console.log(res))

条件类型的使用

typescript 中的条件类型需要依靠extends关键词进行判断,其判断方式类似三元表达式,格式如下:

type Type<T> = T extends U ? X : Y

如果TU的子类,那么返回X,否则返回Y

更特别一点的,可以这样:

type TypeName<T> = T extends string
  ? string
  : T extends number
  ? number
  : T extends boolean
  ? boolean
  : T extends undefined
  ? undefined
  : T extends () => void
  ? () => void
  : object
type Type = TypeName<() => void> // () => void
type Type = TypeName<string[]> // object

因为没有if语句,所以我们通常都会使用这种三元表达式的嵌套实现深层判断。

分布式条件类型

分布式条件类型是条件类型下面的子集,如果条件类型里待检查的类型是naked type parameter(裸类型参数),那么它也被称为分布式条件类型。 分布式条件类型在实例化时会自动分发成联合类型。

例如,实例化T extends U ? X : YT的类型为A | B | C,会被解析为(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)

裸类型参数: 没有被其他类型参数包裹的参数,比如(string | number)[]就是被其他类型包裹的参数,而string | number就不是。

还是拿我们上面的例子:

type TypeName<T> = T extends string
  ? string
  : T extends number
  ? number
  : T extends boolean
  ? boolean
  : T extends undefined
  ? undefined
  : T extends () => void
  ? () => void
  : object
type Type = TypeName<(() => void) | string[]> // () => void | object

可以看到,我们的联合类型被分别解析并形成了新的联合类型。

infer 的使用

infer操作符必须要搭配条件类型(搭配extends关键字)使用,infer是用来用做类型推断并赋值的,后面通常跟一个泛型变量,推断后的返回类型交给后面跟着的泛型变量,用于对其某个方面作出解析。这个操作符通常会在我们书写类型工具的时候起到重要作用。举个例子:

// 如果成立 infer 能推断出数组中元素的类型并且赋值给 U
type Type<T> = T extends Array<infer U> ? U : T 
// 判断类型是数组,解析
type Test = Type<string[]> // string
// 判断类型不是数组,直接返回
type Test2 = Type<string> // string

基本的使用方法很简单,但是我们却可以基于infer推断的类型从原类型衍生出非常多的拓展类型,具体可以看看下面实战篇。

使用内置类型

typescript 本身为我们提供了很多有用的内置类型工具,我们可以很方便的使用这些类型工具得到我们想要的类型。

至于平常如何使用。举个例子,我会在项目中使用Record<string, any>创建一个对象类型,从而代替any做一个索引类型的约束。使用Required<Type>['xxx']的形式将某个索引类型的值全部转为必选并拿到其必选时的定义类型(直接用已存在的类型定义变量类型)等。

interface ButtonProps {
    onClick?: (e: MouseEvent) => void
}
const foo: Record<string, any> = {}
const bar: Required<ButtonProps>['onClick'] = (e) => {
    console.log(e)
}

另外有兴趣的同学可以看一下 utility-types 这个仓库,里面有一些泛用性很高的拓展工具函数。

针对索引类型

针对索引类型的主要有:PartialRequiredReadonlyPickOmitRecord

// 针对索引类型
/**
 * Make all properties in T optional
 */
// 让 T 的所有属性变为可选
type Partial<T> = {
    [P in keyof T]?: T[P
};

/**
 * Make all properties in T required
 */
// 让 T 的所有属性变为必选
type Required<T> = {
    [P in keyof T]-?: T[P];
};

/**
 * Make all properties in T readonly
 */
// 让 T 的所有属性变为只读
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

/**
 * From T, pick a set of properties whose keys are in the union K
 */
// 从 T 中提取 K 集合中的属性(过滤操作)
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

/**
 * Construct a type with the properties of T except for those in type K.
 */
// 与 Pick 相对应,从 T 中提取不是 K 集合中的属性(过滤操作)
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

/**
 * Construct a type with a set of properties K of type T
 */
// 构造一个有以 K 中的集合元素作为属性,T 作为值的类型
type Record<K extends keyof any, T> = {
    [P in K]: T;
};

针对联合类型

针对联合类型的主要有:ExcludeExtractNonNullable

// 针对联合类型
/**
 * Exclude from T those types that are assignable to U
 */
// 去除 T 类型中的 U 类型
type Exclude<T, U> = T extends U ? never : T;

/**
 * Extract from T those types that are assignable to U
 */
 // 提取 T 类型中的 U 类型
type Extract<T, U> = T extends U ? T : never;

/**
 * Exclude null and undefined from T
 */
// 去除 T 类型中的 null 和 undefined 类型
type NonNullable<T> = T extends null | undefined ? never : T;

针对函数

针对函数的主要有:ParametersReturnTypeConstructorParametersInstanceType以及针对this标记的ThisType

// 针对函数
/**
 * Obtain the parameters of a function type in a tuple
 */
// 获取函数参数类型(一个元组)
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

/**
 * Obtain the return type of a function type
 */
// 获取函数返回值类型
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;


/**
 * Obtain the parameters of a constructor function type in a tuple
 */
// 获取构造函数参数类型(一个元组)
type ConstructorParameters<T extends new (...args: any) => any> = T extends new (...args: infer P) => any ? P : never;

/**
 * Obtain the return type of a constructor function type
 */
// 获取构造函数的实例类型
type InstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any;

/**
 * Marker for contextual 'this' type
 */
// 标记 this 的类型,存个疑吧,我自己本身也没用过这个类型,实际项目基本没有用,如果要对 this 做标记的话感觉直接将类型赋值给 this 就行了。
interface ThisType<T> { }

针对字符串模板

针对字符串模板的主要有:UppercaseLowercaseCapitalizeUncapitalize,这几个的实现都是 typescript 内部的固有属性,一般记住有这个工具类型就行了。

// 针对字符串模板
/**
 * Convert string literal type to uppercase
 */
// 将字符串类型所有字符转换为大写
type Uppercase<S extends string> = intrinsic;

/**
 * Convert string literal type to lowercase
 */
// 将字符串类型所有字符转换为小写
type Lowercase<S extends string> = intrinsic;

/**
 * Convert first character of string literal type to uppercase
 */
// 转换第一个字符为大写
type Capitalize<S extends string> = intrinsic;

/**
 * Convert first character of string literal type to lowercase
 */
// 转换第一个字符为小写
type Uncapitalize<S extends string> = intrinsic;

类型保护的使用

类型保护也是 typescript中的一个比较有意思的特性,使用它可以帮助我们缩小类型选择的范围,封装一些特定类型的类型保护可以简化一些大量使用类型断言的场景。

主要的类型保护机制有三种形式:

  • 使用typeof
    function padLeft(value: string, padding: string | number) {
        if (typeof padding === 'number') {
          return Array(padding + 1).join(' ') + value
        }
        if (typeof padding === 'string') {
          return padding + value
        }
        throw new Error(`Expected string or number, got '${padding}'.`)
    }
    
    注意: typeof类型保护只有两种形式能被识别:typeof v === "typename"typeof v !== "typename",其中,"typename"必须是"number","string","boolean""symbol"
  • 使用instanceof
    interface Padder {
       getPaddingString(): string
    }
    
    class SpaceRepeatingPadder implements Padder {
      constructor(private numSpaces: number) {}
      getPaddingString() {
        return Array(this.numSpaces + 1).join(' ')
      }
    }
    
    class StringPadder implements Padder {
      constructor(private value: string) {}
         getPaddingString() {
            return this.value
         }
    }
    
    function getRandomPadder() {
      return Math.random() < 0.5
        ? new SpaceRepeatingPadder(4)
        : new StringPadder('  ')
    }
    
    // 类型为SpaceRepeatingPadder | StringPadder
    let padder: Padder = getRandomPadder()
    
    if (padder instanceof SpaceRepeatingPadder) {
      padder // 类型细化为'SpaceRepeatingPadder'
    }
    if (padder instanceof StringPadder) {
        padder // 类型细化为'StringPadder'
    }
    
    注意: instanceof的右侧要求是一个构造函数,typeScript 将细化为:
    • 此构造函数的prototype属性的类型,如果它的类型不为any的话。
    • 构造签名所返回的类型联合。
  • 使用is(自定义的类型保护):
    class Fish {
      swim() {
        console.log('swim')
      }
    }
    class Bird {
      fly() {
        console.log('fly')
      }
    }
    function isFish(pet: Fish | Bird): pet is Fish {
      return (<Fish>pet).swim !== undefined
    }
    
    pet is Fish是类型谓词。谓词为parameterName is Type这种形式,parameterName必须是来自于当前函数签名里的一个参数名,并且函数的返回值也必须为boolean类型。

在这其中,自定义的类型保护是最常用的,我们可以看看下面这段代码:

const onClick = (e: MouseEvent) => {
  const scrollLeft = (e.target as Element).scrollLeft
  const scrollTop = (e.target as Element).scrollTop
}

再用类型保护来试试:

// 我们可以不做任何事,单纯类型判断
function isElement(target: EventTarget): target is Element {
  return true
}
const onClick = (e: MouseEvent) => {
  // 这个时候 e.target 为 EventTarget
  if (isElement(e.target)) {
    // 这个时候 e.target 为 Element
     const scrollLeft = e.target.scrollLeft
     const scrollTop = e.target.scrollTop
  }
}

可以看到,使用类型保护可以帮助我们优化很多复杂的类型断言。

断言签名

该概念是 typescript 3.7 后才引入的,一般在库的开发中有着大量使用。

下面需要你有了解过 JavaScript 中一些断言库的概念。简单介绍下,假设现在有一个断言函数assert,当传入的值为真时不会有任何影响,传入假值则会抛出错误并提示给用户。

function assert(condition, msg) {
    if (!condition) {
        // assert 库里面自己实现了 AssertionError,这边为了避免报错就用 Error 了
        throw new Error(msg);
    }
}

assert(false, '应该传入真值')

基于此,我们可以明确地在我们的程序中保证当前我们使用的值是符合规范的,这就是断言的作用:

let value:any
value = 'str'
assert(typeof value === 'string', 'value 应该为字符串')
// 可以放心使用
console.log(value.toUpperCase())

那么,你有没有想过,如果能够保证当前的值为string类型了,那么 typescript 为什么不能自动帮我们划分value的类型呢?

在 typescript 3.7 之前,我们可能会这样做:

assert(typeof value === 'string', 'value 应该为字符串')
(value as string).toUpperCase()

但是如果有很多处地方都需要用到该变量呢,也许还可以这样:

let value:any
value = 'str'

assert(typeof value === 'string', 'value 应该为字符串')
// 赋值给一个新的变量强行改变类型
const value2 = value as string
value2.toUpperCase()

通过二次赋值变量强行改变类型,这个方案确实可行,但是不显得很冗余吗?多了行完全没有作用的代码,只为了适应 typescript 的提示,未免有点本末倒置了。

那试试is类型保护?确实,类型保护或许要优雅很多,实际上我在断言签名出来之前也是这样做的:

// 直接返回值,这个函数只是为了改变 value 的类型
function is<T>(value: any): value is T {
  return true
}

let value:any
value = 'str'
assert(typeof value === 'string', 'value 应该为字符串')

if(is<string>(value)) {
    value.toUpperCase() // 有提示了
}

这样写确实好多了,但是还是有缺陷,你可以看到我们在使用类型保护时必须要加入条件判断语句才能让类型保护生效,当然如果你本身在类型保护函数中做了条件判断的话确实是最佳实践方式,但在这里我们仅仅只是修改value的类型,不要忘记我们在每个例子里都加入了assert的判断,在我们使用了assert后其实任何条件判断都是多余的。

回过头来,typescript 给我们的解决方案是直接给assert函数本身添加新的能力,我们对之前的assert函数进行改造:

function assert(condition: any, msg?: string): asserts condition {
    if (!condition) {
        throw new Error(msg);
    }
}

asserts condition,这是 typescript 3.7 版本赋予assert函数的新能力,通过这种能力我们可以在调用assert函数时强制将其类型进行断言,在后续的代码上下文中都会遵循condition的判断条件,相当于无形中加了层类型保护的判断:

let value:any
value = 'str'
assert(typeof value === 'string', 'value 应该为字符串')
// 后面的上下文中都会在 typeof value === 'string' 的判断条件中
value.toUpperCase() // 有提示了

我们还可以配合之前用类型保护定义的is函数:

assert(is<string>(value), 'value 应该为字符串')
value.toUpperCase()

同理,后面我们的代码上下文都会在is<string>(value)中,所以后面的value一样会有提示。

除了使用asserts condition这样的写法,typescript 还给我们提供了另一种断言签名的写法。不检查条件,而是告诉 typeScript 一个特定的变量或属性有不同的类型。

// 和自定义类型保护的写法相同
function assertIsString(val: any): asserts val is string {
    if (typeof val !== "string") {
        throw new Error("Not a string!");
    }
}

当然,我们也可以传入泛型,把上面通过assertis两个函数实现的功能组合在一起:

function assertType<T>(value: any): asserts value is T {
    // do nothing
}

assertType<string>(value)

value.toUpperCase()

可能这就是最简洁的类型判断条件了吧。当然,这样做就失去断言值的意义了,如果不是只想要改变变量类型,一般建议将其配合类型保护来使用,这样既能拿到正确的值,又能得到正确的类型。

模板字符串类型

这个类型是在 typescirpt 4.1 版本后出现的类型,其自带的一些特性可以极大程度上帮助我们简化类型的编写,该类型的写法是下面这样的:

type World = "world";

type Greeting = `hello ${World}`; 
// 等同于 type Greeting = "hello world",与 es6 的模板字符串很像

除此之外,字符串模板类型还可以自动帮助我们拆分联合类型,可以写出类似下面这样的类型定义:

type XDirection = 'left' | 'right'
type YDirection = 'top' | 'bottom'
type Direction = `${YDirection}-${XDirection}`
// 等同于 type Direction = "top-left" | "top-right" | "bottom-left" | "bottom-right"

当然,还可以这样写:

type Prefix<T extends string> = `${T}${string}` // 模板里可以是任何与字符串相关的类型
const foo1: Prefix<'foo-'> = 'foo-1'
const foo2: Prefix<'foo-'> = 'foo-2'

上面的Prefix工具函数就可以借助字符串模板类型让我们创建任何以泛型参数开头的字符串类型,在实际业务中利用这类方式也可以满足很多的类型需求。

有趣的是,字符串类型也是可以使用infer关键字的,我们可以将符合某个规则的字符串类型单独将其子串提取出来:

// 我们可以对匹配的模板格式解构处理
type Tool<T> = T extends `${infer P}.${infer U}`? `${P}+${U}`:never

// "a+b"
type Test1 = Tool<`a.b`>
// `a+${number}`,下面的用法在 Typescript 4.3 版本才能用,4.1 版本识别不出来
type Test2 = Tool<`a.${number}`>

Typescript 4.3 版本对字符串模板类型又进行了完善,可以完成很多复杂有趣的功能了,有兴趣的同学可以多去看看官方文档。

提升篇

本篇是对 Typescript 中一些使用场景的个人理解。

如何定义一个类型

interface 和 type 的区别

这里先说一下我个人认可的观点:用interface描述数据结构,用type描述类型关系

  • 首先,从语义上来说,interface接口的意义很明显,会对我们定义的对象起到约束的作用。而type类型别名则更多的是一个起名的作用,也就是说,本质上其实并不是用它来做一些约束功能的,它仅仅代表着换了个名字,简化我们的类型写法

  • 再者,从 TypeScript 为其赋予的功能讲,interface只能够定义对象(函数)类型的结构,并且是可以进行继承的。而 type能定义的结构和定义的方式(比如type Foo = typeof foo)都会更多,不能够继承其他类型,但是可以使用&交叉类型的方式进行模拟。

  • 然后,从可变与不可变的角度来说,interface由于具有类型合并的特点,所以是可变的,而type定义后就不能够再次修改,是不可变的。具体的介绍在上一节中已经详细举例了,这里就不再赘述。

至于在业务中到底用谁,tslint中的建议是当interfacetype都可使用时优先考虑interface。个人认为在真实的场景中只要风格和团队保持一致就好,除了特定场景(我们上面提到的那些)外,没有什么是必须用interface/type的。

再提个建议: 当你不知道自己应该怎么定义类型的时候,那就使用type来定义吧。

适当定义子类型

其实也就是模块化思想,把写定义当做写组件模块吧。

类似下面这样的:

interface ComponentSize = 'small' | 'default' | 'large'
interface ComponentType = 'primary' | 'warnning' | 'danger'

interface ButtonProps {
    size: ComponentSize
    type: ComponentType
}

将一些可能在多个类型中都会用到的子类型抽离出来,提高代码整洁度。

使用已定义类型

在真实开发中我们可以这样:

import React from 'react'

// 将 props 的类型定义到处
export interface ButtonProps {
  onClick: (e: React.MouseEvent) => void
}
const Button: React.FC<ButtonProps> = ({ children, onClick }) => {
  return <button onClick={onClick}>{children}</button>
}

export default Button
import React from 'react'
import Button, { ButtonProps } from './button'

const Demo: React.FC = () => {
  // 通过这种方式可以快速使用已定义的类型
  const onClick: ButtonProps['onClick'] = () => {}

  return <Button onClick={onClick} />
}

可以看到,我们其实是可以根据已有的ButtonProps类型直接拿到onClick的类型的,在一些具有强业务关联的模块中甚至比使用子类型还合适。

一个先写数据再写类型的情况

可能很多人认为既然要用 typescript,那么一定是写好类型后再写对变量进行约束。确实,大多数情况下是这样的,但是对于某些特殊情况,先写类型确实有些多此一举,反而会让我们代码变得冗余。

在这种情况下,我们对于类型的定义可以这样做:

const obj = {
    a: 1,
    b: 2
}
// type
type Obj = typeof obj

那么在什么情景下我们会这样生成类型呢,这里我举一个例子:

在实际开发中我有遇到一个需求,将所有请求函数封装在一个 api 文件夹里,然后通过require.context(webpack)或者import.meta.glob(vite)这样的方法扫描文件夹,将指定的所有请求方法封装到一个对象上面导出方便调用。 一篇 TypeScript 实践指南(万字长文) 也许有人已经猜到了,在这种情形下,或许我们可以手动定义包含所有请求函数的接口,但当我们请求函数增多的时候,这样做的收益就很低了,我们需要动态地生成一个将所有 Api 的类型声明合并在一起的类型。

对于我来说,我一般会在开发中会专门写一个插件来帮我们自动生成类型接口:

// 根据文件绝对路径生成定义文件
export function generateDeclaration(files: string[]): string {
  const filesImport: string[] = []
  return `${files
    .filter((file) => !file.endsWith('.d.ts'))
    .map((file, index) => {
      const strArr = file.split('.')
      const len = strArr.length
      const ImportName = `File_${index}`
      filesImport.push(ImportName)
      return `import * as ${ImportName} from '${strArr
        .slice(0, len > 1 ? len - 1 : len)
        .join('.')}'`
    })
    .join('\n')}
export type Api = ${filesImport
    .map((fileImport) => `typeof ${fileImport}`)
    .join(' & ')}
    `
}

在开发中使用 fs 模块监听目录,每次修改文件的时候就能自动帮我们生成类型并写入我们指定的类型文件中:

// 下面是导出使用
import { Api } from 'xxx'
const files = require.context('.', true, /\.ts$/)
const $api: Api = {} as Api
files.keys().forEach((key) => {
  const fileModule = files(key)
  const moduleKeys = Object.keys(fileModule) as (keyof Api)[]
  moduleKeys.forEach((moduleKey) => {
    $api[moduleKey] = fileModule[moduleKey]
  })
})
export default $api

有点工程化的意思了~

不过,在日常开发过程中,还是建议如果能先写类型就先写类型,只有在写类型收益反而会小于先写数据的时候再使用typeof value这样的形式获取类型(而这种情况其实是很少发生的)。

什么时候使用类型推断

一个很直观的方法,如果你用了 vscode 这种对 typescript 支持度很高的编辑器,那么使用编辑器的自动识别功能无疑是个很好的方案。如果你声明的变量在编辑器里面能够得到明确的类型,那么就可以不必手动声明类型了,除非该类型为any或者并不符合你想要的类型。

泛型在类型推断中的作用

前面也介绍过了,泛型可以说是 typescript 推断类型的灵魂所在。不知道大家平时都是怎么定义和使用泛型的呢,其实泛型很多时候并不需要我们手动传入,依赖其对传入变量类型的自动推断我们就能得到大部分的类型定义思路。 我们由易到难来看看例子:

function foo<T>(bar: T): T {
    return bar
}

这就是泛型的类型推断最简单的使用,我们调用函数时不必传入任何泛型参数,typescript 会自动帮我们推断类型。

当然,要使用泛型的自动推断需要满足一些条件,我们之前已经说过了,函数、类和接口都可以使用泛型,在这里面除了接口是必须要传入参数(除非写了默认值)以外,类只需要在构造函数参数处,函数在参数处指定就可以了。

就像上面那样,我们可以不传入T的类型,因为我们在参数处写了T会根据bar的值进行推导。

除了在参数处指定类型以外,我们还可以通过其他泛型的推断来省略类型的指定:

function foo<
  T extends Record<string, any>,
  R extends T & {
    test: string
  }
>(bar: T): R {
  return {
    ...bar,
    b: 2,
  } as R
}

// 返回带有 a 和 b 的对象
foo({ a: 1 })

通过这种写法,我们也能将泛型的类型自动推断出来(当然,在函数内部使用这个泛型的时候必须要用到类型断言)。

不过请注意一点,下面这种方式 typescript 是无法推断出来的:

// 不能在 extends 中进行二次推断
function foo<T extends (...args: P) => R, P extends any[], R>(
  bar: T,
  // P 的推断其实是依靠的这个参数
  ...args: P
) {
  return {
    params: args,
    return: bar(...args),
  }
}
// { params:[number], return: unknown }
foo((a: number) => a, 1)

虽然这样推断不行,但是我们就无法获得传入函数的参数和返回值了吗?很显然还有其他的方法,我们可以引入infer来帮助我们进行推导:

function foo<T extends (...args: any[]) => any>(
  bar: T,
  ...args: T extends (...args: infer P) => any ? P : never
): {
  params: T extends (...args: infer P) => any ? P : never
  return: T extends (...args: any) => infer R ? R : any
} {
  return {
    params: args,
    return: bar(...args)
  }
}
// { params:[a:number], return: number }
foo((a: number) => a, 1)

很繁琐对吧,这也就是为什么我们需要工具类型来简化操作。用 typescript 自带的工具类型看起来就简洁很多了:

function foo<T extends (...args: any[]) => any>(
  bar: T,
  ...args: Parameters<T>
): {
  params: Parameters<T>
  return: ReturnType<T>
} {
  return {
    params: args,
    return: bar(...args),
  }
}
// { params:[a:number], return: number }
foo((a: number) => a, 1)

联合类型的类型缩小

相比起交叉类型(&),联合类型的使用范围会更加广一点。我们都知道,交互类型是将几个不同的类型合并在一起变成一个新的类型,该类型包含了之前所有类型的特性,而联合类型(|)表示一个值可以是几种类型之一。

但是,你或许并不知道联合类型也可以让开发者有非常精确的类型选择能力(或者讲可选择类型缩小):

// 要使用这种能力,几个不同的联合类型需要至少要有一个相同的键,并且该键名的类型需要指定
interface TypeA {
  type: 'a'
  a: number
}
interface TypeB {
  type: 'b'
  b: number
}
interface TypeC {
  type: 'c'
  c: number
}

type UType = TypeA | TypeB | TypeC

function foo(obj: UType) {}

foo({
  type: 'a'
})

一篇 TypeScript 实践指南(万字长文) 我们可以看出,当我们给了type一个可以区分的值时,TypeScript 会自动帮我们减少类型的范围。 基于这样的特性,我们就可以构建出根据某一个type不同而拥有不同复杂配置的类型参数。

全局类型和外部模块类型的定义

相信各位在日常开发中难免会遇到为添加全局类型或者为某一个库添加类型定义的情况。下面就来说说如何给它们添加定义。

全局定义

我知道的全局定义方案主要有两种:

  • 一种是只能定义在一个没有importexport关键字的ts文件中(因为如果有importexport会被当做模块处理):
// global.d.ts
interface Test {
  example: string
}
// 当然,全局变量的声明也是可以的
declare const test: Test
// 注意,这里的声明是 var,使用 var 可以把变量挂载到 globalThis 这个全局对象上
declare var globalTest: Test
// 我们就可以使用类似 global.globalTest 的方法来使用变量了

或者使用interface的类型合并功能对已有全局变量做类型拓展。

interface Window {
  example: string
}

这样就为全局的Window类型添加了一个example属性。

  • 还有一种方式叫做全局范围扩大,这种方式必须嵌套在外部模块中或环境模块声明中,具体的定义方式如下:
// 必须在模块中使用,也就是要有 import 和 export 关键词
// ...
// 定义了这句话内部的代码就已经处于全局的上下文环境中了
declare global {
  // 注意这里面是不能导入导出的,默认已经是全局环境了
  interface Test {
    example: string
  }
  const test: Test
  // 注意,这里的声明是 var,使用 var 可以把变量挂载到 globalThis 这个全局对象上  
  var globalTest: Test
}
// ...

这种方案其实我感觉用的比较广,因为很多时候我们定义的全局类型都要引用其他模块的类型定义,而且在发布npm包的时候通常也是使用这种方式定义全局变量的。

外部模块定义

外部模块定义很多时候都用在为第三方包书写或修改定义文件,或者让 typescript 编译器能够识别某些文件后缀。 一般的写法为:

declare module 'xxx' {
    // 书写详细的模块定义
    export interface XXX {}
    export const x
}
// 或
// 外部模块定义的简写,默认导出值为 any
declare module 'xxx'

我们可以使用模块声明通配符(*)来匹配某些特定类型的模块:

// 比如我们要让 typescript 识别 css 文件
declare module '*.css'

这样声明之后,所有以.css结尾的css文件都可以被typescript识别。

修改第三方包的类型定义

为第三方包修改定义其实很简单,只需要利用好外部模块定义和interface的声明合并功能就行了。 下面以修改react-redux的定义文件为例:

在自己的项目中创建一个react-redux.d.ts文件。

// react-redux.d.ts
// 最好加一句这段话,不然导出可能会被覆盖掉,只有 DefaultRootState 存在
export * from 'react-redux'

declare module "react-redux" {
    // 原来的 DefaultRootState 的定义类型为 {},我们把它变成索引类型
    export interface DefaultRootState {
         test:Test
        [key: string]: any
   }
}

如果我们不进行类型拓展,在使用useSelector获取redux的全局状态时就必须手动指明state的类型,否则就会报类型错误。而上面这样写就将state的类型进行了全局拓展,不用在每次调用时都添加参数得到类型定义了。

实践篇

本篇是面向类型编程的实战部分,通过实战中的学习对 Typescript 进一步掌握。

获取函数第 K 个参数的类型

现在我们要创建一个工具函数,该函数可以返回某个函数的任意参数位置的类型,像下面这样:

function foo(a: number, b: string) {}

// number
type A = ParamsTool<typeof foo, 0>
// string
type B = ParamsTool<typeof foo, 1>

思路: 其实写法类似于Parameters的源码写法,只是换了个形式而已,用Parameters的话写法是这样的:

function foo(a: number, b: string) {}
// number
type A = Parameters<typeof foo>[0]
// string
type B = Parameters<typeof foo>[1]
参考答案👇🏻
// 直接 Parameters<F>[K] 也是可行的,不过还要限制 F 的类型
type ParamsTool<F, K extends number> = F extends (...args: infer P) => any
  ? P[K] 
  : never

function foo(a: number, b: string) {}

// number
type A = ParamsTool<typeof foo, 0>
// string
type B = ParamsTool<typeof foo, 1>

写个 Vue3 的 ref 呗

ref是 Vue3 中为我们提供的,可以为我们某一个 value 值提供响应式的载体,这里简单看一下它的类型和如何使用:

// ref 函数生成的对象
export interface Ref<T = any> {
  value: T
  /**
   * Type differentiator only.
   * We need this to be in public d.ts but don't want it to show up in IDE
   * autocomplete, so we use a private Symbol instead.
   */
  [RefSymbol]: true
  /**
   * @internal
   */
  _shallow?: boolean
}
// 值
const test1 = ref(0) // Ref<number>
// 一次 Ref 包装
const test2 = ref(test1) // Ref<number>
// 二次 Ref 包装
const test3 = ref(test2) // Ref<number>
// 传入对象
const test4 = ref({
  test: 0,
  test1,
  test2,
  test3
})
/*
Ref<{
    test: number;
    test1: number;
    test2: number;
    test3: number;
}>
*/
// 传入数组(元组)
const test5 = ref([
  0,
  test1,
  test2,
  test3,
  test4,
  {
    test: 0,
    test1,
    test2,
    test3
  }
] as const)
/*
Ref<readonly [0, number, number, number, {
    test: number;
    test1: number;
    test2: number;
    test3: number;
}, {
    readonly test: 0;
    readonly test1: number;
    readonly test2: number;
    readonly test3: number;
}]>
*/

可以看到,不管我们传入的是值还是ref对象,最后都会进行一个解包处理,拿到真正的值的类型,不会有嵌套的Ref类型产生,并且在传入对象和数组时,都会进行一些特殊的操作,比如传入对象时会直接将所有的Ref对象都解包放在该对象上传入数组时会遍历数组元素单独做判断。现在我们要编写ref函数的定义让它能够符合我们上述预期。

思路: 解包操作无非是走了一遍递归流程,所以定义一个可以递归的类型再用infer判断是否为Ref类型就可实现类型了,对于数组和对象的相关操作则需要使用条件类型单独判断。

参考答案👇🏻

该答案并不是真正的ref类型定义,但也参考过ref源码中的定义,想看源码的同学可以直接访问 ref 源码 查看。

// ref 对象的定义就只简单考虑 value 值了
export interface Ref<T = any> {
  value: T
}

export function ref<T>(value: T): Ref<UnwrapRef<T>>

可以看到我们最后有一个UnwrapRef的工具类型,这个类型就可以帮助我们完成ref的解包操作。

export type UnwrapRef<T> = T extends Ref<infer V>
  ? UnwrapRef<V>
  : T

完了吗,当然没有,我们现在只是完成了基本的解包操作,但是别忘了我们还需要判断传入参数是否为对象和数组,并进行单独操作,所以我们还得单独再提取一个类型:

// 单独提取
type UnwrapRefSimple<T> = T extends Array<any>
  // 如果是数组,递归所有元素
  ? { [K in keyof T]: UnwrapRefSimple<T[K]> }
  : // 需要判断是否为函数或者还是 Ref(这个是在数组中方 ref 对象时会进入)
  T extends Function | Ref
  // 如果是函数或者是 Ref 就原样返回
  ? T
  // 如果是对象,解包对象中的所有属性
  : T extends object
  ? { [P in keyof T]: UnwrapRef<T[P]> }
  : T

OK,再加上ref函数的定义,完整实现如下:

// ref 对象的定义就只简单考虑 value 值了
export interface Ref<T = any> {
  value: T
}

// 单独提取
type UnwrapRefSimple<T> = T extends Array<any>
  // 如果是数组,递归所有元素
  ? { [K in keyof T]: UnwrapRefSimple<T[K]> }
  : // 需要判断是否为函数或者还是 Ref(这个是在数组中方 ref 对象时会进入)
  T extends Function | Ref
  // 如果是函数或者是 Ref 就原样返回
  ? T
  // 如果是对象,解包对象中的所有属性
  : T extends object
  ? { [P in keyof T]: UnwrapRef<T[P]> }
  : T
export type UnwrapRef<T> = T extends Ref<infer V>
  ? UnwrapRefSimple<V>
  : UnwrapRefSimple<T>
  
export declare function ref<T>(value: T): Ref<UnwrapRef<T>>

写个 createLocalStorage 呗

该函数需要实现这样一个效果,当传入不同参数的时候类型定义也会自动帮助我们返回有不同属性值得对象: 一篇 TypeScript 实践指南(万字长文) 该函数的实现如下:

export interface createLocalStorageOptions<V> {
  map?: (v: string | null) => V
  defaultValue?: V
}
// 封装 localStorage
// 我们需要根据传入的 name 自动推断出返回的对象属性
export function createLocalStorage<T extends string, V = string | null>(
  name: T,
  options: createLocalStorageOptions<V> = {}
) {
  const { defaultValue, map = (v: string | null) => v } = options

  const action = name[0].toUpperCase() + name.slice(1)
  const setAction = `set${action}`
  const getAction = `get${action}`
  const removeAction = `remove${action}`
  const key = `${name}-key`

  return {
    [setAction](value: string) {
      localStorage.setItem(key, value)
    },
    [getAction]() {
      return map(localStorage.getItem(key)) ?? defaultValue ?? null
    },
    [removeAction]() {
      localStorage.removeItem(key)
    },
  }
}

现在我们函数的返回值是一个索引类型,可以想一想如何在内部进行完善。

思路: 之前提到过 typescript 内部原生修改字符串大小写的工具类型,再加上条件类型对不同的函数单独进行类型定义。

参考答案👇🏻
export interface createLocalStorageOptions<V> {
  map?: (v: string | null) => V
  defaultValue?: V
}
export function createLocalStorage<T extends string, V = string | null>(
  name: T,
  options: createLocalStorageOptions<V> = {}
) {
  const { defaultValue, map = (v: string | null) => v } = options
  // 修改类型字符串
  type SetAction = `set${Capitalize<T>}`
  type GetAction = `get${Capitalize<T>}`
  type RemoveAction = `remove${Capitalize<T>}`

  const action = name[0].toUpperCase() + name.slice(1)
  const setAction = `set${action}`
  const getAction = `get${action}`
  const removeAction = `remove${action}`
  const key = `local-storage-${name}-key`

  return {
    [setAction](value: string) {
      localStorage.setItem(key, value)
    },
    [getAction]() {
      return map(localStorage.getItem(key)) ?? defaultValue ?? null
    },
    [removeAction]() {
      localStorage.removeItem(key)
    },
  } as {
    // 取到联合类型的每个子项,然后使用条件类型分别判断一下就 ok 啦
    [P in SetAction | GetAction | RemoveAction]: P extends SetAction
      ? (value: string) => void
      : P extends GetAction
      ? () => V
      : () => void
  }
}

用 useState 写一个 setState

React 中的useState我想大家都很熟悉了,但他本身和 React 的ClassComponent带的setState行为是不一致的,所以我们对它进行一下封装让他的行为同setState

import { useState, useCallback } from 'react'

function useSetState<S extends Record<string, any>>(initial: S | (() => S)) {
  const [state, setState] = useState(initial)
  const setMergeState: SetMergeStateTool<typeof setState> = useCallback(
    (currentState) => {
      if (typeof currentState === 'function') {
        setState((prevState) => ({ ...prevState, ...currentState(prevState) }))
      } else {
        setState((prevState) => ({ ...prevState, ...currentState }))
      }
    },
    []
  )

  return [state, setMergeState] as const
}

export default useSetState

ok,封装方法很简单,但是这个类型书写就很有意思了,我们现在要做的是只使用一个类型工具就可以基于原来的setState的类型写出我们现在的setMergeState的类型,你会如何去实现呢?

思路: 对于函数来说,最核心的定义莫过于参数和返回值了,如果我们能拿到这两个东西,再对其进行相关的修改映射,就能得到想要的答案了。

参考答案👇🏻
// 如果传对象属性全部变为可选
type ObjectKeysPartial<T> = T extends (...args: any[]) => any ? T : Partial<T>

// 如果传函数就把函数的返回对象的属性全部变为可选
type FunctionReturnPartial<T> = T extends (...args: infer P) => infer R
  ? (...args: P) => ObjectKeysPartial<R>
  : T
  
// 因为 setState 可以传对象和函数,我们这里要判断一下,先一层判断修改函数,然而二层判断修改对象
type setMergeStateParamsTool<T> = {
  [K in keyof T]: ObjectKeysPartial<FunctionReturnPartial<T[K]>>
}
// 修改参数和返回值
type SetMergeStateTool<T> = T extends (...args: infer P) => infer R
  ? (...args: setMergeStateParamsTool<P>) => R
  : T

vuex 中 dispatch 的 type 类型推断

我们要实现的是当调用dispatch时自动给我们提示能够匹配上的actiontype,包括解析出vuexmodulestype

vuex的源码中我们大致可以提取出来它的类型:

下面的代码可以大致浏览下,我们后面会进行重构

interface DispatchOptions {
  root?: boolean
}
interface Payload {
  type: string
}
interface Dispatch {
   // type 直接是 string,没有进行推导
  (type: string, payload?: any, options?: DispatchOptions): Promise<any>
  <P extends Payload>(
    payloadWithType: P,
    options?: DispatchOptions
  ): Promise<any>
}


interface ActionTree<S, R> {
  [key: string]: Action<S, R>
}

// ActionHandler 和 ActionObject 就不过多讨论了,大体是限制 action 的值
type Action<S, R> = ActionHandler<S, R> | ActionObject<S, R>


interface ModuleTree<R> {
  [key: string]: Module<any, R>
}

interface Module<S, R> {
  // 这里我们简化一下操作,只要开启后就可以嵌套(真实情况还可以有很多特例)
  namespaced?: boolean
  state?: S | (() => S)
  // ...
  // modules 是可以嵌套的
  modules?: ModuleTree<R>
}


interface StoreOptions<S> {
  // 可以看到 S 为 state 的值
  state?: S | (() => S)
  // ...
  actions?: ActionTree<S, S>
  modules?: ModuleTree<S>
 // ..
}


class Store<S> {
  // 这里只讲类型,不讲代码实现
  constructor(options: StoreOptions<S>) {}
  dispatch: Dispatch
  // ...
}
// 使用
const store = new Store({
    actions: {
        test() {
            console.log('test')
        }
    }
})

store.dispatch('test')

但是,我们可以看到,dispatch本身对于type是不会有任何提示的,我们现在需要优化一下Store的类型定义,让它能够自动推断出来。

思路: 使用字符串模板类型,而且因为vuexmodules也是可以嵌套的,那么我们需要再创建类型专门的递归类型来解析modules的类型,同时在解析时还需要判断namespacedtrue的情况。

参考答案👇🏻

我们的注重点在于actiontype类型推导,所以下面的代码就只写和 actionmodules相关的了,如果想完全写全vuex定义的小伙伴可以自行拓展。 这个类型书写就比较复杂了,下面来慢慢说明:

  • 首先我们得重新定义相关类型的的泛型参数。
     // 简化内部操作,就把 Action 看作普通函数
     type Action = () => void
     // actions 的类定义
     interface ActionTree {
       [key: string]: Action
     }
     // modules 的类型定义
     interface ModuleTree {
       [key: string]: Module
     }
     interface DispatchOptions {
       root?: boolean
     }
     // 函数重载第二种模式的 type 类型限定
     interface Payload<A> {
       type: A
     }
      
     // 这边不要写 extends 限定泛型参数,会限制相关推断类型的,我们在后面单独判断
     // 模块的属性
     interface Module<N = boolean, A = ActionTree, M = ModuleTree> {
       // 这里我们简化一下操作,只要开启后就可以嵌套(真实情况还可以有很多特例)
       namespaced?: N
       actions?: A
       // modules 是可以嵌套的
       modules?: M
     }
    
     interface Dispatch<T> {
       // 在这就指定好 type 的类型了
       (type: T, payload?: any, options?: DispatchOptions): Promise<any>
       // 重载
       <P extends Payload<T>>(
         payloadWithType: P,
         options?: DispatchOptions
       ): Promise<any>
     }
     // 构造函数参数
     interface StoreOptions<A extends ActionTree, M extends ModuleTree> {
       actions?: A
       modules?: M
     }
     export declare class Store<A extends ActionTree, M extends ModuleTree> {
       // 这里只讲类型,不讲代码实现
       constructor(options: StoreOptions<A, M>)
       dispatch: Dispatch<keyof A | GetModulesType<M>>
       // ...
     }
    
    OK,现在已有的一些类型我们已经修改完了,现在需要添加我们的递归类型来解析modules支持的type,看到上面的Dispatch了吗,传入它的泛型参数就是我们推断出来的所有type,首先我们使用keyof拿到最外层actionstype类型,然后我们又定义了一个新的类型GetModulesType,看名字就知道,我们需要靠他解析modules的类型。
  • 现在就来定义我们的GetModulesType吧:
    // 遍历 ModuleTree
    type GetModulesType<MS extends ModuleTree> = {
      // 这里我们把里面的某个索引拿到(当前缀),再传入到单独获取类型的工具中
      [K in keyof MS]: GetModuleType<K, MS[K]>
    }[keyof MS] // 遍历到所有的 value
    
    // 递归获取 Module 的所有相关联的 type
    type GetModuleType<P, M> = M extends Module<infer N, infer A, infer SubModules> // N 为 namespaced 的值,A 为模块的 actions 的值(ActionTree),SubModules 为 modules 的值
      ? /* 
      N extends true,因为 N 为 boolean,所以 N 其实是 true 和 false 的联合类型,所以两边都会触发,但我们这个的 N 因为受到了限制,只会是 true 或者 false
      */
        N extends true
        ? // 这里如果 namespaced 为 true 就返回有前缀的 actions 和所有子 modules 的 actions,否则就不加前缀
          GetPrefixActionKeys<P, A> | GetPrefixSubModules<P, SubModules>
        : GetActionKeys<A> | GetSubModules<SubModules>
      : never
    
    经过上面的类型转换,我们就能将索引对象的modulesModuleTree)转换为单个Module,再遍历所有Module,这里我们产生了 4 个的新的类型GetPrefixActionKeysGetPrefixSubModulesGetActionKeysGetSubModules,看名字就知道,是分别处理当前模块的actionsmodules的。
  • 最后我们吧上面四个类型完善就行了:
    // 工具函数
    // 因为我们传的是 keyof ,但是我们只要 string 类型,所以可以直接 & 只取 string
    type Prefix<P, K> = `${P & string}/${K & string}`
    
    // 获取有前缀的子模块
    // 因为我们的选项都是可选的,所以需要再加两个判断,传了对象,并且对象有索引
    type GetPrefixSubModules<P, SM> = SM extends ModuleTree
      ? keyof SM extends string
        ? // 这里就是开始递归了,子模块作为 modules 传入
          Prefix<P, GetModulesType<SM>>
        : never
      : never
    // 获取没有前缀的子模块
    type GetSubModules<SM> = SM extends ModuleTree
      ? keyof SM extends string
        ? // 开始递归
          GetModulesType<SM>
        : never
      : never
    
    // 获取有前缀的 actions 的 keys
    // 因为我们的选项都是可选的,所以需要再加两个判断,传了对象,并且对象有索引
    type GetPrefixActionKeys<P, A> = A extends ActionTree
      ? keyof A extends string
        ? Prefix<P, keyof A>
        : never
      : never
    // 获取没有前缀的 actions 的 keys
    type GetActionKeys<A> = A extends ActionTree
      ? keyof A extends string
        ? keyof A
        : never
      : never
    
    大功告成,在GetPrefixSubModulesGetSubModules使用递归又转换到对模块的type获取,最终完成类型的获取。

做个测试:

// test
const store = new Store({
  actions: {
    root() {}
  },
  modules: {
    cart: {
      namespaced: true,
      actions: {
        add() {},
        remove() {}
      }
    },
    user: {
      namespaced: true,
      actions: {
        login() {}
      },
      modules: {
        admin: {
          namespaced: true,
          actions: {
            adminLogin() {}
          },
          modules: {
            goods: {
              // namespaced 为 false
              namespaced: false,
              actions: {
                add() {}
              }
            }
          }
        },
        editor: {
          namespaced: true,
          actions: {
            editorLogin() {}
          },
          modules: {
            post: {
              // 不写 namespaced
              actions: {
                create() {}
              }
            }
          }
        }
      }
    }
  }
})

store.dispatch('root')

一篇 TypeScript 实践指南(万字长文) Perfect!

完整代码:

// 工具函数
// 因为我们传的是 keyof ,但是我们只要 string 类型,所以可以直接 & 只取 string
type Prefix<P, K> = `${P & string}/${K & string}`

// 获取有前缀的子模块
// 因为我们的选项都是可选的,所以需要再加两个判断,传了对象,并且对象有索引
type GetPrefixSubModules<P, SM> = SM extends ModuleTree
  ? keyof SM extends string
    ? // 这里就是开始递归了,子模块作为 modules 传入
      Prefix<P, GetModulesType<SM>>
    : never
  : never
// 获取没有前缀的子模块
type GetSubModules<SM> = SM extends ModuleTree
  ? keyof SM extends string
    ? // 开始递归
      GetModulesType<SM>
    : never
  : never

// 获取有前缀的 actions 的 keys
// 因为我们的选项都是可选的,所以需要再加两个判断,传了对象,并且对象有索引
type GetPrefixActionKeys<P, A> = A extends ActionTree
  ? keyof A extends string
    ? Prefix<P, keyof A>
    : never
  : never
// 获取没有前缀的 actions 的 keys
type GetActionKeys<A> = A extends ActionTree
  ? keyof A extends string
    ? keyof A
    : never
  : never

// 简化内部操作,就把 Action 看作普通函数
type Action = () => void
interface ActionTree {
  [key: string]: Action
}
interface ModuleTree {
  [key: string]: Module
}

// 这边不要写 extends,会限制相关推断类型的,我们在后面单独判断
interface Module<N = boolean, A = ActionTree, M = ModuleTree> {
  // 这里我们简化一下操作,只要开启后就可以嵌套(真实情况还可以有很多特例)
  namespaced?: N
  actions?: A
  // modules 是可以嵌套的
  modules?: M
}

// 遍历 ModuleTree
type GetModulesType<MS extends ModuleTree> = {
  // 这里我们把里面的某个索引拿到(当前缀),再传入到单独获取类型的工具中
  [K in keyof MS]: GetModuleType<K, MS[K]>
}[keyof MS] // 遍历到所有的 value

// 递归获取 Module 的所有相关联的 type
type GetModuleType<P, M> = M extends Module<infer N, infer A, infer SubModules> // N 为 namespaced 的值,A 为模块的 actions 的值(ActionTree),SubModules 为 modules 的值
  ? /* 
  N extends true,因为 N 为 boolean,所以 N 其实是 true 和 false 的联合类型,所以两边都会触发,但我们这个的 N 因为受到了限制,只会是 true 或者 false
  */
    N extends true
    ? // 这里如果 namespaced 为 true 就返回有前缀的 actions 和所有子 modules 的 actions,否则就不加前缀
      GetPrefixActionKeys<P, A> | GetPrefixSubModules<P, SubModules>
    : GetActionKeys<A> | GetSubModules<SubModules>
  : never

interface StoreOptions<A extends ActionTree, M extends ModuleTree> {
  actions?: A
  modules?: M
}

interface DispatchOptions {
  root?: boolean
}
interface Payload<A> {
  type: A
}
interface Dispatch<T> {
  (type: T, payload?: any, options?: DispatchOptions): Promise<any>
  <P extends Payload<T>>(
    payloadWithType: P,
    options?: DispatchOptions
  ): Promise<any>
}

export declare class Store<A extends ActionTree, M extends ModuleTree> {
  // 这里只讲类型,不讲代码实现
  constructor(options: StoreOptions<A, M>)
  dispatch: Dispatch<keyof A | GetModulesType<M>>
  // ...
}

// test
const store = new Store({
  actions: {
    root() {}
  },
  modules: {
    cart: {
      namespaced: true,
      actions: {
        add() {},
        remove() {}
      }
    },
    user: {
      namespaced: true,
      actions: {
        login() {}
      },
      modules: {
        admin: {
          namespaced: true,
          actions: {
            adminLogin() {}
          },
          modules: {
            goods: {
              // namespaced 为 false
              namespaced: false,
              actions: {
                add() {}
              }
            }
          }
        },
        editor: {
          namespaced: true,
          actions: {
            editorLogin() {}
          },
          modules: {
            post: {
              // 不写 namespaced
              actions: {
                create() {}
              }
            }
          }
        }
      }
    }
  }
})

store.dispatch('root')

总结

本篇文章篇幅很长,很多地方其实都可以单独抽离出来作为一篇文章,但是感觉这样不太利于后期的整理和查阅,所以就合在一起写了,当然如果各位觉得这样观看效果不是很好的话也可以提出来,后面会作出改进。

再次强调(甩锅),本文仅为我在 typescript 中学习中的一些个人实践总结,并不能完全保证其正确性,并且没有引入太多复杂的类型,但 typescript 的能力肯定远远不止于此,在我看过的文章和项目中,就有很多高度应用 typescript 类型编程的实例。不过还是希望本文可以帮助各位了解 typescript 在项目中的一些实践操作,如果你可以用自己的方式完成实践篇的几个类型定义,相信你的 typescript 能力肯定能有所提高的。

最后,本文难免会有一些总结不到位的地方,还请各位看官及时指出或补充,作者会在第一时间进行修正。如果觉得本文对你有帮助的话,还请点个👍🏻哦。

参考