TS 的必会技能,学完这些就可以干活了

285 阅读9分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 1 天,点击查看活动详情

本文的目标:熟悉 TS 的基本操作,学完这些就可以干活了...

基础类型

string, number, boolean, null, undefined

数组类型

Array<T>,T代表数组中的元素类型。<T> 这个叫做钻石💎操作符,把类型当做参数传给另一个类型。

思考:要求数组中元素类型统一优势是什么?

const A = new Array<string>()
const B = [] // Array<any>

A[0] = 1 // error
A[0] = "1"

// 特殊情况
function useState(x){
    // ... 
    return [x, setState]
    // 这块的思想其实不是按照数组来用的,是为了做记录用的,当成一个值类型在用。
}

// 类似数组的解构赋值也如此
1. 影响代码的可读性
2. 思虑不周,容易出错

any / unkown / noImplictAny

let obj: any = { x: 0 };
// 后续都不会被检查
// `any`屏蔽了所有类型检查,相当于你相信你对程序的理解是高于 TS 的

obj.foo();
obj();
obj.bar = 100;
obj = "hello";
const n: number = obj;

implict : 隐式

explict : 显式

配置项:noImplicitAny,当你不为变量声明类型时,如果 noImplicitAny = false,那么它是any。如果 noImplicitAny = true 呢? ——报错 error

let value: unknown;

value = true;             // OK
value = 42;               // OK
value = "Hello World";    // OK

let value2: boolean = value; // Error

思考:为什么要提供unknown

unknown 是 any 的一个类型安全的替代品 unknown 只影响当前变量,不会影响其他变量,不会污染其他变量,收敛影响范围

类型标注

: 用于类型标注。

let myName: string = "Alice";

let myName2 = "Alice" // ts 自动推导出类型
myName2 = 1 // error

函数

函数一般有两个地方需要标记类型,一个是参数,一个是返回值。

function greet(name: string): number {
  console.log("Hello, " + name.toUpperCase() + "!!");
}

greet(42) // Error
let x: string = greet("omg") // Error

匿名函数的类型

const names = ["Alice", "Bob", "Eve"];
// names 的类型 Array<string>

names.forEach(function (s) {
  console.log(s.toUppercase()); // Error
});

names.forEach((s) => {
  console.log(s.toUppercase()); // Error
});

contexture typing(根据上下文猜测匿名函数参数的类型)。例子中会报错,应该是toUpperCase(C 大写)。

函数可选参数:

function print(arg1: string, arg2?: string) {
    console.log(arg1, arg2)
}

print("Hello", "World")
print("Hello")

对象类型

对象如果描述了类型也需要严格执行。

const pt : {
    x: number,
    y: number
} = {x: 100, y: 100}

pt.z = 10000 // Error

可选项:

function printName(obj: {first: string, last?: string}) {
    
}

printName({first: 'Bob'})
printName({first: 'Alice', last: "Alisson"})

? 表达式

? 代表可能是 undefined,但是安全很多。

const o: {
    a: string,
    b?: {
        c: string
    }
} = { a: "1" }

console.log(o.b?.c) // undefined

o.b?.c = "Hello" // Error

联合

function printId(id: number | string) {
  console.log("Your ID is: " + id);
}

// OK
printId(101);
// OK
printId("202");
// Error
printId({ myID: 22342 });

联合类型只能使用两个类型的公共操作。

function printId(id: number | string) {
  console.log(id.toUpperCase());
  // Property 'toUpperCase' does not exist on type 'string | number'.
}

Typescript 会针对联合类型做排除法:

function printID(id: number | string) {
    if(typeof id === 'number') {
        console.log(id)
        return
    }
    console.log(id.toUpperCase())
}

这个也叫做类型窄化技术。

类型别名

type Point = {
  x: number;
  y: number;
};

function printCoord(pt: Point) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}

printCoord({ x: 100, y: 100 });

类型别名也可以使用联合:

type ID = number | string

注意,别名只是别名,例如:

let x: ID = 100
// typeof x === 'number'

当然别名可以和它代表的类型一起工作(因为别名不是创建了新的类型):

let id: ID = "abc"
id = 456 // OK

接口

interface Point {
  x: number;
  y: number;
}

function printCoord(pt: Point) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}

printCoord({ x: 100, y: 100 });
interface Animal {
    name: string
}

// 继承
interface Bear extends Animal {
    honey: boolean
}


type Animal = {
    name: string
}

// 组合
type Bear = Animal & {
    honey: boolean
}

接口的声明合并(Declaration Merging)

interface Box {
  height: number;
  width: number;
}
interface Box {
  scale: number;
}

let box: Box = { height: 5, width: 6, scale: 10 };

也可以把这种能力看做是向接口中添加成员的能力。

interface 和 type 区别:

1. interface 能被继承,type 是组合
2. interface 有 Declaration Merging

类型断言 (assertion)

有时候 TS 对类型的理解没有你多,这个时候你就需要用类型断言:

const myCanvas = 
    // HTMLElement
    document.getElementById("main_canvas") as HTMLCanvasElement;

通常 TS 会接收“说的通”的类型断言。

比如: 父类 as 子类, 联合 as 单个。

但是有的类型断言 TS 会拒绝,比如:

const x = 'hello' as number

TS 会报一个这样的错误:类型 "string" 到类型 "number" 的转换可能是错误的,因为两种类型不能充分重叠。如果这是有意的,请先将表达式转换为 "unknown"。ts(2352).

当然有时候可以用 any as T 来“欺骗” TS,或者说蒙混过关:

const a = (expression as unknown) as T;

字面类型

对于常量,在 TS 中实际上是 Literal Type。

比如:

const someStr = "abc"
// someStr 的类型是 "abc",它的值只能是 abc

const foo = 1
// foo 的类型是 1,而不是整数。

// 当然这只是 ts 的理解,如果用 typeof 操作符
// typeof someStr // 'string'
// typeof foo // 'number'

// 对于 let
let foo = 1 // foo: number

可以用字面类型来约束一些特殊的函数,比如:

function compare(a: string, b: string): -1 | 0 | 1 {
  return a === b ? 0 : a > b ? 1 : -1;
}

当然下面是一个更加贴近真实场景的例子:

interface Options {
  width: number;
}

function configure(x: Options | "auto") {
  // ...
}

configure({ width: 100 });
configure("auto");
configure("automatic"); 
// 类型“"automatic"”的参数不能赋给类型“Options | "auto"”的参数。ts(2345)

字面类型的一个坑:

function handleRequest(url: string, method: "GET" | "POST") {
    // do...
}

const req = { url: "https://example.com", method: "GET" };
handleRequest(req.url, req.method);
// Error : 类型“string”的参数不能赋给类型“"GET" | "POST"”的参数。ts(2345)

// 解决办法
// 1
const req = { url: "https://example.com", method: "GET" as "GET" };

// 2 
handleRequest(req.url, req.method as "GET");

// 3 
const req = { url: "https://example.com", method: "GET" } as const

null / undefined

null 和 undefined 是 Javascript 的两种基础类型(Primitive type),它们描述的是不同的行为:

  • undefined 是一个没有被分配值的变量
  • null 是一个被人为分配的空值

Typescript 有一个配置项,叫做strictNullChecks ,这个配置项设置为 on 的时候,在使用有可能是 null 的值前,需要显式的检查。

function doSomething(x: string | null) {
  if (x === null) {
    // do nothing
  } else {
    console.log("Hello, " + x.toUpperCase());
  }
}

另外, 可以用! 操作符,来断言某个值不是空值:

function doSomething(x: string | null) {
  console.log("Hello, " + x!.toUpperCase());
}

枚举类型

enum Direction {
  Up = 1,
  Down,
  Left,
  Right,
}

上面的含义, Down = 2, Left = 3, Right = 4

枚举类型最后会被翻译成整数,因此枚举的很多性质和整数相似。比如 Down.toString() 会返回 2,而不是Down 。正因为如此,枚举类型的效率很高。

当然如果想用字符串类的枚举(个人觉得没有必要),就需要显示的为每一项赋值:

enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT",
}

当然也可以混合,不过非但没有意义,而且会减少代码的可读性:

enum BooleanEnum {
  No = 0,
  Yes = "YES",
}

在运行时,Enum 会被解释成对象,Enum 的每项会被解释成常数。

下面这个例子可以很好的证明。

enum E {
  X,
  Y,
  Z,
}

function f(obj: { X: number }) {
  return obj.X;
}

f(E)

可以用下面这个语法提取 Enum 中的字符串,这个也叫 Reverse Mapping。

E[E.X] // X

泛型

泛型,可以理解为提取了一类事物的共性特征的一种抽象。比如说:松树、柳树都是树,在程序里有 3 种表达:

  • 接口(Interface)
  • 继承(Inheritance)
  • 泛型(Generics)

继承是一种强表达。

松树继承于树,松树同时也是木材。这样关系的表达,要么让松树多重集成(树、木材),要么松树<-树<-木材。

无论哪种,增加程序设计复杂度,也加强了继承关系的维护成本(或者说高耦合度)。这么看,关系太强,反而并不好!

接口是一种方面(Aspect)描述。比如松树可以生长,那么松树是:Growable;动植物都可以进化,那么它们是Evolvable。

一个类型可以拥有多个方面的特性。

**泛型(Generics)**是对共性的提取(不仅仅是描述)。

// 制床者
class BedMaker<T> {
    // ...
    make(){
        
    }
}

const A = new BedMaker<hongmu>()
const B = new BedMaker<tongmu>()
  • 木头可以制造床,但是不是所有的木头可以制造床
  • 制造床()这个方法,放到木头类中会很奇怪,因为木头不仅仅可以制造床
  • 同理,让木头继承于“可以制造床”这个接口也很奇怪

奇怪的代码展示:

class hongmu implements IMakeBed{
    makeBed(){...}
}

设计 IMakeBed 的目标是为了拆分描述事物不同的方面(Aspect),其实还有一个更专业的词汇——关注点(Interest Point)。拆分关注点的技巧,叫做关注点分离。如果仅仅用接口,不用泛型,那么关注点没有做到完全解耦。

泛型是一种 抽象共性(本质)的编程手段,它允许将类型作为其他类型的参数(表现形式),从而分离不同关注点的实现(作用)。

比如:

Array\<T\> 分离的是数据可以被线性访问、存储的共性。
Stream\<T\>分离的是数据可以随着时间产生的共性。
Promise\<T\>分离的是数据可以被异步计算的特性。
...

Hello 泛型

// 一个identity函数是自己返回自己的函数
// 当然可以声明它是:number -> number
function identity(arg: number): number {
  return arg;
}

// 为了让identity支持更多类型可以声明它是any
function identity(arg: any): any {
  return arg;
}

// any会丢失后续的所有检查,因此可以考虑用泛型
function identity<Type>(arg: Type): Type {
  return arg;
}


let output = identity<string>("MyString")
// 不用显示的指定<>中的类型
// let output = identity("MyString")

output = 100 // Error

<>叫做钻石💎操作符,代表传入的类型参数

泛型类

泛型类的例子:

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

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

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

当然推荐将声明(Declaration)和定义(Definition)写到一起:

class GenericNumber<T> {
    zeroValue: T
    
    constructor(v: T){
        this.zeroValue = v
    }
    
    add(x: T, y: T) {
        return x + y
    }
}

泛型约束(Generic Constraints)

下面的程序会报错:

function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length);
  // Property 'length' does not exist on type 'Type'.
  return arg;
}

考虑为arg增加约束:

interface Lengthwise {
  length: number;
}

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

还有一些小技巧 keyof 操作符

可以用 keyof 关键字作为泛型的约束。

type Point = { x: number; y: number };
type P = keyof Point;
// P = "x" | "y"

如下面这个例子:

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


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

getProperty(x, "a");
getProperty(x, "m"); // Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.

居然可以这么玩?

其实,对 TS 而言,所有对象的 key 是静态的。

const a = {x: 1, y: 2}
a.z = 3 // Error

因为是静态的,所以可以用 keyof 操作符求所有的 key。如果一个对象的类型是 any ,那么keyof 就没有意义了。

实例化泛型类型(将类作为参数)

function create<Type>(c: { new (): Type }): Type {
  return new c();
}

create(Foo) // Foo的实例

一个不错的例子:

class BeeKeeper {
  hasMask: boolean = true;
}

class ZooKeeper {
  nametag: string = "Mikle";
}

class Animal {
  numLegs: number = 4;
}

class Bee extends Animal {
  keeper: BeeKeeper = new BeeKeeper();
}

class Lion extends Animal {
  keeper: ZooKeeper = new ZooKeeper();
}

function createInstance<A extends Animal>(c: new () => A): A {
  return new c();
}

createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask;

思考:什么时候用接口?什么时候用泛型?

接口是约束一个类型的行为的。

泛型是提取共性,做成一个像模板的语法。

思考:将类型作为参数传递,并实例化有哪些应用场景?

小结:至此,掌握这些基本可以用 TS 干活了,剩下一些稍微复杂的,稍后再进阶一下就 ok 了。