從 0 到 1 認識 Typescript

yhlben發表於2020-06-12

最近這兩年,有很多人都在討論 Typescript,無論是社群還是各種文章都能看出來,整體來說正面的資訊是大於負面的,這篇文章就來整理一下我所瞭解的 Typescript。

本文首發於公眾號:【前端日誌】,歡迎關注。

本文主要分為 3 個部分:

  • Typescript 基本概念
  • Typescript 高階用法
  • Typescript 總結

Typescript 基本概念

至於官網的定義,這裡就不多做解釋了,大家可以去官網檢視。Typescript 設計目標

我理解的定義:賦予 Javascript 型別的概念,讓程式碼可以在執行前就能發現問題。

Typescript 都有哪些型別

1、Typescript 基本型別,也就是可以被直接使用的單一型別。

  • 數字
  • 字串
  • 布林型別
  • null
  • undefined
  • any
  • unknown
  • void
  • object
  • 列舉
  • never

2、複合型別,包含多個單一型別的型別。

  • 陣列型別
  • 元組型別
  • 字面量型別
  • 介面型別

3、如果一個型別不能滿足要求怎麼辦?

  • 可空型別,預設任何型別都可以被賦值成 null 或 undefined。
  • 聯合型別,不確定型別是哪個,但能提供幾種選擇,如:type1 | type2。
  • 交叉型別,必須滿足多個型別的組合,如:type1 & type2。

型別都在哪裡使用

在 Typescript 中,型別通常在以下幾種情況下使用。

  • 變數中使用
  • 類中使用
  • 介面中使用
  • 函式中使用

型別在變數中使用

在變數中使用時,直接在變數後面加上型別即可。

let a: number;
let b: string;
let c: null;
let d: undefined;
let e: boolean;
let obj: Ixxx = {
  a: 1,
  b: 2,
};
let fun: Iyyy = () => {};

型別在類中使用

在類中使用方式和在變數中類似,只是提供了一些專門為類設計的靜態屬性、靜態方法、成員屬性、建構函式中的型別等。

class Greeter {
    static name:string = 'Greeter'
    static log(){console.log(‘log')}
    greeting: string;
    constructor(message: string) {
        this.greeting = message;
    }
    greet() {
        return "Hello, " + this.greeting;
    }
}
let greeter = new Greeter("world");

型別在介面中使用

在介面中使用也比較簡單,可以理解為組合多個單一型別。

interface IData {
  name: string;
  age: number;
  func: (s: string) => void;
}

型別在函式中使用

在函式中使用型別時,主要用於處理函式引數、函式返回值。

// 函式引數
function a(all: string) {}
// 函式返回值
function a(a: string): string {}
// 可選引數
function a(a: number, b?: number) {}

Typescript 高階用法

Typescript 中的基本用法非常簡單,有 js 基礎的同學很快就能上手,接下來我們分析一下 Typescript 中更高階的用法,以完成更精密的型別檢查。

類中的高階用法

在類中的高階用法主要有以下幾點:

  • 繼承
  • 儲存器 get set
  • readonly 修飾符
  • 公有,私有,受保護的修飾符
  • 抽象類 abstract

繼承和儲存器和 ES6 裡的功能是一致的,這裡就不多說了,主要說一下類的修飾符和抽象類。

類中的修飾符是體現物件導向封裝性的主要手段,類中的屬性和方法在被不同修飾符修飾之後,就有了不同許可權的劃分,例如:

  • public 表示在當前類、子類、例項中都能訪問。
  • protected 表示只能在當前類、子類中訪問。
  • private 表示只能在當前類訪問。
class Animal {
  // 公有,私有,受保護的修飾符
  protected AnimalName: string;
  readonly age: number;
  static type: string;
  private _age: number;
  // 屬性儲存器
  get age(): number {
    return this._age;
  }
  set age(age: number) {
    this._age = age;
  }
  run() {
    console.log("run", this.AnimalName, this.age);
  }
  constructor(theName: string) {
    this.AnimalName = theName;
  }
}
Animal.type = "2"; // 靜態屬性
const dog = new Animal("dog");
dog.age = 2; // 給 readonly 屬性賦值會報錯
dog.AnimalName; // 例項中訪問 protected 報錯
dog.run; // 正常

在類中的繼承也十分簡單,和 ES6 的語法是一樣的。

class Cat extends Animal {
  dump() {
    console.log(this.AnimalName);
  }
}
let cat = new Cat("catname");

cat.AnimalName; // 受保護的物件,報錯
cat.run; // 正常
cat.age = 2; // 正常

在物件導向中,有一個比較重要的概念就是抽象類,抽象類用於類的抽象,可以定義一些類的公共屬性、公共方法,讓繼承的子類去實現,也可以自己實現。

抽象類有以下兩個特點。

  • 抽象類不能直接例項化
  • 抽象類中的抽象屬性和方法,必須被子類實現

tip 經典問題:抽象類的介面的區別

  • 抽象類要被子類繼承,介面要被類實現。

    • 在 ts 中使用 extends 去繼承一個抽象類。
    • 在 ts 中使用 implements 去實現一個介面。
  • 介面只能做方法宣告,抽象類中可以作方法宣告,也可以做方法實現。
  • 抽象類是有規律的,抽離的是一個類別的公共部分,而介面只是對相同屬性和方法的抽象,屬性和方法可以無任何關聯。

抽象類的用法如下。

abstract class Animal {
  abstract makeSound(): void;
  // 直接定義方法例項
  move(): void {
    console.log("roaming the earch...");
  }
}
class Cat extends Animal {
  makeSound() {} // 必須實現的抽象方法
  move() {
    console.log('move');
  }
}
new Cat3();

介面中的高階用法

介面中的高階用法主要有以下幾點:

  • 繼承
  • 可選屬性
  • 只讀屬性
  • 索引型別:字串和數字
  • 函式型別介面
  • 給類新增型別,建構函式型別

介面中除了可以定義常規屬性之外,還可以定義可選屬性、索引型別等。

interface Ia {
  a: string;
  b?: string; // 可選屬性
  readonly c: number; // 只讀屬性
  [key: number]: string; // 索引型別
}
// 介面繼承
interface Ib extends Ia {
  age: number;
}
let test1: Ia = {
  a: "",
  c: 2,
  age: 1,
};
test1.c = 2; // 報錯,只讀屬性
const item0 = test1[0]; // 索引型別

介面中同時也支援定義函式型別、建構函式型別。

// 介面定義函式型別
interface SearchFunc {
  (source: string, subString: string): boolean;
}
let mySearch: SearchFunc = function (x: string, y: string) {
  return false;
};
// 介面中編寫類的建構函式型別檢查
interface IClass {
  new (hour: number, minute: number);
}
let test2: IClass = class {
  constructor(x: number, y: number) {}
};

函式中的高階用法

函式中的高階用法主要有以下幾點:

  • 函式過載
  • this 型別

函式過載

函式過載指的是一個函式可以根據不同的入參匹配對應的型別。

例如:案例中的 doSomeThing 在傳一個引數的時候被提示為 number 型別,傳兩個引數的話,第一個引數就必須是 string 型別。

// 函式過載
function doSomeThing(x: string, y: number): string;
function doSomeThing(x: number): string;
function doSomeThing(x): any {}

let result = doSomeThing(0);
let result1 = doSomeThing("", 2);

This 型別

我們都知道,Javascript 中的 this 只有在執行的時候,才能夠判斷,所以對於 Typescript 來說是很難做靜態判斷的,對此 Typescript 給我們提供了手動繫結 this 型別,讓我們能夠在明確 this 的情況下,給到靜態的型別提示。

其實在 Javascript 中的 this,就只有這五種情況:

  • 物件呼叫,指向呼叫的物件
  • 全域性函式呼叫,指向 window 物件
  • call apply 呼叫,指向繫結的物件
  • dom.addEventListener 呼叫,指向 dom
  • 箭頭函式中的 this ,指向繫結時的上下文
// 全域性函式呼叫 - window
function doSomeThing() {
  return this;
}
const result2 = doSomeThing();

// 物件呼叫 - 物件
interface IObj {
  age: number;
  // 手動指定 this 型別
  doSomeThing(this: IObj): IObj;
  doSomeThing2(): Function;
}

const obj: IObj = {
  age: 12,
  doSomeThing: function () {
    return this;
  },
  doSomeThing2: () => {
    console.log(this);
  },
};
const result3 = obj.doSomeThing();
let globalDoSomeThing = obj.doSomeThing;
globalDoSomeThing(); // 這樣會報錯,因為我們只允許在物件中呼叫

// call apply 繫結對應的物件
function fn() {
  console.log(this);
}
fn.bind(document)();

// dom.addEventListener
document.body.addEventListener("click", function () {
  console.log(this); // body
});

泛型

泛型表示的是一個型別在定義時並不確定,需要在呼叫的時候才能確定的型別,主要包含以下幾個知識點:

  • 泛型函式
  • 泛型類
  • 泛型約束 T extends XXX

我們試想一下,如果一個函式,把傳入的引數直接輸出,我們怎麼去給它編寫型別?傳入的引數可以是任何型別,難道我們需要把每個型別都寫一遍?

  • 使用函式過載,得把每個型別都寫一遍,不適合。
  • 泛型,用一個型別佔位 T 去代替,在使用時指定對應的型別即可。
// 使用泛型
function doSomeThing<T>(param: T): T {
  return param;
}

let y = doSomeThing(1);

// 泛型類
class MyClass<T> {
  log(msg: T) {
    return msg;
  }
}

let my = new MyClass<string>();
my.log("");

// 泛型約束,可以規定最終執行時,只能是哪些型別
function d2<T extends string | number>(param: T): T {
  return param;
}
let z = d2(true);

其實泛型本來很簡單,但許多初學 Typescript 的同學覺得泛型很難,其實是因為泛型可以結合索引查詢符 keyof、索引訪問符 T[k] 等寫出難以閱讀的程式碼,我們來看一下。

// 以下四種方法,表達的含義是一致的,都是把物件中的某一個屬性的 value 取出來,組成一個陣列
function showKey1<K extends keyof T, T>(items: K[], obj: T): T[K][] {
  return items.map((item) => obj[item]);
}

function showKey2<K extends keyof T, T>(items: K[], obj: T): Array<T[K]> {
  return items.map((item) => obj[item]);
}

function showKey3<K extends keyof T, T>(
  items: K[],
  obj: { [K in keyof T]: any }
): T[K][] {
  return items.map((item) => obj[item]);
}

function showKey4<K extends keyof T, T>(
  items: K[],
  obj: { [K in keyof T]: any }
): Array<T[K]> {
  return items.map((item) => obj[item]);
}

let obj22 = showKey4<"age", { name: string; age: number }>(["age"], {
  name: "yhl",
  age: 12,
});

型別相容性

型別相容性是我認為 Typescript 中最難理解的一個部分,我們來分析一下。

  • 物件中的相容
  • 函式返回值相容
  • 函式引數列表相容
  • 函式引數結構相容
  • 類中的相容
  • 泛型中的相容

在 Typescript 中是通過結構體來判斷相容性的,如果兩個的結構體一致,就直接相容了,但如果不一致,Typescript 給我們提供了一下兩種相容方式:

A = B 這個表示式為例:

  • 協變,表示 B 的結構體必須包含 A 中的所有結構,即:B 中的屬性可以比 A 多,但不能少。
  • 逆變,和協變相反,即:B 中的所有屬性都在 A 中能找到,可以比 A 的少。
  • 雙向協變,即沒有規則,B 中的屬性可以比 A 多,也可以比 A 少。

物件中的相容

物件中的相容,採用的是協變。

let obj1 = {
  a: 1,
  b: "b",
  c: true,
};

let obj2 = {
  a: 1,
};

obj2 = obj1;
obj1 = obj2; // 報錯,因為 obj2 屬性不夠

函式返回值相容

函式返回值中的相容,採用的是協變。

let fun1 = function (): { a: number; b: string } {
  return { a: 1, b: "" };
};
let fun2 = function (): { a: number } {
  return { a: 1 };
};

fun1 = fun2; // 報錯,fun2 中沒有 b 引數
fun2 = fun1;

函式引數個數相容

函式引數個數的相容,採用的是逆變。

// 如果函式中的所有引數,都可以在賦值目標中找到,就能賦值
let fun1 = function (a: number, b: string) {};
let fun2 = function (a: number) {};

fun1 = fun2;
fun2 = fun1; // 報錯, fun1 中的 b 引數不能再 fun2 中找到

函式引數相容

函式引數相容,採用的是雙向協變。

let fn1 = (a: { name: string; age: number }) => {
  console.log("使用 name 和 age");
};
let fn2 = (a: { name: string }) => {
  console.log("使用 name");
};

fn2 = fn1; // 正常
fn1 = fn2; // 正常

tip 理解函式引數雙向協變

1、我們思考一下,一個函式 dog => dog,它的子函式是什麼?
注意:原函式如果被修改成了另一個函式,但他的型別是不會改變的,ts 還是會按照原函式的型別去做型別檢查!

  • grayDog => grayDog

    • 不對,如果傳了其他型別的 dog,沒有 grayDog 的方法,會報錯。
  • grayDog => animal

    • 同上。
  • animal => animal

    • 返回值不對,返回值始終是協變的,必須多傳。
  • animal => grayDog

    • 正確。

所以,函式引數型別應該是逆變的。

2、為什麼 Typescript 中的函式引數也是協變呢?

enum EventType { Mouse, Keyboard }
interface Event { timestamp: number; }
interface MouseEvent extends Event { x: number; y: number }

function listenEvent(eventType: EventType, handler: (n: Event) => void) {
  /* ... */
}
listenEvent(EventType.Mouse, (e: MouseEvent) => console.log(e.x + "," + e.y));

上面程式碼中,我們在呼叫時傳的是 mouse 型別,所以在回撥函式中,我們是知道返回的引數一定是一個 MouseEvent 型別,這樣是符合邏輯的,但由於 MouseEvent 型別的屬性是多於 Event 型別的,所以說 Typescript 的引數型別也是支援協變的。
:::

類中的相容

類中的相容,是在比較兩個例項中的結構體,是一種協變。

class Student1 {
  name: string;
  // private weight:number
}

class Student2 {
  // extends Student1
  name: string;
  age: number;
}

let student1 = new Student1();
let student2 = new Student2();

student1 = student2;
student2 = student1; // 報錯,student1 沒有 age 引數

需要注意的是,例項中的屬性和方法會受到類中修飾符的影響,如果是 private 修飾符,那麼必須保證兩者之間的 private 修飾的屬性來自同一物件。如上文中如果把 private 註釋放開的話,只能通過繼承去實現相容。

泛型中的相容

泛型中的相容,如果沒有用到 T,則兩個泛型也是相容的。

interface Empty<T> {}
let x1: Empty<number>;
let y1: Empty<string>;

x1 = y1;
y1 = x1;

高階型別

Typescript 中的高階型別包括:交叉型別、聯合型別、字面量型別、索引型別、對映型別等,這裡我們主要討論一下

  • 聯合型別
  • 對映型別

聯合型別

聯合型別是指一個物件可能是多個型別中的一個,如:let a :number | string 表示 a 要麼是 number 型別,要麼是 string 型別。

那麼問題來了,我們怎麼去確定執行時到底是什麼型別?

答:型別保護。型別保護是針對於聯合型別,讓我們能夠通過邏輯判斷,確定最終的型別,是來自聯合型別中的哪個型別。

判斷聯合型別的方法很多:

  • typeof
  • instanceof
  • in
  • 字面量保護,===!=====!=
  • 自定義型別保護,通過判斷是否有某個屬性等
// 自定義型別保護
function isFish(pet: Fish | Bird): pet is Fish {
  return (<Fish>pet).swim !== undefined;
}
if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}

對映型別

對映型別表示可以對某一個型別進行操作,產生出另一個符合我們要求的型別:

  • ReadOnly<T>,將 T 中的型別都變為只讀。
  • Partial<T>,將 T 中的型別都變為可選。
  • Exclude<T, U>,從 T 中剔除可以賦值給 U 的型別。
  • Extract<T, U>,提取 T 中可以賦值給 U 的型別。
  • NonNullable<T>,從 T 中剔除 null 和 undefined。
  • ReturnType<T>,獲取函式返回值型別。
  • InstanceType<T>,獲取建構函式型別的例項型別。

我們也可以編寫自定義的對映型別。

//定義toPromise對映
type ToPromise<T> = { [K in keyof T]: Promise<T[K]> };
type NumberList = [number, number];
type PromiseCoordinate = ToPromise<NumberList>;
// [Promise<number>, Promise<number>]

Typescript 總結

寫了這麼多,接下來說說我對 Typescript 的一些看法。

Typescript 優點

1、靜態型別檢查,提早發現問題。

2、型別即文件,便於理解,協作。

3、型別推導,自動補全,提升開發效率。

4、出錯時,可以大概率排除型別問題,縮短 bug 解決時間。

實戰中的優點:

1、發現 es 規範中棄用的方法,如:Date.toGMTString。

2、避免了一些不友好的開發程式碼,如:動態給 obj 新增屬性。

3、vue 使用變數,如果沒有在 data 定義,會直接丟擲問題。

Typescript 缺點

1、短期增加開發成本。

2、部分庫還沒有寫 types 檔案。

3、不是完全的超集。

實戰中的問題:

1、還有一些坑不好解決,axios 編寫了攔截器之後,typescript 反映不到 response 中去。

在這裡插入圖片描述

參考資料

相關文章