1. 程式人生 > 程式設計 >詳解Typescript裡的This的使用方法

詳解Typescript裡的This的使用方法

this可以說是Javascript裡最難理解的特性之一了,Typescript裡的 this 似乎更加複雜了,Typescript裡的 this 有三中場景,不同的場景都有不同意思。

  • this 引數: 限制呼叫函式時的 this 型別
  • this 型別: 用於支援鏈式呼叫,尤其支援 class 繼承的鏈式呼叫
  • ThisType: 用於構造複雜的 factory 函式

this 引數

由於 javascript 支援靈活的函式呼叫方式,不同的呼叫場景,this 的指向也有所不同

  • 作為物件的方法呼叫
  • 作為普通函式呼叫
  • 作為構造器呼叫
  • 作為 Function.prototype.call 和 Function.prototype.bind 呼叫

物件方法呼叫

這也是絕大部分 this 的使用場景,當函式作為物件的 方法呼叫時,this 指向該物件

const obj = {
 name: "yj",getName() {
 return this.name // 可以自動推導為{ name:string,getName():string}型別
 },}
obj.getName() // string型別

這裡有個坑就是如果物件定義時物件方法是使用箭頭函式進行定義,則 this 指向的並不是物件而是全域性的 window,Typescript 也自動的幫我推導為 window

const obj2 = {
 name: "yj",getName: () => {
 return this.name // check 報錯,這裡的this指向的是window
 },}
obj2.getName() // 執行時報錯

普通函式呼叫

即使是通過非箭頭函式定義的函式,當將其賦值給變數,並直接通過變數呼叫時,其執行時 this 執行的並非物件本身

const obj = {
 name: "yj",getName() {
 return this.name
 },}
const fn1 = obj.getName
fn1() // this指向的是window,執行時報錯

很不幸,上述程式碼在編譯期間並未檢查出來,我們可以通過為getName新增this的型別標註解決該問題

interface Obj {
 name: string
 // 限定getName呼叫時的this型別
 getName(this: Obj): string
}
const obj: Obj = {
 name: "yj",}
obj.getName() // check ok
const fn1 = obj.getName
fn1() // check error

這樣我們就能報保證呼叫時的 this 的型別安全

構造器呼叫

在 class 出現之前,一直是把 function 當做建構函式使用,當通過 new 呼叫 function 時,構造器裡的 this 就指向返回物件

function People(name: string) {
 this.name = name // check error
}
People.prototype.getName = function() {
 return this.name
}
const people = new People() // check error

很不幸,Typescript 暫時對 ES5 的 constructor function 的型別推斷暫時並未支援 https://github.com/microsoft/TypeScript/issues/18171),沒辦法推匯出 this 的型別和 people 可以作為建構函式呼叫,因此需要顯示的進行型別標註

interface People {
 name: string
 getName(): string
}
interface PeopleConstructor {
 new (name: string): People // 宣告可以作為建構函式呼叫
 prototype: People // 宣告prototype,支援後續修改prototype
}
const ctor = (function(this: People,name: string) {
 this.name = name
} as unknown) as PeopleConstructor // 型別不相容,二次轉型

ctor.prototype.getName = function() {
 return this.name
}

const people = new ctor("yj")
console.log("people:",people)
console.log(people.getName())

當然最簡潔的方式,還是使用 class

class People {
 name: string
 constructor(name: string) {
 this.name = name // check ok
 }
 getName() {
 return this.name
 }
}

const people = new People("yj") // check ok

這裡還有一個坑,即在 class 裡 public field method 和 method 有這本質的區別 考慮如下三種 method

class Test {
 name = 1
 method1() {
 return this.name
 }
 method2 = function() {
 return this.name // check error
 }
 method3 = () => {
 return this.name
 }
}

const test = new Test()

console.log(test.method1()) // 1
console.log(test.method2()) // 1
console.log(test.method3()) // 1

雖然上述三個程式碼都能成功的輸出 1,但是有這本質的區別

  • method1: 原型方法,動態 this,非同步回撥場景下需要自己手動 bind this
  • method2: 例項方法,型別報錯,非同步場景下需要手動 bind this
  • method3: 例項方法,靜態 this,非同步場景下不需要手動 bind this

在我們編寫 React 應用時,大量的使用了 method3 這種自動繫結 this 的方式, 但實際上這種做法存在較大的問題

  • 每個例項都會建立一個例項方法,造成了浪費
  • 在處理繼承時,會導致違反直覺的現象
class Parent {
 constructor() {
 this.setup()
 }

 setup = () => {
 console.log("parent")
 }
}

class Child extends Parent {
 constructor() {
 super()
 }

 setup = () => {
 console.log("child")
 }
}

const child = new Child() // parent

class Parent2 {
 constructor() {
 this.setup()
 }

 setup() {
 console.log("parent")
 }
}

class Child2 extends Parent2 {
 constructor() {
 super()
 }
 setup() {
 console.log("child")
 }
}

const child2 = new Child2() // child

在處理繼承的時候,如果 superclass 呼叫了示例方法而非原型方法,那麼是無法在 subclass 裡進行 override 的,這與其他語言處理繼承的 override 的行為向左,很容出問題。 因此更加合理的方式應該是不要使用例項方法,但是如何處理 this 的繫結問題呢。 目前較為合理的方式要麼手動 bind,或者使用 decorator 來做 bind

import autobind from "autobind-decorator"
class Test {
 name = 1
 @autobind
 method1() {
 return this.name
 }
}

call 和 apply 呼叫

call 和 apply 呼叫沒有什麼本質區別,主要區別就是 arguments 的傳遞方式,不分別討論。和普通的函式呼叫相比,call 呼叫可以動態的改變傳入的 this, 幸運的是 Typescript 藉助 this 引數也支援對 call 呼叫的型別檢查

interface People {
 name: string
}
const obj1 = {
 name: "yj",getName(this: People) {
 return this.name
 },}
const obj2 = {
 name: "zrj",}
const obj3 = {
 name2: "zrj",}
obj1.getName.call(obj2)
obj1.getName.call(obj3) // check error

另外 call 的實現也非常有意思,可以簡單研究下其實現,我們的實現就叫做 call2 首先需要確定 call 裡 第一個引數的型別,很明顯 第一個引數 的型別對應的是函式裡的 this 引數的型別,我們可以通過 ThisParameterType 工具來獲取一個函式的 this 引數型別

interface People {
 name: string
}
function ctor(this: People) {}

type ThisArg = ThisParameterType<typeof ctor> // 為People型別

ThisParameterType 的實現也很簡單,藉助 infer type 即可
type ThisParameterType<T> = T extends (this: unknown,...args: any[]) => any
 T extends (this: infer U,...args: any[]) => any
 ? U
 : unknown

但是我們怎麼獲取當前函式的型別呢,通過泛型例項化和泛型約束

interface CallableFunction {
 call2<T>(this: (this: T) => any,thisArg: T): any
}
interface People {
 name: string
}
function ctor(this: People) {}
ctor.call2() //

在進行 ctor.call 呼叫時,根據 CallableFunction 的定義其 this 引數型別為 (this:T) => any,而此時的 this 即為 ctor,而根據 ctro 的型別定義,其型別為 (this:People) => any,例項化即可得此時的 T 例項化型別為 People,即 thisArg 的型別為 People
進一步的新增返回值和其餘引數型別

interface CallableFunction {
 call<T,A extends any[],R>(
 this: (this: T,...args: A) => R,thisArg: T,...args: A
 ): R
}

This Types

為了支援 fluent interface,需要支援方法的返回型別由呼叫示例確定,這實際上需要型別系統的額外至此。考慮如下程式碼

class A {
 A1() {
 return this
 }
 A2() {
 return this
 }
}
class B extends A {
 B1() {
 return this
 }
 B2() {
 return this
 }
}
const b = new B()
const a = new A()
b.A1().B1() // 不報錯
a.A1().B1() // 報錯
type M1 = ReturnType<typeof b.A1> // B
type M2 = ReturnType<typeof a.A1> // A

仔細觀察上述程式碼發現,在不同的情況下,A1 的返回型別實際上是和呼叫物件有關的而非固定,只有這樣才能支援如下的鏈式呼叫,保證每一步呼叫都是型別安全

b.A1()
 .B1()
 .A2()
 .B2() // check ok

this 的處理還有其特殊之處,大部分語言對 this 的處理,都是將其作為隱式的引數處理,但是對於函式來講其引數應該是逆變的,但是 this 的處理實際上是當做協變處理的。考慮如下程式碼

class Parent {
 name: string
}
class Child extends Parent {
 age: number
}
class A {
 A1() {
 return this.A2(new Parent())
 }
 A2(arg: Parent) {}
 A3(arg: string) {}
}
class B extends A {
 A1() {
 // 不報錯,this特殊處理,視為協變
 return this.A2(new Parent())
 }
 A2(arg: Child) {} // flow下報錯,typescript沒報錯
 A3(arg: number) {} // flow和typescript下均報錯
}

這裡還要提的一點是 Typescript 處於相容考慮,對方法進行了雙變處理,但是函式還是採用了逆變,相比之下 flow 則安全了許多,方法也採用了逆變處理

ThisType

Vue2.x 最令人詬病的一點就是對 Typescript 的羸弱支援,其根源也在於 vue2.x 的 api 大量使用了 this,造成其型別難以推斷,Vue2.5 通過 ThisType 對 vue 的 typescript 支援進行了一波增強,但還是有不足之處,Vue3 的一個大的賣點也是改進了增強了對 Typescript 的支援。下面我們就研究下下 ThisType 和 vue 中是如何利用 ThisType 改進 Typescript 的支援的。

先簡單說一下 This 的決斷規則,推測物件方法的 this 型別規則如下,優先順序由低到高

物件字面量方法的 this 型別為該物件字面量本身

// containing object literal type
let foo = {
 x: "hello",f(n: number) {
 this //this: {x: string;f(n: number):void }
 },}

如果物件字面量進行了型別標註了,則 this 型別為標註的物件型別

type Point = {
 x: number
 y: number
 moveBy(dx: number,dy: number): void
}

let p: Point = {
 x: 10,y: 20,moveBy(dx,dy) {
 this // Point
 },}

如果物件字面量的方法有 this 型別標註了,則為標註的 this 型別

let bar = {
 x: "hello",f(this: { message: string }) {
 this // { message: string }
 },}

如果物件字面量的即進行了型別標註,同時方法也標註了型別,則方法的標註 this 型別優先

type Point = {
 x: number
 y: number
 moveBy(dx: number,moveBy(this: { message: string },dx,dy) {
 this // {message:string},方法型別標註優先順序高於物件型別標註
 },}

如果物件字面量進行了型別標註,且該型別標註裡包含了 ThisType,那麼 this 型別為 T

type Point = {
 x: number
 y: number
 moveBy: (dx: number,dy: number) => void
} & ThisType<{ message: string }>

let p: Point = {
 x: 10,dy) {
 this // {message:string}
 },}

如果物件字面量進行了型別標註,且型別標註裡指明瞭 this 型別,則使用該標註型別

type Point = {
 x: number
 y: number
 moveBy(this: { message: string },dx: number,dy) {
 this // { message:string}
 },}

將規則按從高到低排列如下

  • 如果方法裡顯示標註了 this 型別,這是用該標註型別
  • 如果上述沒標註,但是物件標註的型別裡的方法型別標註了 this 型別,則使用該 this 型別
  • 如果上述都沒標註,但物件標註的型別裡包含了 ThisType,那麼 this 型別為 T
  • 如果上述都沒標註,this 型別為物件的標註型別
  • 如果上述都沒標註,this 型別為物件字面量型別

這裡的一條重要規則就是在沒有其他型別標註的情況下,如果物件標註的型別裡如果包含了 ThisType,那麼 this 型別為 T,這意味著我們可以通過型別計算為我們的物件字面量新增字面量裡沒存在的屬性,這對於 Vue 極其重要。 我們來看一下 Vue 的 api

import Vue from 'vue';
export const Component = Vue.extend({
 data(){
 return {
  msg: 'hello'
 }
 }
 methods:{
 greet(){
  return this.msg + 'world';
 }
 }
})

這裡的一個主要問題是 greet 是 methods 的方法,其 this 預設是 methods 這個物件字面量的型別,因此無法從中區獲取 data 的型別,所以主要難題是如何在 methods.greet 裡型別安全的訪問到 data 裡的 msg。 藉助於泛型推導和 ThisType 可以很輕鬆的實現,下面讓我們自己實現一些這個 api

type ObjectDescriptor<D,M> = {
 data: () => D
 methods: M & ThisType<D & M>
}

declare function extend<D,M>(obj: ObjectDescriptor<D,M>): D & M

const x = extend({
 data() {
 return {
  msg: "hello",}
 },methods: {
 greet() {
  return this.msg + "world" // check
 },},})

其推導規則如下 首先根據物件字面量的型別和泛型約束對比,可得到型別引數 T 和 M 的例項化型別結果

D: { msg: string}
M: {
 greet(): todo
}

接著推導 ObjectDescriptor 型別為

{
 data(): { msg: string},methods: {
 greet(): string
 } & ThisType<{msg:string} & {greet(): todo}>
}

接著藉助推匯出來的 ObjectDescriptor 推匯出 greet 裡的 this 型別為

{ msg: string} & { greet(): todo}

因此推匯出 this.msg 型別為 string,進一步推匯出 greet 的型別為 string,至此所有型別推完。 另外為了減小 Typescript 的型別推倒難度,應該儘可能的顯示的標註型別,防止出現迴圈推導或者造成推導複雜度變高等導致編譯速度過慢甚至出現死迴圈或者記憶體耗盡的問題。

type ObjectDescriptor<D,methods: {
 greet(): string {
  // 顯示的標註返回型別,簡化推導
  return this.msg + "world" // check
 },})

到此這篇關於詳解Typescript裡的This的使用方法的文章就介紹到這了,更多相關Typescript This內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!