玩轉TypeScript工具型別(下)

有道技術團隊發表於2021-09-25


聯絡我們有道技術團隊助手:ydtech01 / 郵箱ydtech@rd.netease.com

本文是《玩轉TypeScript工具型別》系列的最後一篇,包含了如下幾部分內容:

  • ThisParameterType<Type>
  • OmitThisParameter<Type>
  • ThisType<Type>

快捷跳轉

一. ThisParameterType<Type>

提取一個函式型別顯式定義的 this 引數,如果沒有顯式定義的 this 引數,則返回 unknown 。 這裡有如下幾個需要注意的點:

  • this引數只能叫 this,且必須在引數列表的第一個位置
  • this 必須是顯式定義的
  • 這個 this 引數在函式實際被呼叫的時候不存在,不需要顯式作為引數傳入,而是通過 call、apply或者是 bind 等方法指定

1.1 原始碼解析

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

從原始碼可以看出對於型別引數 T 是要嚴格匹配 (this: infer U, ...args: any[]) => any格式的,所以對於 this 引數的名稱和位置都是固定的。剩下的邏輯就是對 this 引數的型別定義一個型別引數 U ,在 extends 判斷走 true 分支時返回 this 型別引數 U ,false 分支就返回 unknown。

1.2 實戰用法

顯式的定義 this 型別有助於我們在函式內部安全的使用 this 。

function toHex(this: Number) {
  return this.toString(16);
}
 
function numberToString(n: ThisParameterType<typeof toHex>) {
  return toHex.apply(n);
}

注:定義了一個函式,要使用這個函式的型別,可以直接使用 typeof [funcName] ,可以省去額外再定義一個型別宣告。

二. OmitThisParameter<Type>

有了 ThisParameterType獲取 this 的型別,那麼如何將一個定義了 this 引數型別的函式型別中的this 引數型別去掉呢? 這就是 OmitThisParameter 做的事情。

一句話概括,就是對於沒有定義 this 引數型別的函式型別,直接返回這個函式型別,如果定義了 this 引數型別,就返回一個僅是去掉了 this 引數型別的新函式型別。

2.1 原始碼解析

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

似乎有點長,其實就是兩個巢狀的 extends條件判斷,分成兩部分就很好理解了,首先是:

unknown extends ThisParameterType<T> ? T : ...

對於傳入的函式型別 T ,首先使用 ThisParameterType 獲取 this 引數的型別,可能有兩種結果一種是成功拿到 this 引數型別並返回,另一種是 unknown 。 所以如果返回的是 unknown ,那麼就是走 true 分支,直接返回 T 。如果不是返回的 unknown ,那麼就走 false 分支,即:

T extends (...args: infer A) => infer R ? (...args: A) => R : T

又是一個條件判斷,即只要 T 是一個合法的函式型別,就一定滿足 (...args: infer A) => infer R,剩下的就是對引數定義一個型別引數 A ,對返回值定義一個型別引數 R ,返回(...args: A) => R ,這個新的函式型別已經不包含 this 了。

2.2 實戰用法

function toHex(this: Number) {
  return this.toString(16);
}
 
const fiveToHex: OmitThisParameter<typeof toHex> = toHex.bind(5);
 
console.log(fiveToHex());

三. ThisType<Type>

這個工具型別非常特殊,第一個特殊之處就是它的原始碼定義,是一個空介面

/**
 * Marker for contextual 'this' type
 */
interface ThisType<T> { }

那麼 ThisType 的作用是什麼呢?正如官方註釋所寫的:作為上下文 this型別的標記。

要使用 ThisType 必須保證 noImplicitThis 配置開啟,後續我們只討論開啟的情況

那麼如何理解這句話呢?我們需要從實際效果來理解,先看如下這段程式碼:

let demo1 = {
  a: 'lipengpeng',
  test(msg: string) {
    this;
  }
};

它的 this 型別是什麼呢?

this: {
    a: string;
    test(msg: string): void;
}

也可以手動指定 this 型別,比如:

let demo2 = {
  a: 'lipengpeng',
  test(this:{a: string}, msg: string) {
    this;
  }
};

這時的 this 型別就是

this: {
    a: string
}

其實這只是理想情況下的 this 型別分析,因為 TypeScript 是通過靜態程式碼分析推斷出的型別,在實際執行階段的 this 是可能發生變化的,那麼我們如何指定執行階段的 this 型別呢?

如果只看如上兩種情況,可能覺得不用 ThisType 也足夠了,因為 TypeScript 會推斷 this 型別,但是這只是簡單情況,就如我們之前提到的,執行階段的 this 是可以改變的,所以僅是依賴程式碼分析是無法預測到未來的 this 型別的,這時候就需要藉助我們的主角—— ThisType 了。 我們繼續從實際的使用場景入手,實際開發中我們定義一個物件有時候會給一個資料結構,就類似於 Vue2.x Options API

let options = {
  data: {
    x: 0,
    y: 0
  },
  methods: {
    moveBy(dx: number, dy: number) {
      this.x += dx;
      this.y += dy;
    }
  }
}

我們希望在 moveBy 的 this 物件上可以直接獲取到 data 物件中的 x和y 。為了實現這個功能,我們需要對定義的資料結構做一些處理,讓 methods 和 data 中的屬性共享同一個 this 物件,因此我們需要一個工具方法 makeObject

function makeObject(config) {
  let data = config?.data || {}
  let methods = config?.methods || {}
  return {
    ...data,
    ...methods
  }
}

let options = makeObject({
  data: {
    x: 0,
    y: 0
  },
  methods: {
    moveBy(dx: number, dy: number) {
      this.x += dx;
      this.y += dy;
    }
  }
})

方法也很簡單,就是把 data 和 methods 展開,放在同一個物件 options 中,當我們通過options.moveBy() 的方式呼叫 moveBy 的時候,moveBy 的 this 就是這個物件。 功能實現了,那麼如何實現型別安全呢?接下來就需要在 makeObject 方法上做一些改動了,重點就是定義引數型別返回值型別

// 只考慮傳入makeObject的config引數只包含data和methods兩個引數
// 定義兩個泛型引數D & M來代表它們的型別
type ObjectConfigDesc<D, M> = {
  data: D
  methods: M
}

function makeObject<D, M>(config: ObjectConfigDesc<D, M>): D & M {
  let data = config?.data || {}
  let methods = config?.methods || {}
  return {
    ...data,
    ...methods
  } as D & M
}

此時 options 物件的型別已經是型別安全的了。但是我們最關心的 moveBy 中的 this 物件卻仍然會報型別警告,但我們知道在實際的執行過程中, moveBy 中的 this 物件已經可以取到 x 和 y 了,最後一步就是明確告訴 TypeScript 這個 this 物件的真實型別了,非常簡單,利用 ThisType :

type ObjectConfigDesc<D, M> = {
  data: D
  methods: M & ThisType<D & M>
}

這時候再看 options 的型別提示已經是正確的了:

let options: {
    x: number;
    y: number;
} & {
    moveBy(dx: number, dy: number): void;
}

大家可以在 TypeScript Playground 中親手試一試,感受會更深刻一些。

注意:ThisType 僅支援在物件字面量的上下文中使用,在其他地方使用作用等同於空介面。

四. 結束語

截止到這裡,《玩轉TypeScript工具型別》系列總計三篇就全部完成了,寫這個系列其實就是想記錄自己學習過程中的一些學習思路和感受,同時通過文章的方式寫下來加深自己的理解,所以如果有任何錯誤的地方歡迎批評指正。

相關文章