TypeScript高級用法詳解

中,由於我們將訪問修飾符設置爲public,因此我們通過實例man來訪問nameage屬性是被允許的,同時對age屬性重新賦值也是允許的。但是在某些情況下,我們希望某些屬性是對外不可見的,同時不允許被修改,那麼我們就可以使用private修飾符:

class Human {
    public name: string;
    private age: number; // 此處修改爲使用private修飾符
    public constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}

const man = new Human('tom', 20);
console.log(man.name); // -> tom
console.log(man.age);
// -> Property 'age' is private and only accessible within class 'Human'.

我們將age屬性的修飾符修改爲private後,在外部通過man.age對其進行訪問,TypeScript在編譯階段就會發現其是一個私有屬性並最終將會報錯。

注意:在TypeScript編譯之後的代碼中並沒有限制對私有屬性的存取操作。

編譯後的代碼如下:

var Human = /** @class */ (function () {
    function Human(name, age) {
        this.name = name;
        this.age = age;
    }
    return Human;
}());
var man = new Human('tom', 20);
console.log(man.name); // -> tom
console.log(man.age); // -> 20

使用private修飾符修飾的屬性或者方法在子類中也是不允許訪問的,示例如下:

class Human {
    public name: string;
    private age: number;
    public constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}

class Woman extends Human {
    private gender: number = 0;
    public constructor(name: string, age: number) {
        super(name, age);
        console.log(this.age);
    }
}

const woman = new Woman('Alice', 18);
// -> Property 'age' is private and only accessible within class 'Human'.

在上述示例中由於在父類Humanage屬性被設置爲private,因此在子類Woman中無法訪問到age屬性,爲了讓在子類中允許訪問age屬性,我們可以使用protected修飾符來對其進行修飾:

class Human {
    public name: string;
    protected age: number; // 此處修改爲使用protected修飾符
    public constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}

class Woman extends Human {
    private gender: number = 0;
    public constructor(name: string, age: number) {
        super(name, age);
        console.log(this.age);
    }
}

const woman = new Woman('Alice', 18); // -> 18

當我們將private修飾符用於構造函數時,則表示該類不允許被繼承或實例化,示例如下:

class Human {
    public name: string;
    public age: number;
    
    // 此處修改爲使用private修飾符
    private constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}

class Woman extends Human {
    private gender: number = 0;
    public constructor(name: string, age: number) {
        super(name, age);
    }
}

const man = new Human('Alice', 18);
// -> Cannot extend a class 'Human'. Class constructor is marked as private.
// -> Constructor of class 'Human' is private and only accessible within the class declaration.

當我們將protected修飾符用於構造函數時,則表示該類只允許被繼承,示例如下:

class Human {
    public name: string;
    public age: number;
    
    // 此處修改爲使用protected修飾符
    protected constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}

class Woman extends Human {
    private gender: number = 0;
    public constructor(name: string, age: number) {
        super(name, age);
    }
}

const man = new Human('Alice', 18);
// -> Constructor of class 'Human' is protected and only accessible within the class declaration.

另外我們還可以直接將修飾符放到構造函數的參數中,示例如下:

class Human {
    // public name: string;
    // private age: number;
    
    public constructor(public name: string, private age: number) {
        this.name = name;
        this.age = age;
    }
}

const man = new Human('tom', 20);
console.log(man.name); // -> tom
console.log(man.age);
// -> Property 'age' is private and only accessible within class 'Human'.

3、接口與構造器簽名

當我們的項目中擁有很多不同的類時並且這些類之間可能存在某方面的共同點,爲了描述這種共同點,我們可以將其提取到一個接口(interface)中用於集中維護,並使用implements關鍵字來實現這個接口,示例如下:

interface IHuman {
    name: string;
    age: number;
    walk(): void;
}

class Human implements IHuman {
    
    public constructor(public name: string, public age: number) {
        this.name = name;
        this.age = age;
    }

    walk(): void {
        console.log('I am walking...');
    }
}

上述代碼在編譯階段能順利通過,但是我們注意到在Human類中包含constructor構造函數,如果我們想在接口中爲該構造函數定義一個簽名並讓Human類來實現這個接口,看會發生什麼:

interface HumanConstructor {
  new (name: string, age: number);    
}

class Human implements HumanConstructor {
    
    public constructor(public name: string, public age: number) {
        this.name = name;
        this.age = age;
    }

    walk(): void {
        console.log('I am walking...');
    }
}
// -> Class 'Human' incorrectly implements interface 'HumanConstructor'.
// -> Type 'Human' provides no match for the signature 'new (name: string, age: number): any'.

然而TypeScript會編譯出錯,告訴我們錯誤地實現了HumanConstructor接口,這是因爲當一個類實現一個接口時,只會對實例部分進行編譯檢查,類的靜態部分是不會被編譯器檢查的。因此這裏我們嘗試換種方式,直接操作類的靜態部分,示例如下:

interface HumanConstructor {
  new (name: string, age: number);    
}

interface IHuman {
    name: string;
    age: number;
    walk(): void;
}

class Human implements IHuman {
    
    public constructor(public name: string, public age: number) {
        this.name = name;
        this.age = age;
    }

    walk(): void {
        console.log('I am walking...');
    }
}

// 定義一個工廠方法
function createHuman(constructor: HumanConstructor, name: string, age: number): IHuman {
    return new constructor(name, age);
}

const man = createHuman(Human, 'tom', 18);
console.log(man.name, man.age); // -> tom 18

在上述示例中通過額外創建一個工廠方法createHuman並將構造函數作爲第一個參數傳入,此時當我們調用createHuman(Human, 'tom', 18)時編譯器便會檢查第一個參數是否符合HumanConstructor接口的構造器簽名。

4、聲明合併

在聲明合併中最常見的合併類型就是接口了,因此這裏先從接口開始介紹幾種比較常見的合併方式。

4.1 接口合併

示例代碼如下:

interface A {
    name: string;
}

interface A {
    age: number;
}

// 等價於
interface A {
    name: string;
    age: number;
}

const a: A = {name: 'tom', age: 18};

接口合併的方式比較容易理解,即聲明多個同名的接口,每個接口中包含不同的屬性聲明,最終這些來自多個接口的屬性聲明會被合併到同一個接口中。

注意:所有同名接口中的非函數成員必須唯一,如果不唯一則必須保證類型相同,否則編譯器會報錯。對於函數成員,後聲明的同名接口會覆蓋掉之前聲明的同名接口,即後聲明的同名接口中的函數相當於一次重載,具有更高的優先級。

4.2 函數合併

函數的合併可以簡單理解爲函數的重載,即通過同時定義多個不同類型參數或不同類型返回值的同名函數來實現,示例代碼如下:

// 函數定義
function foo(x: number): number;
function foo(x: string): string;

// 函數具體實現
function foo(x: number | string): number | string {
    if (typeof x === 'number') {
        return (x).toFixed(2);
    }
    
    return x.substring(0, x.length - 1);
}

在上述示例中,我們對foo函數進行多次定義,每次定義的函數參數類型不同,返回值類型不同,最後一次爲函數的具體實現,在實現中只有在兼容到前面的所有定義時,編譯器纔不會報錯。

注意:TypeScript編譯器會優先從最開始的函數定義進行匹配,因此如果多個函數定義存在包含關係,則需要將最精確的函數定義放到最前面,否則將始終不會被匹配到。

4.3 類型別名聯合

類型別名聯合與接口合併有所區別,類型別名不會新建一個類型,只是創建一個新的別名來對多個類型進行引用,同時不能像接口一樣被實現(implements)繼承(extends),示例如下:

type HumanProperty = {
    name: string;
    age: number;
    gender: number;
};

type HumanBehavior = {
    eat(): void;
    walk(): void;
}

type Human = HumanProperty & HumanBehavior;

let woman: Human = {
    name: 'tom',
    age: 18,
    gender: 0,
    eat() {
        console.log('I can eat.');
    },
    walk() {
        console.log('I can walk.');
    }
}

class HumanComponent extends Human {
    constructor(public name: string, public age: number, public gender: number) {
        this.name = name;
        this.age = age;
        this.gender = gender;
    }
    
    eat() {
        console.log('I can eat.');
    }
    
    walk() {
        console.log('I can walk.');
    }
}
// -> 'Human' only refers to a type, but is being used as a value here.

5、keyof 索引查詢

在TypeScript中的keyof有點類似於JS中的Object.keys()方法,但是區別在於前者遍歷的是類型中的字符串索引,後者遍歷的是對象中的鍵名,示例如下:

interface Rectangle {
    x: number;
    y: number;
    width: number;
    height: number;
}

type keys = keyof Rectangle;
// 等價於
type keys = "x" | "y" | "width" | "height";

// 這裏使用了泛型,強制要求第二個參數的參數名必須包含在第一個參數的所有字符串索引中
function getRectProperty<T extends object, K extends keyof T>(rect: T, property: K): T[K] {
    return rect[property];
} 

let rect: Rectangle = {
    x: 50,
    y: 50,
    width: 100,
    height: 200
};

console.log(getRectProperty(rect, 'width')); // -> 100
console.log(getRectProperty(rect, 'notExist'));
// -> Argument of type '"notExist"' is not assignable to parameter of type '"width" | "x" | "y" | "height"'.

在上述示例中我們通過使用keyof來限制函數的參數名property必須被包含在類型Rectangle的所有字符串索引中,如果沒有被包含則編譯器會報錯,可以用來在編譯時檢測對象的屬性名是否書寫有誤。

6、Partial 可選屬性

在某些情況下,我們希望類型中的所有屬性都不是必需的,只有在某些條件下才存在,我們就可以使用Partial來將已聲明的類型中的所有屬性標識爲可選的,示例如下:

// 該類型已內置在TypeScript中
type Partial<T> = {
    [P in keyof T]?: T[P]
};

interface Rectangle {
    x: number;
    y: number;
    width: number;
    height: number;
}

type PartialRectangle = Partial<Rectangle>;
// 等價於
type PartialRectangle = {
    x?: number;
    y?: number;
    width?: number;
    height?: number;
}

let rect: PartialRectangle = {
    width: 100,
    height: 200
};

在上述示例中由於我們使用Partial將所有屬性標識爲可選的,因此最終rect對象中雖然只包含widthheight屬性,但是編譯器依舊沒有報錯,當我們不能明確地確定對象中包含哪些屬性時,我們就可以通過Partial來聲明。

7、Pick 部分選擇

在某些應用場景下,我們可能需要從一個已聲明的類型中抽取出一個子類型,在子類型中包含父類型中的部分或全部屬性,這時我們可以使用Pick來實現,示例代碼如下:

// 該類型已內置在TypeScript中
type Pick<T, K extends keyof T> = {
    [P in K]: T[P]
};

interface User {
    id: number;
    name: string;
    age: number;
    gender: number;
    email: string;
}

type PickUser = Pick<User, "id" | "name" | "gender">;
// 等價於
type PickUser = {
    id: number;
    name: string;
    gender: number;
};

let user: PickUser = {
    id: 1,
    name: 'tom',
    gender: 1
};

在上述示例中,由於我們只關心user對象中的idnamegender是否存在,其他屬性不做明確規定,因此我們就可以使用PickUser接口中揀選出我們關心的屬性而忽略其他屬性的編譯檢查。

8、never 永不存在

never表示的是那些永不存在的值的類型,比如在函數中拋出異常或者無限循環,never類型可以是任何類型的子類型,也可以賦值給任何類型,但是相反卻沒有一個類型可以作爲never類型的子類型,示例如下:

// 函數拋出異常
function throwError(message: string): never {
    throw new Error(message);
}

// 函數自動推斷出返回值爲never類型
function reportError(message: string) {
    return throwError(message);
}

// 無限循環
function loop(): never {
    while(true) {
        console.log(1);
    }
}

// never類型可以是任何類型的子類型
let n: never;
let a: string = n;
let b: number = n;
let c: boolean = n;
let d: null = n;
let e: undefined = n;
let f: any = n;

// 任何類型都不能賦值給never類型
let a: string = '123';
let b: number = 0;
let c: boolean = true;
let d: null = null;
let e: undefined = undefined;
let f: any = [];

let n: never = a;
// -> Type 'string' is not assignable to type 'never'.

let n: never = b;
// -> Type 'number' is not assignable to type 'never'.

let n: never = c;
// -> Type 'true' is not assignable to type 'never'.

let n: never = d;
// -> Type 'null' is not assignable to type 'never'.

let n: never = e;
// -> Type 'undefined' is not assignable to type 'never'.

let n: never = f;
// -> Type 'any' is not assignable to type 'never'.

9、Exclude 屬性排除

Pick相反,Pick用於揀選出我們需要關心的屬性,而Exclude用於排除掉我們不需要關心的屬性,示例如下:

// 該類型已內置在TypeScript中
// 這裏使用了條件類型(Conditional Type),和JS中的三目運算符效果一致
type Exclude<T, U> = T extends U ? never : T;

interface User {
    id: number;
    name: string;
    age: number;
    gender: number;
    email: string;
}

type keys = keyof User; // -> "id" | "name" | "age" | "gender" | "email"

type ExcludeUser = Exclude<keys, "age" | "email">;
// 等價於
type ExcludeUser = "id" | "name" | "gender";

在上述示例中我們通過在ExcludeUser中傳入我們不需要關心的ageemail屬性,Exclude會幫助我們將不需要的屬性進行剔除,留下的屬性idnamegender即爲我們需要關心的屬性。一般來說,Exclude很少單獨使用,可以與其他類型配合實現更復雜更有用的功能。

10、Omit 屬性忽略

在上一個用法中,我們使用Exclude來排除掉其他不需要的屬性,但是在上述示例中的寫法耦合度較高,當有其他類型也需要這樣處理時,就必須再實現一遍相同的邏輯,不妨我們再進一步封裝,隱藏這些底層的處理細節,只對外暴露簡單的公共接口,示例如下:

// 使用Pick和Exclude組合實現
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

interface User {
    id: number;
    name: string;
    age: number;
    gender: number;
    email: string;
}

// 表示忽略掉User接口中的age和email屬性
type OmitUser = Omit<User, "age" | "email">;
// 等價於
type OmitUser = {
  id: number;
  name: string;
  gender: number;
};

let user: OmitUser = {
    id: 1,
    name: 'tom',
    gender: 1
};

在上述示例中,我們需要忽略掉User接口中的ageemail屬性,則只需要將接口名和屬性傳入Omit即可,對於其他類型也是如此,大大提高了類型的可擴展能力,方便複用。

總結

在本文中總結了幾種TypeScript的使用技巧,如果在我們的TypeScript項目中發現有很多類型聲明的地方具有共性,那麼不妨可以使用文中的幾種技巧來對其進行優化改善,增加代碼的可維護性和可複用性。筆者之前使用TypeScript的機會也不多,所以最近也是一邊學習一邊總結,如果文中有錯誤的地方,還希望能夠在評論區指正。

交流

如果你覺得這篇文章的內容對你有幫助,能否幫個忙關注一下筆者的公衆號[前端之境],每週都會努力原創一些前端技術乾貨,關注公衆號後可以邀你加入前端技術交流羣,我們可以一起互相交流,共同進步。

文章已同步更新至Github博客,若覺文章尚可,歡迎前往star!

你的一個點贊,值得讓我付出更多的努力!

逆境中成長,只有不斷地學習,才能成爲更好的自己,與君共勉!

轉自https://www.cnblogs.com/tangshiwei/p/12052494.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章