TS流水账

405 阅读18分钟

快6年前端了,还是菜鸟一枚。开发过的东西很多,用过的东西也很多,但是没有形成系统的认识。对于一个知识点,可能就是用过、知道,但是不知道为啥这样,或者这样用对不对,为什么对?知其然不知其所以然。所以准备写点流水账,对以前的知识做一个总结。

第一篇就写TS好了

TS用了两年半的时间了,基本是在业务代码里面使用,常用的地方:

  • 组件propsstate的定义,用来规范组件的使用
  • 工具方法的定义,方便智能提示和代码自注释
  • 数据格式定义,如Redux、表单配置、状态枚举等数据定义

这些只需要掌握一些TS基础和少量的额外工作,但好处是显而易见的:

  • TS类型报错,可以避免一些低级错误,同时写出符合类型定义的代码,自然能提升代码质量
  • 代码自注释,借助vscode的智能提示,基本很少需要再去写文字性的注释了。方法怎么用、组件怎么用,一目了然。
  • 整理思绪,我经常不知道自己代码写到那儿了,当前写的这个函数要实现哪些功能,而TS可以帮我组织代码。

既然是流水账,下面我会从TS基础、TS使用、项目实践三方面来谈。

TS基础

基础类型

布尔类型

let isDone: boolean = false;

数字类型

let decLiteral: number = 6;

字符串类型

let name: string = "bob";

数组类型

let list: number[] = [1, 2, 3]; // 常用
let list: Array<number> = [1, 2, 3];

元组类型

元组类型允许表示一个已知元素数量和类型的数组

let x: [string, number] = ['hello', 10] // 当访问一个越界的元素,会使用联合类型替代

枚举类型

enum Color {Red, Green, Blue} 
let c: Color = Color.Green;
// 下标默认从0开始递增
// 可以修改数字下标,后续递增
// 如果引入字符串当下标,则必须全部定义,或者后续出现数字下标
// 数字下班可以反向引用

Any (AnyScript)

表示任意类型

对js代码改造的时候或者第三方数据(如接口数据)定义的时候用得比较多

let notSure: any = 4;

Void

表示没有任何类型,比如方法没有返回值

function warnUser(): void { 
    console.log("This is my warning message");
}

Null 和 Undefined

let u: undefined = undefined; 
let n: null = null;

Never

表示永不存在的类型,总是抛错或者无限循环的情况

// 返回never的函数必须存在无法达到的终点 
function error(message: string): never {
    throw new Error(message); 
} 
// 推断的返回值类型为never 
function fail() { 
    return error("Something failed");
} 
// 返回never的函数必须存在无法达到的终点
function infiniteLoop(): never { 
    while (true) { } 
}

Object

object表示非原始类型,也就是除numberstringbooleansymbolnullundefined之外的类型。

declare function create(o: object | null): void;

高级类型

接口

创建一个新的类型,它能合适的描述一个复杂场景。

interface LabelValue {
  value: string | number
  label: string
}

function printLabel(labelObj: LabelValue) {
  console.log(labelObj.label);
}

printLabel({ label: '1', value: 1 });

可选属性

预设一个非必须的属性类型,后续使用该属性时,可以做类型检查

interface LabelValue {
  value?: string | number
  label: string
}

const label: LabelValue = { label: '姓名' };

索引类型

批量的预设属性,其他属性必须是索引类型的子类型

ts只有number和string类型的索引,number类型必须是string类型的子类型,应为number的索引都会被转成string

interface LabelValue {
  [key: string]: string | number
  [key: number]: number
  label: string
  1: number
}

const label: LabelValue = {
  label: '姓名', alias: '客户姓名', 1: 1, 2: 2,
};

或者这样表述

class Animal {
  name: string;
}
class Dog extends Animal {
  breed: string;
}

interface NotOkay {
  [key: string]: Animal;
  dog: Dog;
}

函数(接口表示法)

好处是可以描述带属性的函数

interface SearchFunc {
  (source: string, subString: string): boolean;
  length: number
}

function getSearch(): SearchFunc {
  const fn = (source, subString) => {
    const result = source.search(subString);
    return result > -1;
  };

  fn.length = 10

  return fn
}

类类型

interface ClockInterface {
  currentTime: Date;
  setTime(d: Date);
}
class Clock implements ClockInterface {
  currentTime: Date;

  setTime(d: Date) { this.currentTime = d; }

  constructor(h: number, m: number) { }
}

接口继承接口

interface Shape {
  color: string;
}

interface PenStroke {
  penWidth: number;
}

interface Square extends Shape, PenStroke {
  sideLength: number;
}

let square = <Square>{};
square.color = "blue";
square.sideLength = 10;
square.penWidth = 5.0;

接口继承类

接口继承的类拥有privateprotected属性时,只能被这个类或其子类所实现

class Control {
  private state: any;
}

interface SelectableControl extends Control {
  select(): void;
}

class Button extends Control implements SelectableControl {
  select() { }
}

class TextBox extends Control {
  select() { }
}

// 错误:“Image”类型缺少“state”属性。
class Image implements SelectableControl {
  select() { }
}

class Location {

}

class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
  greet() {
    return "Hello, " + this.greeting;
  }
}

let greeter = new Greeter("world");

修饰符

class Octopus {
  // public,默认值
  public gender: string;
  // private,只能在类的内部访问
  private age: number;
  // protected, 类的内部或子类访问
  protected phone: number;
  // 只能在声明时或构造方法内初始化
  readonly name: string;
  readonly numberOfLegs: number = 8;
  constructor(theName: string, theAge: number) {
    this.name = theName;
    this.age = theAge;
  }
}

函数

function add(x: number, y: number): number {
    return x + y;
}

// 完整的类型定义
let myAdd: (x: number, y: number) => number;
myAdd = function (x: number, y: number): number { return x + y; };

可选参数和默认参数

?表示可选参数,必须放在最后

=表示默认参数,参数类型等于默认值的类型,参数顺序没有要求

...表示剩余参数,放在最后

function buildName(firstName: string, lastName?: string) {
    if (lastName)
        return firstName + " " + lastName;
    else
        return firstName;
}

function buildName2(firstName = "Will", lastName: string) {
    return firstName + " " + lastName;
}
// 此时 buildName和buildName2 推断的类型一致

function buildName3(firstName: string, ...restOfName: string[]) {
    return firstName + " " + restOfName.join(" ");
}

this

interface Card {
    suit: string;
    card: number;
}
interface Deck {
    suits: string[];
    cards: number[];
    createCardPicker(this: Deck): () => Card;
}
let deck: Deck = {
    suits: ["hearts", "spades", "clubs", "diamonds"],
    cards: Array(52),
    createCardPicker: function (this: Deck) {
        return () => {
            let pickedCard = Math.floor(Math.random() * 52);
            let pickedSuit = Math.floor(pickedCard / 13);

            return { suit: this.suits[pickedSuit], card: pickedCard % 13 };
        }
    }
}

let cardPicker = deck.createCardPicker();
let pickedCard = cardPicker();

alert("card: " + pickedCard.card + " of " + pickedCard.suit);

规避函数被独立调用

interface UIElement {
    addClickListener(onclick: (this: void, e: Event) => void): void;
}

class Handler {
    info: string;
    onClickBad(this: Handler, e: Event) {
        this.info = e.message;
    }
}
let h = new Handler();
uiElement.addClickListener(h.onClickBad); // error!

重载

  • 从上到下,按参数去匹配,匹配到可用的就停止。因此,最精确的定义应放在最前面。
  • function pickCard(x): any并不是重载列表的一部分,是用于实现的,需要兼容所有类型。
let suits = ["hearts", "spades", "clubs", "diamonds"];

function pickCard(x: { suit: string; card: number; }[]): number;
function pickCard(x: number): { suit: string; card: number; };
function pickCard(x): any {
    if (typeof x == "object") {
        let pickedCard = Math.floor(Math.random() * x.length);
        return pickedCard;
    }
    else if (typeof x == "number") {
        let pickedSuit = Math.floor(x / 13);
        return { suit: suits[pickedSuit], card: x % 13 };
    }
}

let myDeck = [{ suit: "diamonds", card: 2 }, { suit: "spades", card: 10 }, { suit: "hearts", card: 4 }];

let pickedCard1 = myDeck[pickCard(myDeck)];
let pickedCard2 = pickCard(15);

泛型

一种宽泛类型的表示形式,真正使用时才能明确。

function identity<T>(arg: T): T {
    return arg;
}
// 明确指出泛型类型
let output = identity<string>("myString"); 
// 利用类型推断明确泛型类型
let output2 = identity("myString")

使用泛型变量

泛型变量可以自由的使用

function loggingIdentity<T>(arg: T[]): T[] {
    console.log(arg.length);
    return arg;
}

表示形式

function identity<T>(arg: T): T {
    return arg;
}

let myIdentity: <T>(arg: T) => T = identity;
let myIdentity2: <U>(arg: U) => U = identity; // 不同名
let myIdentity3: { <T>(arg: T): T } = identity; // 字面量

也可以抽离成单独的类型

function identity<T>(arg: T): T {
    return arg;
}

interface GenericIdentityFn {
    <T>(arg: T): T;
}
let myIdentity: GenericIdentityFn = identity;

// 更好的写法 可以明确定义T的类型,并且其他成员也可以使用
interface GenericIdentityFn2<T> {
    (arg: T): T;
}
let myIdentity2: GenericIdentityFn2<number> = identity;

泛型类

class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) { return x + y; };

let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) { return x + y; };

注意:类的静态属性不能使用这个泛型类型。

泛型约束

interface Lengthwise {
    length?: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
    console.log(arg.length);
    return arg;
}

loggingIdentity({ length: 10, value: 3 });

泛型互相约束

function getProperty<T, K extends keyof T>(obj: T, key: K) {
    return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a"); // okay
getProperty(x, "m"); // 类型“"m"”的参数不能赋给类型“"a" | "b" | "c" | "d"”的参数

一个更高级的例子

class BeeKeeper {
    hasMask: boolean;
}

class ZooKeeper {
    nametag: string;
}

class Animal {
    numLegs: number;
}

class Bee extends Animal {
    keeper: BeeKeeper;
}

class Lion extends Animal {
    keeper: ZooKeeper;
}

// new () => A 表示类的构造函数
function createInstance<A extends Animal>(c: new () => A): A {
    return new c();
}

createInstance(Lion).keeper.nametag;  // typechecks!
createInstance(Bee).keeper.hasMask;   // typechecks!

交叉类型

交叉类型是将多个类型合并为一个类型。例如, Person & Serializable & Loggable同时是 Person  Serializable  Loggable

大多是在混入(mixins)或其它不适合典型面向对象模型的地方看到交叉类型的使用。

function extend<T, U>(first: T, second: U): T & U {
  let result = <T & U>{};
  for (let id in first) {
    (<any>result)[id] = (<any>first)[id];
  }
  for (let id in second) {
    if (!result.hasOwnProperty(id)) {
      (<any>result)[id] = (<any>second)[id];
    }
  }
  return result;
}

联合类型

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}'.`);
}

let indentedString = padLeft("Hello world", 1);
let indentedString2 = padLeft("Hello world", ' ts');

联合类型表示一个值可以是几种类型之一。 我们用竖线( |)分隔每个类型,所以 number | string | boolean表示一个值可以是 number, string,或 boolean

如果一个值是联合类型,我们只能访问此联合类型的所有类型里共有的成员。

interface Bird {
  fly();
  layEggs();
}

interface Fish {
  swim();
  layEggs();
}

function getSmallPet(): Fish | Bird {
  // ...
}

let pet = getSmallPet();
pet.layEggs(); // okay
pet.swim();    // errors 类型“Bird”上不存在属性“swim”

访问特性成员

// 1 类型断言
if ((<Fish>pet).swim) {
  (<Fish>pet).swim();
}
else {
  (<Bird>pet).fly();
}

// 2 自定义的类型保护
function isFish(pet: Fish | Bird): pet is Fish {
  return (<Fish>pet).swim !== undefined;
}

if (isFish(pet)) {
  pet.swim();
}
else {
  pet.fly();
}

typeof类型保护

typeof会被TypeScript自动识别为一个类型保护

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"必须是 "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function"。

instanceof类型保护

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'
}

类型别名

类型别名会给一个类型起个新名字。起别名不会新建一个类型 - 它创建了一个新 名字来引用那个类型。

类型别名有时和接口很像,但是可以作用于原始值,联合类型,元组以及其它任何你需要手写的类型。

type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;
function getName(n: NameOrResolver): Name {
  if (typeof n === 'string') {
    return n;
  }
  else {
    return n();
  }
}

类型别名也可以是泛型

type Tree<T> = {
  value: T;
  left: Tree<T>;
  right: Tree<T>;
}

与交叉类型一起使用,我们可以创建出一些十分稀奇古怪的类型。

type LinkedList<T> = T & { next: LinkedList<T> };

interface Person {
  name: string;
}

var people: LinkedList<Person>;
var s = people.name;
var s = people.next.name;
var s = people.next.next.name;
var s = people.next.next.next.name;

接口 vs. 类型别名

typeinterface都可以描述一个复杂类型,可以互相混用

interface Name {
  name: string
}

type Age = {
  age: number
}

// 拓展
interface User extends Name {
  age: number
}

type User2 = Age & {
  name: string
}

// 混用
interface User3 extends Age {
  name: string
}

type User4 = Name & {
  age: number
}

type可以interface不行

  • type 可以声明基本类型别名,联合类型
// 基本类型别名
type Int32 = number

// 联合类型
type Key = number | string
  • type 可以使用typeof获取实例的类型
const div = document.createElement('div')
type B = typeof div // type B = HTMLDivElement
  • type 能使用 in 关键字生成映射类型,但 interface 不行
type Keys = 'firstname' | 'surname'

type DudeType = {
  [key in Keys]: string
}

const test: DudeType = {
  firstname: 'Pawel',
  surname: 'Grzybek'
}

// 报错 映射的类型可能不声明属性或方法
interface DudeType2 {
  [key in keys]: string
}

interface可以type不行

  • interface可以进行声明合并,type则会报错
interface User {
  name: string
}

interface User {
  age: number
}

let user:User // name和age属性都有

总的来说:interface用于创建新的类型,一般是原子性的公用的。type用于创建一个类型别名,是处理后的类型。

字符串字面量类型

指定字符串必须的固定值

type Easing = "ease-in" | "ease-out" | "ease-in-out";
function animate(dx: number, dy: number, easing: Easing) {
}
animate(1, 2, 'ease-in')

数字字面量类型

function rollDie(): 1 | 2 | 3 | 4 | 5 | 6 {}

可辨识联合

  1. 具有普通的单例类型属性— 可辨识的特征
  2. 一个类型别名包含了那些类型的联合— 联合
  3. 此属性上的类型保护。
interface Square {
  kind: "square";
  size: number;
}
interface Rectangle {
  kind: "rectangle";
  width: number;
  height: number;
}
interface Circle {
  kind: "circle";
  radius: number;
}

type Shape = Square | Rectangle | Circle;
function area(s: Shape) {
  switch (s.kind) {
    case "square": return s.size * s.size;
    case "rectangle": return s.height * s.width;
    case "circle": return Math.PI * s.radius ** 2;
  }
}

索引类型

function pluck<T, K extends keyof T>(o: T, names: K[]): T[K][] {
  return names.map(n => o[n]);
}

interface Person {
  name: string;
  age: number;
}
let person: Person = {
  name: 'Jarid',
  age: 35
};
let strings: string[] = pluck(person, ['name']);

keyof T 索引类型查询操作符。 对于任何类型 T, keyof T的结果为 T上已知的公共属性名的联合。

let personProps: keyof Person; // 'name' | 'age'

T[K]  索引访问操作符

interface Person {
  name: string;
  age: number;
}
let person: Person = {
  name: 'Jarid',
  age: 35
};
function getProperty<T, K extends keyof T>(o: T, name: K): T[K] {
  return o[name]; // o[name] is of type T[K]
}
let myName: string = getProperty(person, 'name');
let age: number = getProperty(person, 'age');

keyof和 T[K]与字符串索引签名进行交互。 如果你有一个带有字符串索引签名的类型,那么 keyof T会是 string

interface Map<T> { 
    [key: string]: T; 
} 
let keys: keyof Map<number>; // string
let value: Map<number>['foo']; // number

映射类型

如何一个已知的类型每个属性都变为可选的或者只读的?

interface Person {
  name: string;
  age: number;
}

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
}
type Partial<T> = {
  [P in keyof T]?: T[P];
}

type PersonPartial = Partial<Person>;
type ReadonlyPerson = Readonly<Person>;

在映射类型里,新类型以相同的形式去转换旧类型里每个属性。

下面来看看最简单的映射类型和它的组成部分:

type Keys = 'option1' | 'option2';
type Flags = { [K in Keys]: boolean };

它的语法与索引签名的语法类型,内部使用了 for .. in。 具有三个部分:

  1. 类型变量 K,它会依次绑定到每个属性。
  2. 字符串字面量联合的 Keys,它包含了要迭代的属性名的集合。
  3. 属性的结果类型。

另外类转换是同态的,编译器知道在添加任何新属性之前可以拷贝所有存在的属性修饰符。 例如,假设 Person.name是只读的,那么 Partial<Person>.name也将是只读的且为可选的。

一个复杂的列子

type Proxy<T> = {
  get(): T;
  set(value: T): void;
}
type Proxify<T> = {
  [P in keyof T]: Proxy<T[P]>;
}
function proxify<T>(o: T): Proxify<T> {
  // ... wrap proxies ...
}
let proxyProps = proxify(props);

类型推断

类型推断可以帮助我们精简代码,不至于满屏都是类型定义。

基础

初始化变量和成员,设置默认参数值和函数返回值时,会自动推断类型。

let x = 3; // number
function add(x = 0, y = 1) {
    return x + y
} // function add(x?: number, y?: number): number

多个类型时,会取最佳通用类型

let x = [0, 1, null]; // number[]

class Animal { }
class Rhino extends Animal { }
class Elephant extends Animal { }
class Snake extends Animal { }
let zoo = [new Rhino(), new Elephant(), new Snake()]; //  ts找不到Animal类型 所以推断出(Rhino | Elephant | Snake)[]
let zoo2 = [new Rhino(), new Elephant(), new Snake(), new Animal()]; //  有通用类型 使用通用类型 Animal[]
let zoo3: Animal[] = [new Rhino(), new Elephant(), new Snake()]; // 或者明确指出

上下文归类

TS在上下文关系中能找到关联关系时,会自动推断类型。

type Adder = (a: number, b: number) => number;
let foo: Adder = (a, b) => {
  a = 'hello'; // Error:不能把 'string' 类型赋值给 'number' 类型
  return a + b;
};

上下文归类会在很多情况下使用到。 通常包含函数的参数,赋值表达式的右边,类型断言,对象成员和数组字面量和返回值语句。

类型断言

程序推断不准确,但自己能确定变量的类型的时候

let someValue: any = "this is a string"; 
let strLength: number = (<string>someValue).length;
let strLength: number = (someValue as string).length; // tsx文件内只能用as

类型兼容性

TypeScript里的类型兼容性是基于结构子类型的。 结构类型是一种只使用其成员来描述类型的方式。

TypeScript的结构性子类型是根据JavaScript代码的典型写法来设计的。 因为JavaScript里广泛地使用匿名对象,例如函数表达式和对象字面量,所以使用结构类型系统来描述这些类型比使用名义类型系统更好。

interface Named {
    name: string;
}

class Person {
    name: string;
}

let p: Named;
p = new Person();

基本规则: 如果x要兼容y,那么y至少具有与x相同的属性。

这个比较过程是递归进行的,检查每个成员及子成员。

interface Named {
    name: string;
}

let x: Named;
let y = { name: 'Alice', location: 'Seattle' };
x = y; // ok y具有x的属性

// 函数参数也是同样的规则
function greet(n: Named) {
    console.log('Hello, ' + n.name);
}
greet(y);

函数兼容

函数参数列表

x是否能赋值给yx的每个参数必须能在y里找到对应类型的参数。允许忽略参数,原因是忽略额外的参数在JavaScript里是很常见的。

let x = (a: number) => 0;
let y = (b: number, s: string) => 0;

y = x; // OK
// 赋值错误,因为`y`有个必需的第二个参数,但是`x`并没有,所以不允许赋值。
x = y; // Error 不能将类型“(b: number, s: string) => number”分配给类型“(a: number) => number”。

函数返回值

let x = () => ({ name: 'Alice' });
let y = () => ({ name: 'Alice', location: 'Seattle' });

x = y; // OK
y = x; // Error 中缺少属性 "location"

可选参数及剩余参数

  • 比较函数兼容性的时候,可选参数与必须参数是可互换的。
  • 源类型上有额外的可选参数不是错误,目标类型的可选参数在源类型里没有对应的参数也不是错误。
  • 当一个函数有剩余参数时,它被当做无限个可选参数。
// 比较函数兼容性的时候,可选参数与必须参数是可互换的。
let x = (a: number, b?: string[]) => 0;
let y = (c: number, d: string[]) => 0;

x = y
y = x
// 源类型上有额外的可选参数不是错误, 目标类型的可选参数在源类型里没有对应的参数也不是错误。
let x = (a: number, b?: string[]) => 0;
let y = (c: number) => 0;

x = y
y = x
// 当一个函数有剩余参数时,它被当做无限个可选参数。
function invokeLater(args: any[], callback: (...args: any[]) => void) {
}

invokeLater([1, 2], (x, y) => console.log(x + ', ' + y));
invokeLater([1, 2], (x?, y?) => console.log(x + ', ' + y));

函数重载

对于有重载的函数,源函数的每个重载都要在目标函数上找到对应的函数签名。 这确保了目标函数可以在所有源函数可调用的地方调用。

枚举兼容

枚举类型与数字类型互相兼容。

不同枚举类型之间是不兼容的。

enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };

let y: Status = 1
let z: number = Status.Ready

let x = Status.Ready;
x = Color.Green;  // Error 不能将类型“Color.Green”分配给类型“Status”。

类兼容

比较两个类类型的对象时,只有实例的成员会被比较。 静态成员和构造函数不在比较的范围内。

但是类的私有成员和受保护成员来源必须相同。

class Animal {
  static hand: string
  feet: number;
  constructor(name: string, numFeet: number) { }
}

class Size {
  static hand: number
  feet: number;
  constructor(numFeet: number) { }
}

let a: Animal;
let s: Size;

a = s;  // OK
s = a;  // OK

泛型兼容

泛型兼容要看具体影响的范围

// 下面的例子,因为没有使用泛型参数,所以兼容
interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;

x = y;

// data 使用了泛型参数,会导致不兼容
interface NotEmpty<T> {
  data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;

x = y;  // Error 不能将类型“string”分配给类型“number”。

// 没指定泛型类型的时候,会被当成any
let identity = function <T>(x: T) {
  // ...
}

let reverse = function <U>(y: U) {
  // ...
}

identity = reverse;  // OK, 因为 (x: any) => void matches (y: any) => void

TS使用

模块和命名空间

为了和ES6概念保持一致,目前模块一般指外部模块,命名空间一般指内部模块。

模块moudle

TS里面模块和ES6的概念基本一致,文件里面含有export或者import则会自动的被识别为一个模块。否则视为全局可见。

// 定义
const numberRegexp = /^[0-9]+$/;

export class ZipCodeValidator {
  isAcceptable(s: string) {
    return s.length === 5 && numberRegexp.test(s);
  }
}

// 引用
import ZipCodeValidator from "./ZipCodeValidator";

let myValidator = new ZipCodeValidator();

命名空间namespace 

命名空间定义了标识符的可见范围。主要用于当代码庞大时,可以对函数/类/接口等进行分组管理。

添加了export外部才可以访问

namespace Space {  // namespace:声明命名空间
  const name: string = 'space'
  // export:暴露给外部访问
  export class Test {
    name: string
  }
  // 命名空间也可用类型
  export interface Man {
    name: string
  }
  export function getName() {
    console.log('space')
  }
}

// 使用:
Space.name // 报错:Space上没有name属性
Space.getName()  // 输出:'space'
let t: Space.Test

编译后

var Space;
(function (Space) {
    var name = 'space';
    var Test = /** @class */ (function () {
        function Test() {
        }
        return Test;
    }());
    Space.Test = Test;
    function getName() {
        console.log('space');
    }
    Space.getName = getName;
})(Space || (Space = {}));

可以看出命名空间被编译成了一个全局对象,因此程序中需要暴露成全局对象,可以使用namespace

一般会使用命名空间定义全局对象

declare namespace D3 {
  export interface Selectors {
    select: {
      (selector: string): Selection;
      (element: EventTarget): Selection;
    };
  }
  export interface Event {
    x: number;
    y: number;
  }
  export interface Base extends Selectors {
    event: Event;
  }
}
declare var d3: D3.Base;

命名空间合并

命名空间可以与其它类型的声明进行合并。 TypeScript使用这个功能去实现一些JavaScript里的设计模式。

合并命名空间和类

class Album {
  label: Album.AlbumLabel;
}
namespace Album {
  export class AlbumLabel { }
}

合并命名空间和函数

function buildLabel(name: string): string {
  return buildLabel.prefix + name + buildLabel.suffix;
}

namespace buildLabel {
  export let suffix = "";
  export let prefix = "Hello, ";
}

命名空间和枚举

enum Color {
  red = 1,
  green = 2,
  blue = 4
}

namespace Color {
  export function mixColor(colorName: string) {
    if (colorName == "yellow") {
      return Color.red + Color.green;
    }
    else if (colorName == "white") {
      return Color.red + Color.green + Color.blue;
    }
    else if (colorName == "magenta") {
      return Color.red + Color.blue;
    }
    else if (colorName == "cyan") {
      return Color.green + Color.blue;
    }
  }
}

模块和命名空间的区别

就像开头所说的,命名空间用于内部模块,因为它是全局空间下的一个普通js对象,不能避免全局命名空间污染的问题,它很难去组织文件的依赖关系。而模块用于外部模块,它天生就被设计用于组织文件的依赖关系。

平时开发项目时不建议用命名空间,它通常用于.d.ts文件,用来给传统的js库提供类型声明。

声明含义

一个名字,多种意义

一个给定的名字A,我们可以找出三种不同的意义:一个类型,一个值或一个命名空间。 要如何去解析这个名字要看它所在的上下文是怎样的。 比如,在声明 let m: A.A = A;, A首先被当做命名空间,然后做为类型名,最后是值。

内置组合

class同时出现在类型列表里。 class C { }声明创建了两个东西: 类型C指向类的实例结构, C指向类构造函数。 枚举声明拥有相似的行为。

用户组合

export var Bar: { a: Bar };
export interface Bar {
  count: number;
}

使用Bar做为类型和值

import { Bar } from './foo';
let x: Bar = Bar.a;
console.log(x.count);

高级组合

namespace X {
  export interface Y { }
  export class Z { }
}

// ... elsewhere ...
namespace X {
  export var Y: number
  export namespace Z {
    export class C { }
  }
}
type X = string;

在这个例子里,第一个代码块创建了以下名字与含义:

  • 一个值X(因为namespace声明包含一个值,Z
  • 一个命名空间X(因为namespace声明包含一个值,Z
  • 在命名空间X里的类型Y
  • 在命名空间X里的类型Z(类的实例结构)
  • X的一个属性值Z(类的构造函数)

第二个代码块创建了以下名字与含义:

  • Ynumber类型),它是值X的一个属性
  • 一个命名空间Z
  • Z,它是值X的一个属性
  • X.Z命名空间下的类型C
  • X.Z的一个属性值C
  • 类型X

declare 声明

declare就是告诉TS编译器你担保这些变量和模块存在,并声明了相应类型,编译的时候不需要提示错误!

declare声明的类型是全局的,使用的时候不需要再次被import导入

声明一个类型

typeinterface可以省略declare

declare type Asd {
  name: string;
}

声明一个模块

declare module '*.css';
declare module '*.less';
declare module '*.png';
declare module '*.text' {
  const content: string
  export default content
}
declare module 'json/*' {
  const value: any
  export default value
}

声明一个变量

declare let wx: any
declare const process: {
  env: {
    TARO_ENV: 'weapp' | 'swan' | 'alipay' | 'h5' | 'rn' | 'tt' | 'quickapp' | 'qq' | 'jd'
    [key: string]: any
  }
}

声明一个命名空间

declare namespace jQuery {
  function ajax(url: string, settings?: any): void;
  const version: number
  class Event {
    blur(eventType: EventType): void
  }
  enum EventType {
    CustomClick
  }
}

注意

声明语句中只能定义类型,不能写具体实现

declare const jQuery = function (selector) {
  return document.querySelector(selector)
} // 报错 不能在环境上下文中声明实现。

对于使用export的文件(NPM、UMD模块),使用 declare 不再会声明一个全局变量,而只会在当前文件中声明一个局部变量。使用需要通过exportimport来导入导出。

declare const name: string;
declare function getName(): string;

export { name, getName };
import { name, getName } from 'foo';

console.log(name);
let myName = getName();

当(NPM、UMD模块)需要扩展全局变量时,通过declare global来声明

class Observable<T> {
  // ... still no implementation ...
}

declare global {
  interface Array<T> {
    toObservable(): Observable<T>;
  }
}

Array.prototype.toObservable = function () {
  // ...
}

export {};

声明文件 *.d.ts

声明语句单独放在一个文件内(手写或者自动生成),这个就是声明文件,以.d.ts结尾。

在不同的场景下,声明文件的内容和使用方式会有所区别。

库的使用场景主要有以下几种:

  • [全局变量]:通过 <script> 标签引入第三方库,注入全局变量
  • [npm 包]:通过 import foo from 'foo' 导入,符合 ES6 模块规范
  • [UMD 库]:既可以通过 <script> 标签引入,又可以通过 import 导入
  • [直接扩展全局变量]:通过 <script> 标签引入后,改变一个全局变量的结构
  • [在 npm 包或 UMD 库中扩展全局变量]:引用 npm 包或 UMD 库后,改变一个全局变量的结构
  • [模块插件]:通过 <script> 或 import 导入后,改变另一个模块的结构

全局变量

全局变量是最简单的一种场景,比如通过 <script> 标签引入 jQuery,注入全局变量 $ 和 jQuery

使用全局变量的声明文件时,如果是以 npm install @types/xxx --save-dev 安装的,则不需要任何配置。如果是将声明文件直接存放于当前项目中,则建议和其他源码一起放到 src 目录下(或者对应的源码目录下):

/path/to/project
├── src
|  ├── index.ts
|  └── jQuery.d.ts
└── tsconfig.json
interface JQueryStatic {
  // ....
}

declare var jQuery: JQueryStatic;
declare var $: JQueryStatic;

npm 包

比如我们通过 import foo from 'foo' 导入一个 npm 包,但是包没有对应的声明文件,这时候就需要我们自己去补充了。

常用的做法是,创建一个 types 目录,专门用来管理自己写的声明文件,将 foo 的声明文件放到 types/foo/index.d.ts 中。这种方式需要配置下 tsconfig.json 中的 paths 和 baseUrl 字段。

目录结构:

/path/to/project
├── src
|  └── index.ts
├── types
|  └── foo
|     └── index.d.ts
└── tsconfig.json

tsconfig.json 内容:

{
    "compilerOptions": {
        "module": "commonjs",
        "baseUrl": "./",
        "paths": {
            "*": ["types/*"]
        }
    }
}

如此配置之后,通过 import 导入 foo 的时候,也会去 types 目录下寻找对应的模块的声明文件了。

// types/foo/index.d.ts
// 注意: .d.ts 文件中的顶级声明必须以 "declare" 或 "export" 修饰符开头。
declare const name: string;
declare function getName(): string;
declare class Animal {
    constructor(name: string);
    sayHi(): string;
}
declare enum Directions {
    Up,
    Down,
    Left,
    Right
}
interface Options {
    data: any;
}

export { name, getName, Animal, Directions, Options };

UMD 库

既可以通过 <script> 标签引入,又可以通过 import 导入的库,称为 UMD 库。相比于 npm 包的类型声明文件,我们需要额外声明一个全局变量,为了实现这种方式,ts 提供了一个新语法 export as namespace

// types/foo/index.d.ts

export as namespace foo;
export = foo; // 兼容commonjs 库,但是不推荐

declare function foo(): string;
declare namespace foo {
    const bar: number;
}

直接扩展全局变量

比如第三方库扩展了 String 类型,通过声明合并,使用 interface String 即可给 String 添加属性或方法。

interface String {
    prependHello(): string;
}

也可以使用 declare namespace 给已有的命名空间添加类型声明

// types/jquery-plugin/index.d.ts

declare namespace JQuery {
    interface CustomOptions {
        bar: string;
    }
}

interface JQueryStatic {
    foo(options: JQuery.CustomOptions): string;
}
// src/index.ts

jQuery.foo({
    bar: ''
});

在 npm 包或 UMD 库中扩展全局变量

上文已经说过,使用declare global即可

模块插件

有时通过 import 导入一个模块插件,可以改变另一个原有模块的结构。ts 提供了一个语法 declare module,它可以用来扩展原有模块的类型。

需要在类型声明文件中先引用原有模块,再使用 declare module 扩展原有模块

// types/moment-plugin/index.d.ts

import * as moment from 'moment';

declare module 'moment' {
    export function foo(): moment.CalendarKey;
}

TS配置解析

项目中,TS配置一般集中在tsconfig.json文件中

1. files

由于默认情况下,tsc会编译当前项目下的所有ts文件,我们可以通过files配置来指定编译的入口文件

{
    //"files": [ // files中不能使用通配符进行配置
      //  "ts/**/*.ts"
    //]
    "files": [
        "ts/index.ts" // 对ts目录下的index.ts文件进行编译
    ]
}

2. include

include弥补了files不能使用通配符的限制,且可以同时使用,最终编译的源文件包含filesinclude的合集。

{
    "files": [
        "foo/foo.ts" // 包含foo目录下的foo.ts
    ],
    "include": [ // 可以使用通配符
        "ts/**/*.ts" // 包含ts目录下的所有ts文件
    ]
}

3. exclude

exclude可以排除include配置中包含的文件,但是无法排除files中的文件。exclude通常用来排除测试文件。

exclude的默认值是["node_modules", "bower_components", "jspm_packages"]加上outDir

{
    "include": [
        "ts/**/*.ts"
    ],
    "exclude": ["node_modules", "ts/test"] // 排除对ts目录下的test目录下的测试文件的编译
}

注意,即使在exclude中指定的被忽略文件,还是可以通过import操作符、types操作符、///<reference操作符以及在files选项中添加配置的方式对这些被忽略的代码文件进行引用的

如果"files""include"都没有被指定,编译器默认包含当前目录和子目录下所有的TypeScript文件(.ts.d.ts 和 .tsx),排除在"exclude"里指定的文件。JS文件(.js.jsx)也被包含进来如果allowJs被设置成true

includeexclude都支持使用通配符:

  • *匹配零个或者多个字符(包括目录的分隔符)
  • ?匹配任一字符(包括目录分隔符)
  • **/匹配任何层级的嵌套目录

4. compilerOptions

这是一个编译选项配置,用于控制编译过程和编译结果。常用的编译选项为:

4.1 noEmitOnError

当编译源文件出现错误的时候,是否继续输出编译结果。默认为false。

{
    "compilerOptions": {
        "noEmitOnError": true // 编译的源文件中存在错误的时候不再输出编译结果文件
    }
}

4.2 outDir

指定编译结果的输出目录的。在不指定outDir的时候,默认是将编译结果输出文件输出到源文件所在目录下。

{
    "compilerOptions": {
        "outDir": "./dist" // 将所有编译结果输出到dist目录下
    }
}

4.3 noImplicitAny

控制当源文件中存在隐式的any的时候是否报错,默认为false

// ts/index.ts
function foo(bar) { // bar参数存在隐式any
    console.log(bar);
}
{
    "compilerOptions": {
        "noImplicitAny": true, // 当源文件中存在隐式any的时候报错
    }
}

4.4 noImplicitThis

控制当源文件中存在this的值是any的时候是否报错,默认为false

// ts/index.ts
function foo(bar: string) {
    console.log(this.str); // 这里的this为any
}
{
     "compilerOptions": {
        "noImplicitThis": true, // 当源文件中存在this为any的时候报错
     }
}
// 改正方法为,显示指定this的类型
class Foo {
    str: string;
}
function foo(this: Foo, bar: string) { // 指定this为Foo类型的实例
    console.log(this.str);
}

4.5 target

控制编译后输出的是什么js版本。即生成的js符合什么版本的js规范,其默认值为es3,如下:

// ts/index.ts
const str = "this is a string";
{
     "compilerOptions": {
        "target": "es6"
     }
}
// dist/index.js编译输出结果
const str = "this is a string";

4.6 lib

指定要引入的库文件,属性值为一个数组,有es5es6es7dom四个值可选,默认会引入dom库,但是如果配置了lib,那么就只会引入指定的库了。如:

// ts/index.ts
document.getElementById("#app");
{
    "compilerOptions": {
        "lib": ["es6"], // 只引入es6的库文件,不引入dom的库文件
        "target": "es6"
    }
}

lib的配置和target也有关,target默认值为es3,所以当ts文件中使用到了Promise等全局类库的时候,就无法解析了,这个时候我们可以通过lib配置,引入es6的全局类库;当然我们也可以直接将target设置为es6,那么就可以解析Promise了。

4.7 module

指定要使用的模块标准,如果不显式配置module,那么其值与target的配置有关,其默认值为target === "es3" or "es5" ?"commonjs" : "es6"

可选值 "None", "CommonJS", "AMD", "System", "UMD", "ES6"或 "ES2015"

{
    "compilerOptions": {
        "module": "commonjs", // 指定使用的模块标准
    }
}

NodeJS 使用 CommonJS,浏览器里可以使用 ESM,不过现在的打包工具,会自动处理 CommonJS 和 ESM 的差异,并包装成符合指定模块化规范的代码

4.8 removeComments

指定编译输出文件中是否删除源文件中的注释,默认为false

{
    "compilerOptions": {
        "removeComments": true, // 是否在输出文件中清除源文件中的注释
    }
}

4.9 alwaysStrict

是否始终以严格模式检查每个模块,并且在编译后的输出结果中加入"use strict";默认为false。

{
    "compilerOptions": {
        "alwaysStrict": true, // 始终以严格模式检查每个模块,并且在编译后的结果文件中加入"use strict";
    }
}

4.10 declaration

指定是否在编译完成后生成相应的*.d.ts文件,默认为false,即不生成对应的声明文件,只有当你的代码需要给其他模块引用的时候才需要生成相应的类型声明文件:

{
    "compilerOptions": {
        "declaration": true, // 用于指定是否在编译完成后生成相应的*.d.ts文件
    }
}

4.11 moduleResolution

配置模块的解析规则,主要有两种,分别为classicnode。默认值为module ==="amd" or "system" or "es6" or "es2015"?"classic" : "node",所以其默认值和module的配置有关联,由于module的默认值和target有关,而target默认值为es3,所以module的默认值commonjs,所以moduleResolution的默认值为node。这里解释一下classic和node两种解析规则的不同:


假设用户主目录下有一个ts-test的项目,里面有一个src目录,src目录下有一个a.ts文件,即/Users/**/ts-test/src/a.ts

  • classic模块解析规则:

    相对路径模块: 只会在当前相对路径下查找是否存在该文件(.ts文件),不会作进一步的解析,如"./src/a.ts"文件中,有一行import { b } from "./b",那么其只会检测是否存在"./src/b.ts",没有就算找不到。

    非相对路径模块: 编译器则会从包含导入文件的目录开始依次向上级目录遍历尝试定位匹配的ts文件或者d.ts类型声明文件,如果/Users/**/ts-test/src/a.ts文件中有一行import { b } from "b",那么其查找过程如下:

    /Users/**/ts-test/src/b.ts
    /Users/**/ts-test/src/b.d.ts
    /Users/**/ts-test/b.ts
    /Users/**/ts-test/b.d.ts
    /Users/**/b.ts
    /Users/**/b.d.ts
    /Users/b.ts
    /Users/b.d.ts
    /b.ts
    /b.d.ts
    
  • node模块解析规则:

    相对路径

    比如,有一个导入语句import { b } from "./moduleB"/root/src/moduleA.ts里,会以下面的流程来定位"./moduleB"

    1. /root/src/moduleB.ts
    2. /root/src/moduleB.tsx
    3. /root/src/moduleB.d.ts
    4. /root/src/moduleB/package.json (如果指定了"types"属性)
    5. /root/src/moduleB/index.ts
    6. /root/src/moduleB/index.tsx
    7. /root/src/moduleB/index.d.ts

    非相对路径

    /root/src/moduleA.ts文件里的import { b } from "moduleB"会以下面的查找顺序解析:

    1. /root/src/node_modules/moduleB.ts
    2. /root/src/node_modules/moduleB.tsx
    3. /root/src/node_modules/moduleB.d.ts
    4. /root/src/node_modules/moduleB/package.json (如果指定了"types"属性)
    5. /root/src/node_modules/moduleB/index.ts
    6. /root/src/node_modules/moduleB/index.tsx
    7. /root/src/node_modules/moduleB/index.d.ts
    8. /root/node_modules/moduleB.ts
    9. /root/node_modules/moduleB.tsx
    10. /root/node_modules/moduleB.d.ts
    11. /root/node_modules/moduleB/package.json (如果指定了"types"属性)
    12. /root/node_modules/moduleB/index.ts
    13. /root/node_modules/moduleB/index.tsx
    14. /root/node_modules/moduleB/index.d.ts

    一直找到最上层的node_modules

4.12 baseUrl

拓宽引入非相对模块时的查找路径的默认值是"./"

所有非相对模块导入都会被当做相对于 baseUrl,译器在node_modules中没有找到的情况下,还会到baseUrl中指定的目录下查找。

4.13 paths

这个是配合baseUrl一起使用的,因为其是相对于baseUrl所在的路径的,主要用于到baseUrl所在目录下查找的时候进行的路径映射

有时候模块不是直接放在baseUrl下的,比如jquery模块地导入,在运行时可能被解释为node_modules/jquery/dist/jquery.slim.min.js

tsconfig.json文件里的"paths"被用来支持这样的声明映射

{
  "compilerOptions": {
    "baseUrl": ".", // paths存在则必须指定
    "paths": {
      "jquery": ["node_modules/jquery/dist/jquery"] // 此处映射是相对于"baseUrl"
    }
  }
}

"paths"是相对于"baseUrl"进行解析

路径映射也常用于公共模块路径的简写

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@srcTypes/*": [
        "src/types/*"
      ],
      "@constants/*": [
        "src/constants/*"
      ],
      "@commands/*": [
        "src/commands/*"
      ],
      "@utils/*": [
        "src/utils/*"
      ]
    }
  }
}

使用时,我们就可以不必写很长的相对路径了

import XXX from "@utils/getPluginFile";
import { xx } from "@utils/checktype";
import { xx } from "@utils/createContext";

4.14 typeRoots

指定类型声明文件的查找路径。默认值为node_modules/@types,即在node_modules下的@types里面查找。需要注意的是这里仅仅是d.ts文件的查找路径

// projectRoot/src/index.ts
import foo from "foo";
{
    "compilerOptions": {
        "typeRoots": [
            "node_modules/@types", // 默认值
            "./typings"
        ]
    }
}

在其他情况都找不到foo模块的时候,编译器还会到项目根目录下的typings目录下去查找有没有foo目录里面是否有一个index.d.ts类型声明文件,并且只能识别目录下的.d.ts文件,不能识别.ts文件

4.15 types

指定需要包含的模块只有在这里列出的模块的声明文件才会被加载进来,其属性值为一个数组,如果将types设置为一个空的数组,那么typeRoots配置的目录里的声明文件都将不会被加载进来。

// ts/index.ts
import http = require("http"); // 引入了node里的http模块
console.log(http);
{
    "compilerOptions": {
        "typeRoots": [
            "node_modules/@types" // 默认值
        ],
        "types": ["node"], // 将@types/node里的类型声明文件引入进来
    }
}

完整的配置编译选项配置

TS项目实践

认识内置工具类型

首先我们从TS内置工具类型开始,学习如何在项目中得心应手的使用这门语言

Partial 属性全部转为可选

type Partial<T> = {
    [K in keyof T]?: T[K];
};

interface UserInfo {
    name:string;
    age:number;
}

// 这里会将 UserInfo 所有的属性变为可选
const foo:Partial<UserInfo> = {
    name:"张三" 
}

keyof:索引类型查询操作符,获取公共属性名的联合类型

class Person {
    private sex: 'M' | 'W'
    name: string
    age: number
}

keyof Person // 'name' | 'age'

其他例子

interface Person {
    name: string;
    age: number;
    location: string;
}

type K1 = keyof Person; // "name" | "age" | "location"
type K2 = keyof Person[];  // number | "length" | "push" | "concat" | ...
type K3 = keyof { [x: string]: Person };  // string | number

// keyof也可以操作基本类型
let K1: keyof boolean; // let K1: "valueOf"
let K2: keyof number; // let K2: "toString" | "toFixed" | "toExponential" | ...
let K3: keyof symbol; // let K1: "valueOf"

T[K]:索引访问操作符,获取索引的类型

interface Person {
    name: string;
    age: number;
    location: string;
}

type P1 = Person["name"];  // string
type P2 = Person["name" | "age"];  // string | number
type P3 = string["charAt"];  // (pos: number) => string
type P4 = string[]["push"];  // (...items: string[]) => number
type P5 = string[][0];  // string

K in:内部使用for .. in循环,依次把索引赋值给K变量

Required 属性转为必选

type Required<T> = {
    [P in keyof T]-?: T[P];
};

interface UserInfo {
    name?:string;
    age?:number;
}

// 这里会将 UserInfo 所有可选的属性变为必选
const foo:Required<UserInfo> = {
    name:"张三",
    age:18
}

-?:去除可选属性

Readonly 属性变为只读

type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

interface UserInfo {
    name?:string;
    age?:number;
}
 
const foo:Readonly<UserInfo> = {
    name:"张三",
    age:18
}
foo.name = '李四';// error: 无法分配到 "name" ,因为它是只读属性

Pick 选择类型的部分字段

type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

interface UserInfo {
    name:string;
    age:number;
}

// 这时候我们只需要 UserInfo 的 name 属性。
type UserInfoT = Pick<UserInfo, "name"> // type UserInfoT = { name: string; }

extends关键字条件判断的用法

extends前面的类型能够赋值给extends后面的类型,那么表达式判断为真,否则为假

普通用法

interface Animal {
    name: string
}

interface Dog {
    name: string
    barking: boolean
}

type Test = Dog extends Animal ? string : number // string
type Test2 = '1' | '2' | '3' extends '1' | '2' ? string : number // number

泛型用法

type EX<T> = T extends '1' | '2' ? string : number;
type Test = EX<'1' | '2' | '3'>  // string | number

对于使用extends关键字的条件类型(即上面的三元表达式类型),如果extends前面的参数是一个泛型类型,当传入该参数的是联合类型,则使用分配律计算最终的结果。分配律是指,将联合类型的联合项拆成单项,分别代入条件类型,然后将每个单项代入得到的结果再联合起来,得到最终的判断结果。

type Test = EX<'1' | '2' | '3'> = EX<'1'> | EX<'2'> | EX<'3'> // string | number

never会被当成空的联合类型

type EX<T> = T extends '1' | '2' ? string : number;
type Test = EX<never>  // never

防止条件判断中的分配

type EX<T> = [T] extends ['1' | '2'] ? string : number;
type Test = EX<'1' | '2' | '3'>  // number
type Test2 = EX<never>  // string

泛型参数使用[]括起来,则会被当成一个整体

Record 用于方便的创造类型

type Record<K extends keyof any, T> = {
    [P in K]: T;
};

type UserInfoT = Record<"name" | "age", string | number>

// type UserInfoT = {
//     name: string | number;
//     age: string | number;
// }

Exclude 排除联合类型中的某些类型

type Exclude<T, U> = T extends U ? never : T;

type UserInfoT = Exclude<"name" | "age", "name">; // "age"

Extract 提取联合类型中的某些类型

type Extract<T, U> = T extends U ? T : never;

type UserInfoT = Extract<"name" | "age" | "sex",  "name" | "weight">; // "name"

Omit 从一个对象类型删除一些属性

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

interface UserInfo {
    name:string;
    age:number;
}

// 这时候我们不需要 UserInfo 的 name 属性。
type UserInfoT = Omit<UserInfo, "name">

// type UserInfoT = {
//     age: number;
// }

keyof any 等于 string | number | symbol

NonNullable 从联合类型中排除null和undefined

type NonNullable<T> = T extends null | undefined ? never : T;

type UserInfoK = NonNullable<"name" | "hob" | undefined>; // "name" | "hob"

Parameters 获取函数的参数的元组类型

type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

function getUserInfo(id:number, group:string){}

// 获取到函数需要的形参 Type[]
type GetUserInfoArg = Parameters<typeof getUserInfo>; // [id: number, group: string]
   
const arg:GetUserInfoArg = [ 1, "002" ];

infer可以在extends的条件语句中推断待推断的类型

ReturnType 获取函数的返回值类型

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

type func = () => number;
type variable = () => string;
type funcReturnType = ReturnType<func>; // funcReturnType 类型为 number
type varReturnType = ReturnType<variable>; // varReturnType 类型为 string

infer的其他运用

infer 解包

type Ids = number[];
type Names = string[];

// 解数组
type Unpacked<T> = T extends (infer R)[] ? R : T;

type idType = Unpacked<Ids>; // idType 类型为 number
type nameType = Unpacked<Names>; // nameType 类型为string

// 解泛型
type Responses = Promise<number[]>;
type Unpacked<T> = T extends Promise<infer R> ? R : T;

type resType = Unpacked<Responses>; // resType 类型为number[]

infer推断联合类型

type Foo<T> = T extends { a: infer U; b: infer U } ? U : never;

type T10 = Foo<{ a: string; b: string }>; // T10类型为 string
type T11 = Foo<{ a: string; b: number }>; // T11类型为 string | number

同一个类型变量在推断的值有多种情况的时候会推断为联合类型

// 元组转为联合类型
type ElementOf<T> = T extends (infer R)[] ? R : never;

type TTuple = [string, number];
type Union = ElementOf<TTuple>; // Union 类型为 string | number

ConstructorParameters 获取构造函数的参数的元组类型

type ConstructorParameters<T extends abstract new (...args: any) => any> = T extends abstract new (...args: infer P) => any ? P : never;

class User{
    constructor(id:number, group:string){}
}

type NewUserArg =  ConstructorParameters<typeof User>; // [id: number, group: string]

const arg:NewUserArg = [ 1, "002"];

InstanceType 获取构造函数的返回类型

type InstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any;

class User{
    constructor(id:number, group:string){}
}

type NewUserArg =  InstanceType<typeof User>; // User

Uppercase 字符串字面量类型转为大写

type Uppercase<S extends string> = intrinsic;

type MyText = "Hello, world" 
type A = Uppercase<MyText>; // type A = "HELLO, WORLD"

intrinsic TS内置类型

Lowercase 字符串字面量类型转为小写

type Lowercase<S extends string> = intrinsic;

type MyText = "Hello, world" 
type A = Lowercase<MyText>; // type A = "hello, world"

Capitalize 字符串字面量类型转为首字母大写

type Capitalize<S extends string> = intrinsic;

type MyText = "hello, world" 
type A = Capitalize<MyText>; // type A = "Hello, world"

Uncapitalize 字符串字面量类型转为首字母小写

type Uncapitalize<S extends string> = intrinsic;

type MyText = "Hello, World" 
type A = Uncapitalize<MyText>; // type A = "hello, World"

ThisParameterType 获取函数this类型

type ThisParameterType<T> = T extends (this: infer U, ...args: never) => any ? U : unknown;

// 定义一个函数,并且定义函数 this 类型。 
function getUserInfo(this:{ name:string }){}

const getUserInfoArgThis: ThisParameterType<typeof getUserInfo> = { name: '帅' }; // {  name: string; }

OmitThisParameter 获取忽略this类型的函数

type OmitThisParameter<T> = unknown extends ThisParameterType<T> ? T : T extends (...args: infer A) => infer R ? (...args: A) => R : T;

// 定义一个函数
function getUserInfo(this:{ name:string }, id:string){}

// 去除 getUserInfo 函数 this 参,然后创建出来了一个新类型
const noThis: OmitThisParameter<typeof getUserInfo> = (id:string)=>{}  //  (id: string) => void

第一个三元表达式:如果传入的T没有this参数就直接返回T,如果有this参数就继续进行判断,

第二个三元表达式:如果T不是函数那也会直接返回T,最后是重新定义了一个函数然后返回。其中使用infer定义了我们所需要的形参和返回值。


认识完成TS的内置工具类型后,我们知道了它是一个相当灵活的工具,接下来我们一起学习下,真实项目中如何运用 。

TS在React项目中的运用

本节主要引用自文章《TypeScript 2.8下的终极React组件模式》,因为本人水平有限,TS版本也比较老了,理解难免有偏差,建议大家仔细阅读原文。

文章虽然比较久远了,不过代码思想在现在也非常值得学习。

无状态组件

import React, { MouseEvent, FC } from 'react';

type Props = { onClick(e: MouseEvent<HTMLElement>): void };

const Button: FC<Props> = ({ onClick: handleClick, children }) => (
  <button onClick={handleClick}>{children}</button>
);

我们定义了一个无状态组件Button,接受一个onClick点击事件回调和一个children

首先React已经在@types/react中已经预定义一个类型type FC<P = {}>,它也是类型interface FunctionComponent<P>的一个别名,此外,它已经有预定义的children和其他(defaultProps、displayName等等…)

type FC<P = {}> = FunctionComponent<P>;

interface FunctionComponent<P = {}> {
    (props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null;
    propTypes?: WeakValidationMap<P> | undefined;
    contextTypes?: ValidationMap<any> | undefined;
    defaultProps?: Partial<P> | undefined;
    displayName?: string | undefined;
}

type PropsWithChildren<P> = P & { children?: ReactNode | undefined };

同样的还有MouseEvent,这是React内部的事件系统相关的定义。HTMLElement则来自于@types/reactglobal.d.ts

借助这些已有的能力,我们能够非常方便的组织我们的代码。

有状态组件

import React, { Component } from 'react';

import Button from './Button';

const initialState = { clicksCount: 0 };
type State = Readonly<typeof initialState>;

class ButtonCounter extends Component<object, State> {
  readonly state: State = initialState;

  render() {
    const { clicksCount } = this.state;

    return (
      <>
        <Button onClick={this.handleIncrement}>Increment</Button>
        <Button onClick={this.handleDecrement}>Decrement</Button>
        You've clicked me {clicksCount} times!
      </>
    );
  }

  private handleIncrement = () => this.setState(incrementClicksCount);
  private handleDecrement = () => this.setState(decrementClicksCount);
}

const incrementClicksCount = (prevState: State) => ({
  clicksCount: prevState.clicksCount + 1,
});
const decrementClicksCount = (prevState: State) => ({
  clicksCount: prevState.clicksCount - 1,
});

有状态组件ButtonCounter继承Component<P, S>,泛型P指外部传入的propsS指内部状态state

interface Component<P = {}, S = {}, SS = any> extends ComponentLifecycle<P, S, SS> { }
class Component<P, S> {
    static contextType?: Context<any> | undefined;

    context: any;

    constructor(props: Readonly<P> | P);
    constructor(props: P, context: any);

    setState<K extends keyof S>(
        state: ((prevState: Readonly<S>, props: Readonly<P>) => (Pick<S, K> | S | null)) | (Pick<S, K> | S | null),
        callback?: () => void
    ): void;

    forceUpdate(callback?: () => void): void;
    render(): ReactNode;

    readonly props: Readonly<P> & Readonly<{ children?: ReactNode | undefined }>;
    state: Readonly<S>;
    refs: {
        [key: string]: ReactInstance
    };
}

我们可以发现一个细节,propsstate最终都被Readonly处理,当我们直接修改propsstate时,编译器就会提示我们 无法分配到 "clicksCount" ,因为它是只读属性。

因为我们重写了属性state并且赋予了初始值,所以我们需要定义一致 readonly state: State = initialState;

默认属性

现在我们想给Button组件新增一个color的属性,并且color本身有默认值red。那这时候我们定义Props的时候应该这样定义:

type Props = {
  onClick(e: MouseEvent<HTMLElement>): void;
  color?: string; // color有默认值,所以非必填
};

但是当我们使用的时候color的类型推断为string | undefined,编译器会认为color可能不存在

const Button: FC<Props> = ({ onClick: handleClick, color, children }) => (
  <button style={{ color }} onClick={handleClick}>
    {color.length} // 对象可能为“未定义”
    {children}
  </button>
);

为了满足TS编译器,我们可以使用下面3种方式:

  • 使用!操作符告诉编译器这个变量不会是undefined
  • 使用条件语句/三目运算符来让编译器明白什么时候属性存在:color ? color.length : null
  • 创建可复用的withDefaultProps高阶函数,达到有默认值的属性,对外是可选的,对内是必选的效果。
export const withDefaultProps = <
  P extends object,
  DP extends Partial<P> = Partial<P>
>(
  defaultProps: DP,
  Cmp: ComponentType<P>,
) => {
  // 提取出必须的属性
  type RequiredProps = Omit<P, keyof DP>;
  // 重新创建我们的属性定义,通过一个相交类型,将所有的原始属性标记成可选的,必选的属性标记成可选的
  type Props = Partial<DP> & Required<RequiredProps>;

  Cmp.defaultProps = defaultProps;

  // 返回重新的定义的属性类型组件,通过将原始组件的类型检查关闭,然后再设置正确的属性类型
  return (Cmp as ComponentType<any>) as ComponentType<Props>;
};

高阶函数withDefaultProps在赋值默认值的同时,巧妙的利用Omit将有默认值的属性和其他属性分开,最终对外的props被定义为默认值可选 & 其他属性必选,从而达到效果

实际使用

const defaultProps = {
  color: 'red',
};

type DefaultProps = typeof defaultProps;
type Props = { onClick(e: MouseEvent<HTMLElement>): void } & DefaultProps;

const Button: FC<Props> = ({ onClick: handleClick, color, children }) => (
  <button style={{ color }} onClick={handleClick}>
    { color.length }
    {children}
  </button>
);

const ButtonWithDefaultProps = withDefaultProps(defaultProps, Button);

或者使用内联(注意我们需要显式的提供原始Button组件的属性定义,TS不能从函数中推断出参数的类型)

const ButtonWithDefaultProps = withDefaultProps<Props, DefaultProps>(
  defaultProps,
  ({ onClick: handleClick, color, children }) => (
    <button style={{ color }} onClick={handleClick}>
      {children}
    </button>
  ),
);

现在Button组件默认属性被反应出来并且在类型定义中是可选的,但在实现中是必选的!

image.png

render回调/render属性模式

render回调是一种实现组件逻辑复用的方式,让我们用render属性方法实现一个Toggleable组件:

import React, { Component, MouseEvent } from 'react';
import { isFunction } from 'lodash';

// 初始状态
const initialState = {
  show: false,
};

type State = Readonly<typeof initialState>;
                      
// 要么接收符合RenderCallback类型的函数子组件,要么render属性接收符合RenderCallback类型的函数组件
type Props = Partial<{
  children: RenderCallback;
  render: RenderCallback;
}>;

// 提供能力 show 和 toggle
type RenderCallback = (args: ToggleableComponentProps) => JSX.Element;
// 利用索引访问符,避免重复定义类型
type ToggleableComponentProps = {
  show: State['show'];
  toggle: Toggleable['toggle'];
};

export class Toggleable extends Component<Props, State> {
  readonly state: State = initialState;

  render() {
    const { render, children } = this.props;
    const renderProps = {
      show: this.state.show,
      toggle: this.toggle,
    };

    if (render) {
      return render(renderProps);
    }

    return isFunction(children) ? children(renderProps) : null;
  }

  private toggle = (event: MouseEvent<HTMLElement>) =>
    this.setState(updateShowState);
}

// 纯函数 方便测试
const updateShowState = (prevState: State) => ({ show: !prevState.show });

Toggleable组件能够提供toggle能力,并且利用TS的能力,约束了我们的使用方式。

现在我们可以把函数作为children传给Toggleable组件了:

<Toggleable>
  {({ show, toggle }) => (
    <>
      <div onClick={toggle}>
        <h1>some title</h1>
      </div>
      {show ? <p>some content</p> : null}
    </>
  )}
</Toggleable>

或者我们可以把函数作为render属性:

<Toggleable
  render={({ show, toggle }) => (
    <>
      <div onClick={toggle}>
        <h1>some title</h1>
      </div>
      {show ? <p>some content</p> : null}
    </>
  )}
/>

如果我们想复用它(比如用在多个菜单组件中),我们只需要创建一个使用Toggleable逻辑的新组件:

type Props = { title: string }
const ToggleableMenu: FC<Props> = ({ title, children }) => (
  <Toggleable
    render={({ show, toggle }) => (
      <>
        <div onClick={toggle}>
          <h1>title</h1>
        </div>
        {show ? children : null}
      </>
    )}
  />
)

export class Menu extends Component {
  render() {
    return (
      <>
        <ToggleableMenu title="First Menu">Some content</ToggleableMenu>
        <ToggleableMenu title="Second Menu">Some content</ToggleableMenu>
        <ToggleableMenu title="Third Menu">Some content</ToggleableMenu>
      </>
    );
  }
}

组件注入 & 泛型组件

支持组件注入的模式,使代码更灵活,比如React-Router

<Route path="/foo" component={MyView} />

这样我们不是把函数传递给render/children属性,而是通过component属性“注入”组件。

完整的 Toggleable 组件实现,支持 Render 属性、Children 作为函数、带泛型 props 属性支持的组件注入:

import React, {
  Component,
  ReactNode,
  ComponentType,
  MouseEvent,
  FC
} from 'react';

import { isFunction } from 'lodash';

const initialState = { show: false };

type State = Readonly<typeof initialState>;

// 泛型P指注入组件本身的属性, 如菜单组件,本身具有title { title: string }
type Props<P extends object = object> = Partial<
  {
    children: RenderCallback | ReactNode;
    render: RenderCallback;
    component: ComponentType<ToggleableComponentProps<P>>;
  } & DefaultProps<P>
>;


// 注入组件的默认属性
type DefaultProps<P extends object = object> = { props: P };
const defaultProps: DefaultProps = { props: {} };
type RenderCallback = (args: ToggleableComponentProps) => JSX.Element;

// 注入组件最终接收的完整props类型
export type ToggleableComponentProps<P extends object = object> = {
  show: State['show'];
  toggle: Toggleable['toggle'];
} & P;

export class Toggleable<T extends object = {}> extends Component<Props<T>, State> {
  // ofType的工场模式, 是一种标识函数,返回的是相同实现的 Toggleable 组件,但带有制定的 props 类型
  static ofType<T extends object>() {
    return Toggleable as Constructor<Toggleable<T>>;
  }
  static readonly defaultProps: Props = defaultProps;
  readonly state: State = initialState;

  render() {
    const {
      component: InjectedComponent,
      props,
      render,
      children,
    } = this.props;
    const renderProps = {
      show: this.state.show,
      toggle: this.toggle,
    };

    if (InjectedComponent) {
      return (
        <InjectedComponent {...props} {...renderProps}>
          {children}
        </InjectedComponent>
      );
    }

    // 其他模式不变 和之前一样
    if (render) {
      return render(renderProps);
    }

    return isFunction(children) ? children(renderProps) : null;
  }

  private toggle = (event: MouseEvent<HTMLElement>) =>
    this.setState(updateShowState);
}

const updateShowState = (prevState: State) => ({ show: !prevState.show });


// 使用
type MenuItemProps = { title: string };

// 注入的MenuItem组件 拥有toggle和show的能力
const MenuItem: FC<ToggleableComponentProps<MenuItemProps>> = ({
  title,
  toggle,
  show,
  children,
}) => (
  <>
    <div onClick={toggle}>
      <h1>{title}</h1>
    </div>
    {show ? children : null}
  </>
);

const ToggleableWithTitle = Toggleable.ofType<MenuItemProps>();

// 对外暴露组件 只保留最干净的属性
type ToggleableMenuProps = MenuItemProps;
const ToggleableMenuViaComponentInjection: FC<ToggleableMenuProps> = ({
  title,
  children,
}) => (
  <ToggleableWithTitle component={MenuItem} props={{ title }}>
    {children}
  </ToggleableWithTitle>
);

业务项目

最近两年写的TS项目基本都是些业务代码,代码质量也是一般(比较懒,很少去重构),所以TS方面来说,只需要用到很基础的知识就够了,本节算是一些业务项目经验总结。

global.d.ts

全局类型定义文件,一般放在项目最外层。

  • 定义引用的特殊模块,比如图片、样式文件等,防止编译器报错
  • 定义全局变量,比如微信小程序的wx
  • 扩展全局变量、模块,比如process
// tsconfig.json
{
  "compilerOptions": {
    "typeRoots": ["node_modules/@types", "global.d.ts"]
   }
}

使用示例

declare module '*.png'
declare module '*.gif'
declare module '*.jpg'
declare module '*.jpeg'
declare module '*.svg'
declare module '*.css'
declare module '*.less'
declare module '*.scss'
declare module '*.sass'

declare module 'process' {
  global {
      namespace NodeJS { 
          interface ProcessEnv {
            TARO_ENV: 'weapp' | 'swan' | 'alipay' | 'h5' | 'rn' | 'tt' | 'quickapp' | 'qq' | 'jd'
          }
      }
  }
}

enum.ts

全局状态枚举,主要用来定义系统约定的枚举状态,减少出错,规范代码。

/**
 * 婚姻状态
 */
export enum Marital {
  married = 1,
  unmarried = 2,
  widowed = 3,
  divorce = 4
}

/**
 * 婚姻状态文案映射
 */
export enum MaritalText {
  married = '已婚',
  unmarried = '未婚',
  widowed = '丧偶',
  divorce = '离婚'
}

utils

我们项目中经常全局工具方法,要么是基础能力扩展,要么是和业务绑定的赋能工具。

这些方法的编写要求稳定、健壮,且使用方式明了。通过TS我们能够加强这些方面。

// TS可以自动推断类型 <T>(promise: Promise<T>) => Promise<any[] | (T | null)[]>
export const promiseCatcher = <T>(promise: Promise<T>) =>
  promise.then((res) => [null, res]).catch((err) => [err || {}])


/**
 * 序列化 url 参数
*/
interface SerializeOptions {
  query: object
  url?: string
}
// ({ url, query }: SerializeOptions) => string
export const serialize = ({ url, query }: SerializeOptions) => {
  const queryArray = _.reduce(
    query,
    (result, value, key) =>
      _.isNil(value) ? result : [...result, `${key}=${encodeURIComponent(value)}`],
    []
  )
  const serializedStr = _.join(queryArray, '&')

  return url ? `${url}?${serializedStr}` : serializedStr
}

基础组件、业务组件

React中万物皆是组件,合理的组件分层能帮助我们构建健康的项目。

相信我,编写组件时,加上一些必要的TS定义,就可以极大的提升编码的幸福度。特别是业务组件本来功能就相对单一,少量的额外工作就可以带来极大的收益。

import { FC, memo, CSSProperties, ReactNode } from 'react'
import { GenderTypes } from '@/containers/Gender'

interface Tag {
  color?: string
  label: string
}

// 我们可以一眼就看出title和name是必传参数,其他都是非必传。
// 当我们传递tags的时候,应该怎么构造,等等
// 并且我们永远不用担心定义过时或者不准确
interface IProps {
  renderHeader?: ReactNode
  style?: CSSProperties
  gender?: GenderTypes
  className?: string
  message?: string
  title: string
  phone?: string
  name: string
  url?: string
  tags?: Tag[]
}

const TaskList: FC<IProps> = (props) => {
  const {
    tags = [],
    className,
    message,
    gender,
    title,
    phone,
    style,
    url,
    name,
  } = props

  return (
    // ...
  )
}

export default memo(TaskList)

HOOKS

新版本的React项目中,hooks组件因为其简洁的语法,逐渐成为主流。

以我之拙见,hooks带来的最大好处是, 将UI和状态分离,让我们可以编写可复用的状态。缺点是复杂场景使用hooks,后期将难以维护,当然这也是明确的重构信号了。

import { useState, useRef, useCallback, useEffect, SetStateAction, Dispatch } from 'react'
import AsyncValidator, { Rules, RuleItem, ValidateError, Values } from 'async-validator'
import _ from 'lodash'

type ErrorList = ValidateError[]
type Fields = Values

interface FormConfig {
  validateTrigger?: 'onChange' | 'onSubmit'
  defaultValue?: Fields
  rules?: Rules
}

export interface FieldOptions {
  rules?: RuleItem[]
}

type Error = ErrorList | null
type Result = [FormInstance, Fields, Error]

// 定义表单实例具有的能力,怎么使用
export interface FormInstance {
  getFieldDecorator(filed: string, options?: FieldOptions): (value: any) => void
  setRules(callback: (currentRules: Rules) => Rules): void
  setValues: Dispatch<SetStateAction<Fields>>
  validateFields(): Promise<Error[]>
  setFields(values: Fields): void
}

// 自定义hooks useFormField 入参和返回一目了然
const useFormField = (config: FormConfig = {}): Result => {
  const { defaultValue = {}, rules = {}, validateTrigger = 'onChange' } = config
  const [values, setValues] = useState<Fields>(defaultValue)
  const [formRules, setFormRules] = useState(rules)
  const [error, setError] = useState<Error>(null)
  const rulesCache = useRef(rules)

  const setFields = useCallback((vals: Fields) => {
    // ....
  }, [])

  const setRules = useCallback((callback: (currentRules: Rules) => Rules) => {
    setFormRules((prev) => {
     // ...
    })
  }, [])

  const getFieldDecorator = useCallback(
    (field: string, options?: FieldOptions) => {
      // ...
    },
    [setFields]
  )

  const validateRules = useCallback(
    (vals: Fields) => {
     // ...
    },
    [formRules]
  )

  const validateFields = useCallback(
    () => {
      // ...
    },
    [validateRules, values]
  )

  useEffect(() => {
    if (validateTrigger === 'onChange') validateFields()
  }, [validateTrigger, validateFields])

  useEffect(
    () => () => {
      rulesCache.current = {}
    },
    []
  )

  return [{ setFields, setValues, getFieldDecorator, validateFields, setRules }, values, error]
}

export default useFormField

不知不觉就写了这么多,果然有好多知识点没有掌握,学海无涯啊。

不过,第一篇文章终于完成了,希望对大家有所帮助。

引用致谢: