TypeScript基础与进阶

110 阅读16分钟

Typescript 基础语法

Typescript 语法只是在 JavaScript 基础上增加了更多类型定义方面的内容,以此保证程序的类型安全。

为什么要用Typescript?

  1. 类型安全
  2. 编码时类型错误提示和自动补全
  3. Typescript自称为Javascript超集,可以支持一些很新的特性
  4. 针对编码文档的支持

Typescript 首先要掌握的就是:类型定义。

类型定义

一个小问题,在 JavaScript 中有哪些数据类型?
基本数据类型:string 、number 、boolean 、null、undefined、bigint、symbol
引用数据类型:Object (eg:数组、对象、function...)

它们在 TypeScript 中都有对应的类型注解:

const name: string = 'linbudu';
const age: number = 24;
const male: boolean = false;
//const是必须要赋值的,如果不想,可以用let,然后在想赋值的地方赋值。
//let istrue:boolean    
//istrue=true
const undef: undefined = undefined;
const nul: null = null;
const obj: object = { name, age, male };
const bigintVar1: bigint = 9007199254740991n;
const bigintVar2: bigint = BigInt(9007199254740991);
const symbolVar: symbol = Symbol('unique');

//在 TypeScript 中有两种方式来声明一个数组类型:
 const arr:number[] = [1,2,3]//常规写法
 const arr2:Array<string>=['222','333'] //泛型的写法

//数组是我们在日常开发大量使用的数据结构,但在某些情况下,使用 元组 来代替数组要更加妥当,
//比如一个数组中只存放固定长度的变量,但我们进行了超出长度地访问
const arr3: string[] = ['Hi', 'HELLO', 'HEY'];
console.log(arr3[599]);
//这种情况肯定是不符合预期的,因为我们能确定这个数组中只有三个成员,并希望在越界访问时给出类型报错。
//这时我们可以使用元组类型进行类型标注:
const arr4: [string, string, string] = ['Hi', 'HELLO', 'HEY'];
console.log(arr4[599]);//此时将会产生一个类型错误:长度为“3”的元组类型“[string, string, string]”在索引“599“处没有元素。
//元组内部也可以声明多个与其位置强绑定的,不同类型的元素
const arr5: [string, number, boolean] = ['HIHI', 599, true];
//元组也支持了在某一个位置上的可选成员:
const arr6: [string, number?, boolean?] = ['HIHI'];//或者 ['HiHI', , ,];
//对于标记为可选的成员,会被视为一个 `string | undefined` 的类型。

const date:Date = new Date()//日期类型
const reg:RegExp = new RegExp('^Hi$')//正则

其中,除了 null 与 undefined 以外,余下的类型基本上可以完全对应到 JavaScript 中的数据类型概念,因此这里我们只对 null 与 undefined 展开介绍。

null 与 undefined

在 JavaScript 中,null 与 undefined 分别表示“这里有值,但是个 空值”和“这里没有值”。而在 TypeScript 中,null 与 undefined 类型都是有具体意义的类型。也就是说,它们作为类型时,表示的是一个有意义的具体类型值。这两者在没有开启 strictNullChecks 检查的情况下,会被视作其他类型的子类型,比如 string 类型会被认为包含了 null 与 undefined 类型:

const tmp1: null = null;
const tmp2: undefined = undefined;
const tmp3: string = null; // 仅在关闭 strictNullChecks 时成立,下同
const tmp4: string = undefined;

除了上面介绍的原始类型以及 null、undefined 类型以外,在 TypeScript 中还存在着一个特殊的类型:void,它和 JavaScript 中的 void 同样不是一回事,我们接着往下看。

void

<a href="javascript:void(0)">清除缓存</a>

这里的 void(0) 等价于 void 0,即 void expression 的语法。void 操作符会执行后面跟着的表达式并返回一个 undefined,如你可以使用它来执行一个立即执行函数(IIFE):

void function iife() {
  console.log("Invoked!");
}();

能这么做是因为,void 操作符强制将后面的函数声明转化为了表达式,因此整体其实相当于:void((function iife(){})())

事实上,TypeScript 的原始类型标注中也有 void,但与 JavaScript 中不同的是,这里的 void 用于描述一个内部没有 return 语句,或者没有显式 return 一个值的函数的返回值,如:

function func1() {}
function func2() {
  return;
}

function func3() {
  return undefined;
}

在这里,func1 与 func2 的返回值类型都会被隐式推导为 void,只有显式返回了 undefined 值的 func3 其返回值类型才被推导为了 undefined。但在实际的代码执行中,func1 与 func2 的返回值均是 undefined。

虽然 func3 的返回值类型会被推导为 undefined,但是你仍然可以使用 void 类型进行标注,因为在类型层面 func1、func2、func3 都表示“没有返回一个有意义的值”。

对象

在 TypeScript 中我们需要特殊的类型标注来描述对象类型,即 interface ,你可以理解为它代表了这个对象对外提供的接口结构。

首先我们使用 interface 声明一个结构,然后使用这个结构来作为一个对象的类型标注即可:

interface IDescription {
  name: string;  
  age: number;  
  male: boolean;
}
const obj1: IDescription = {
  name: 'HI',  
  age: 599,  
  male: true,
};

这里的“描述”指:

  • 每一个属性的值必须一一对应到接口的属性类型
  • 不能有多的属性,也不能有少的属性,包括直接在对象内部声明,或是 obj1.other = 'xxx' 这样属性访问赋值的形式

除了声明属性以及属性的类型以外,我们还可以对属性进行修饰,常见的修饰包括可选(Optional)只读 Readonly 这两种。

修饰接口属性

类似于元组可选,在接口结构中同样通过 ? 来标记一个属性为可选:

interface IDescription {
  name: string;  
  age: number;  
  male?: boolean;  
  func?: Function;
}
const obj2: IDescription = {
  name: 'HI',  
  age: 599,  
  male: true,  
  // 无需实现 func 也是合法的
};

在这种情况下,即使你在 obj2 中定义了 male 属性,但当你访问 obj2.male 时,它的类型仍然会是 boolean | undefined,因为毕竟这是我们自己定义的类型嘛。

假设新增一个可选的函数类型属性,然后进行调用:obj2.func() ,此时将会产生一个类型报错:不能调用可能是未定义的方法。但可选属性标记不会影响你对这个属性进行赋值,如:

obj2.male = false;
obj2.func = () => {};

即使你对可选属性进行了赋值,TypeScript 仍然会使用接口的描述为准进行类型检查。

除了标记一个属性为可选以外,还可以标记这个属性为只读:readonly。作用是防止对象的属性被再次赋值

interface IDescription {
  readonly name: string;  
  age: number;
}
const obj3: IDescription = {
  name: 'hi',  
  age: 599,
}; // 无法分配到 "name" ,因为它是只读属性

obj3.name = "hihi";

其实在数组与元组层面也有着只读的修饰,但与对象类型有着两处不同。

  • 你只能将整个数组/元组标记为只读,而不能像对象那样标记某个属性为只读。
  • 一旦被标记为只读,那这个只读数组/元组的类型上,将不再具有 push、pop 等方法(即会修改原数组的方法),因此报错信息也将是类型 xxx 上不存在属性“push”这种。这一实现的本质是只读数组与只读元组的类型实际上变成了 ReadonlyArray,而不再是 Array。

类型联合(|)与交叉(&)

类型联合用于指定一个值的类型可以是多个,我们可以把一个业务化的数据变量指定成既可以是 string 又可以是 number。

let myFavoriteNumber: string | number;
//也可以这样:
let myNum:1 | 2 | 3//1、2、3不是值吗?为什么可以作为类型?
//在 TypeScript 中,这叫做 字面量类型,项这样:
const str1: "hi" = "hi";
const str1: "hi" = "hihi";//但是这样是会报错的
//单独使用字面量类型比较少见,因为单个字面量类型并没有什么实际意义。它通常和联合类型(即这里的 | )一起使用

这里有几点需要注意的:

  • 对于联合类型中的函数类型,需要使用括号()包裹起来
  • 函数类型并不存在字面量类型,因此这里的 (() => {}) 就是一个合法的函数类型
  • 你可以在联合类型中进一步嵌套联合类型,但这些嵌套的联合类型最终都会被展平到第一级中
let mixed: true | string | 599 | {} | (() => {}) | (1 | 2)

类型交叉是将多个类型合并为一个类型。 这让我们可以把现有的多种类型叠加到一起成为一种类型,它包含了所需的所有类型的特性。

type NameProtocal = {name: string}
type PersonLikeProtocal = {age: number; say: (message:string) => void}

type Student = NameProtocal & PersonLikeProtocal

const person: student = {
    name:'hi',
    age:18,
    say(msg){
        console.log(msg)
    }
}
person.say('123')

类型工具

类型工具顾名思义,它就是对类型进行处理的工具。如果按照使用方式来划分,类型工具可以分成三类:操作符、关键字与专用语法

而按照使用目的来划分,类型工具可以分为 类型创建类型安全保护 两类。

类型创建

作用是基于已有的类型去创建出新的类型

类型别名

类型别名可以说是 TypeScript 类型编程中最重要的一个功能,从一个简单的函数类型别名,到让你眼花缭乱的类型体操,都离不开类型别名。虽然很重要,但它的使用却并不复杂

eg: 通过 type 关键字声明一个类型别名 A ,同时它的类型等价于 string 类型:type A=string

类型别名的作用主要是对一组类型或一个特定类型结构进行封装,以便于在其它地方进行复用。

比如抽离一组联合类型:

type StatusCode = 200 | 301 | 400 | 500 | 502;
type PossibleDataTypes = string | number | (() => unknown);
const status: StatusCode = 502;

抽离一个函数类型:

type Handler = (e: Event) => void;
const clickHandler: Handler = (e) => { };
const moveHandler: Handler = (e) => { };

类型别名还能作为工具类型。工具类同样基于类型别名,只是多了个 泛型

在类型别名中,类型别名可以这么声明自己能够接受泛型。一旦接受了泛型,我们就叫它工具类型:

type Factory<T> = T | number | string;

虽然现在类型别名摇身一变成了工具类型,但它的基本功能仍然是创建类型,只不过工具类型能够接受泛型参数,实现更灵活的类型创建功能

联合类型与交叉类型

在前面有提到,总结一下交叉类型和联合类型的区别就是,联合类型只需要符合成员之一即可(||),而交叉类型需要严格符合每一位成员(&&)。

索引签名类型

索引签名类型主要指的是在接口或类型别名中,通过以下语法来快速声明一个键值类型一致的类型结构

interface AllStringTypes {
  [key: string]: string;
}
type AllStringTypes = {
  [key: string]: string;
}

这时,即使你还没声明具体的属性,对于这些类型结构的属性访问也将全部被视为 string 类型:

interface AllStringTypes {
  [key: string]: string;
}
type PropType1 = AllStringTypes['linbudu']; // string
type PropType2 = AllStringTypes['599']; // string

类型安全保护

类型查询操作符(typeof)

TypeScript 存在两种功能不同的 typeof 操作符。我们最常见的一种 typeof 操作符就是 JavaScript 中,用于检查变量类型的 typeof ,它会返回 "string" / "number" / "object" / "undefined" 等值。而除此以外, TypeScript 还新增了用于类型查询的 typeof ,这个 typeof 返回的是一个TypeScript 类型

const str = "linbudu";
const obj = { name: "linbudu" };
const nullVar = null;
const undefinedVar = undefined;
const func = (input: string) => {
  return input.length > 10;
}
type Str = typeof str; // "linbudu"
type Obj = typeof obj; // { name: string; }
type Null = typeof nullVar; // null
type Undefined = typeof undefined; // undefined
type Func = typeof func; // (input: string) => boolean

类型守卫

类型守卫可以简单理解为通过类型判断或者类型中某个属性是否满足从而推导对应类型。

比如判断一个类型是否是预期

const dom = document.querySelector('dom')

dom?.addEventListener('mousedown', (ev) => {
    const t = ev.target

    // 类型守卫
    if (t instanceof HTMLDivElement) {
        t.classList.add('active')
    }
}, false)

类型保护是在类型守卫基础上,将类型的判断约束进行封装,在使用的位置通过调用对应方法进行判断来处理。

基于 in 与 instanceof 的类型保护

in 操作符 并不是 TypeScript 中新增的概念,而是 JavaScript 中已有的部分,它可以通过 key in object 的方式来判断 key 是否存在于 object 或其原型链上(返回 true 说明存在)。

既然能起到区分作用,那么 TypeScript 中自然也可以用它来保护类型:

interface Foo {
  foo: string;  
  fooOnly: boolean;  
  shared: number;
}

interface Bar {
  bar: string;  
  barOnly: boolean;  
  shared: number;
}

function handle(input: Foo | Bar) {
  if ('foo' in input) {  
    input.fooOnly;  
  } else {  
    input.barOnly;  
  }
}

我们使用 foo 和 bar 来区分 input 联合类型,然后就可以在对应的分支代码块中正确访问到 Foo 和 Bar 独有的类型 fooOnly / barOnly

foo / bar 和 fooOnly / barOnly 是各个类型独有的属性,因此可以作为可辨识属性(Discriminant Property 或 Tagged Property)

这个可辨识属性可以是结构层面的,比如结构 A 的属性 prop 是数组,而结构 B 的属性 prop 是对象,或者结构 A 中存在属性 prop 而结构 B 中不存在。

它甚至可以是共同属性的字面量类型差异:

interface Foo {
  kind: 'foo';  
  diffType: string;  
  fooOnly: boolean;  
  shared: number;
}
interface Bar {
  kind: 'bar';  
  diffType: number;  
  barOnly: boolean;  
  shared: number;
}
function handle1(input: Foo | Bar) {
  if (input.kind === 'foo') {  
    input.fooOnly;  
  } else {  
    input.barOnly;  
  }
}

如上例所示,对于同名但不同类型的属性,我们需要使用字面量类型的区分,并不能使用简单的 typeof:

function handle2(input: Foo | Bar) {
  // 报错,并没有起到区分的作用,在两个代码块中都是 Foo | Bar  
  if (typeof input.diffType === 'string') {  
    input.fooOnly;  
  } else {  
    input.barOnly;  
  }
}

除此之外,JavaScript 中还存在一个功能类似于 typeof 与 in 的操作符:instanceof它判断的是原型级别的关系,如 foo instanceof Base 会沿着 foo 的原型链查找 Base.prototype 是否存在其上。

class FooBase {}
class BarBase {}
class Foo extends FooBase {
  fooOnly() {}
}
class Bar extends BarBase {
  barOnly() {}
}
function handle(input: Foo | Bar) {
  if (input instanceof FooBase) {  
    input.fooOnly();  
  } else {  
    input.barOnly();  
  }
}

Typescript 泛型与类型体操

泛型,这个概念我们可以用生活中的 “泛指” 类比,大家还记得汉语言中的 代词 吗?

我们很多时候不清楚一个值的具体类型,这个类型可能跟函数调用时或者类创建时的入参有关。但是呢,我们想对入参进行一些控制,让开发者在一定约束内保证类型格式。

泛型的概念及使用

//计算传入 number 数组的和
//入参多样化, 虽然使用了泛型,但是这里限制了类型必须为number 或者是 string
function sum<T extends (number | string)>(nums: T[]): number{
    let total = 0;
    for(let num of nums){
        //做一个判断,就算传入的能不能转成一个合法的数字,如果能就直接转
        const n=Number(num)
        //确保传进来的元素一定是被转成了合法的数字
        const newNumber = isNaN(n) ? 0 : n
        total += Number(newNumber)
    }
    return total
}

console.log(sum([1,'2',3,0])) //6

为什么不用any呢,因为如果用any我们是起不到类型限制的。而泛型是在一范围内让你灵活,给你一定自由,又给一定的限制。

我们定义一个函数叫 sayHello,我们只知道入参必须要有一个名称属性,其他的我们不关心。

function sayHello<P extends {name: string}>(person: P) {
    console.log(person.name);
}

再比如,我们想要根据入参去限制返回值类型,我们可以这样子

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

再比如我们看看这样一个案例

下面是对一个先进先出的数据结构——队列,在 TypeScriptJavaScript 中的简单实现。

class Queue {
  private data = [];
  push = item => this.data.push(item);
  pop = () => this.data.shift();
}

在上述代码中存在一个问题,它允许你向队列中添加任何类型的数据,当然,当数据被弹出队列时,也可以是任意类型。
在下面的示例中,看起来人们可以向队列中添加string 类型的数据,但是实际上,该用法假定的是只有 number 类型会被添加到队列里。

class Queue {
  private data = [];
  push = item => this.data.push(item);
  pop = () => this.data.shift();
}

const queue = new Queue();

queue.push(0);
queue.push('1'); // Oops,一个错误

// 一个使用者,走入了误区
console.log(queue.pop().toPrecision(1));
console.log(queue.pop().toPrecision(1)); // RUNTIME ERROR

一个解决的办法(事实上,这也是不支持泛型类型的唯一解决办法)是为这些约束创建特殊类,如快速创建数字类型的队列:

class QueueNumber {
  private data = [];
  push = (item: number) => this.data.push(item);
  pop = (): number => this.data.shift();
}

const queue = new QueueNumber();

queue.push(0);
queue.push('1'); // Error: 不能推入一个 `string` 类型,只能是 `number` 类型

// 如果该错误得到修复,其他将不会出现问题

当然,快速也意味着痛苦。例如当你想创建一个字符串的队列时,你将不得不再次修改相当大的代码。我们真正想要的一种方式是无论什么类型被推入队列,被推出的类型都与推入类型一样。当你使用泛型时,这会很容易:

// 创建一个泛型类
class Queue<T> {
  private data: T[] = [];
  push = (item: T) => this.data.push(item);
  pop = (): T | undefined => this.data.shift();
}

// 简单的使用
const queue = new Queue<number>();
queue.push(0);
queue.push('1'); // Error:不能推入一个 `string`,只有 number 类型被允许

extends 的重要性

除了声明默认值以外,泛型还能做到一样函数参数做不到的事:泛型约束。也就是说,你可以要求传入这个工具类型的泛型必须符合某些条件,否则你就拒绝进行后面的逻辑。在函数中,我们只能在逻辑中处理:

function add(source: number, add: number){
  if(typeof source !== 'number' || typeof add !== 'number'){  
    throw new Error("Invalid arguments!")  
  }  
  return source + add;
}

而在泛型中,我们可以使用 extends 关键字来约束传入的泛型参数必须符合要求。
关于 extends,A extends B 意味着 A 是 B 的子类型

type ResStatus<ResCode extends number> = ResCode extends 10000 | 10001 | 10002  ? 'success'  : 'failure';

这个例子会根据传入的请求码判断请求是否成功,这意味着它只能处理数字字面量类型的参数,因此这里我们通过 extends number 来标明其类型约束,如果传入一个不合法的值,就会出现类型错误:

type ResStatus<ResCode extends number> = ResCode extends 10000 | 10001 | 10002  ? 'success'  : 'failure';
type Res1 = ResStatus<10000>; // "success"
type Res2 = ResStatus<20000>; // "failure"
type Res3 = ResStatus<'10000'>; // 类型“string”不满足约束“number”。 

Infer

TypeScript 中支持通过 infer 关键字来在条件类型中提取类型的某一部分信息

获取函数参数类型

有没有想过,假如我们定义了一个函数,此刻我们想获取参数的类型,怎么做?

type ParamType<T> = T extends (arg: infer P) => any ? P : T;

使用

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

type Func = (user: User) => void;

type Param = ParamType<Func>; // Param = User
type AA = ParamType<string>; // string

获取函数返回值

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