從 Vue typings 看 “this”

三毛丶發表於2018-07-18

在 2.5.0 版本中,Vue 大大改進了型別宣告系統以更好地使用預設的基於物件的 API。

意味著當我們僅是安裝 Vue 的宣告檔案時,一切也都將會按預期進行:

  • this,就是 Vue;
  • this 屬性上,具有 Methods 選項上定義的同名函式屬性;
  • 在例項 data、computed、prop 上定義的屬性/方法,也都將會出現在 this 屬性上;
  • ......

在這篇文章裡,我們來談談上述背後的故事。

Methods

當我們建立 Vue 例項,並在 Methods 上定義方法時, this 不僅具有 Vue 例項上屬性,同時也具有與 Methods 選項上同名的函式屬性:

new Vue({
  methods: {
    test () {
     this.$el   // Vue 例項上的屬性
    }
  },
  
  created () {
    this.test() // methods 選項上同名的方法
    this.$el    // Vue 例項上的屬性
  }
})
複製程式碼

為了探究其原理,我們把元件選項的宣告改寫成以下方式:

定義 Methods:

// methods 是 [key: string]: (this: Vue) => any 的集合
type Methods = Record<string, (this: Vue) => any>
複製程式碼

這會存在一個問題,Methods 上定義的方法裡的 this,全部都是 Vue 建構函式上的方法,而不能訪問我們自定義的方法。 我們需要把 Vue 例項傳進去:

type Methods<V> = Record<string, (this: V) => any>
複製程式碼

元件選項(同樣也需要傳例項):

interface ComponentOption<V> {
  methods: Methods<V>,
  created?(this: V): void
}
複製程式碼

我們可以使用它:

declare function testVue<V extends Vue>(option: ComponentOption<V>): V
複製程式碼

此種情形下,我們必須將元件例項的型別顯式傳入,從而使其編譯通過:

interface TestComponent extends Vue {
  test (): void
}

testVue<TestComponent>({
  methods: {
    test () {}
  },

  created () {
    this.test() // 編譯通過
    this.$el    // 通過
  }
})
複製程式碼

這有點麻煩,為了使它能按我們預期的工作,我們定義了一個額外的 interface。

在 Vue 的宣告檔案裡,使用了一種簡單的方式:通過使用 ThisType<T> 對映型別,讓 this 具有所需要的屬性。

在 TypeScript 倉庫 ThisType<T>PR 下,有一個使用例子:

從 Vue typings 看 “this”

在這個例子中,通過對 methods 的值使用 ThisType<D & M>,從而 TypeScript 推匯出 methods 物件中 this 即是: { x: number, y: number } & { moveBy(dx: number, dy: number ): void }

與此類似,我們可以讓 this 具有 Methods 上定義的同名函式屬性:

type DefaultMethods<V> = Record<string, (this: V) => any>

interface ComponentOption<
  V,
  Methods = DefaultMethods<V>
> {
  methods: Methods,
  created?(): void
}

declare function testVue<V extends Vue, Methods> (
  option: ComponentOption<V, Methods> & ThisType<V & Methods>
): V & Methods

testVue({
  methods: {
    test () {}
  },
  created () {
    this.test() // 編譯通過
    this.$el    // 例項上的屬性
  }
})
複製程式碼

在上面程式碼中,我們:

  • 建立了一個 ComponentOption interface,它有兩個引數,當前例項 Vue 與 預設值是 [key: string]: (this: V) => any 的 Methods。
  • 定義了一個函式 testVue,同時將範型 V, Methods 傳遞給 ComponentOption 與 ThisTypeThisType<V & Methods> 標誌著例項內的 this 即是 V 與 Methods 的交叉型別。
  • 當 testVue 函式被呼叫時,TypeScript 推斷出 Methods 為 { test (): void },從而在例項內 this 即是:Vue & { test (): void };

Data

得益於上文中的 ThisType<T>,Data 的處理有點類似與 Methods,唯一不同之處 Data 可有兩種不同型別,Object 或者 Function。它的型別寫法如下:

type DefaultData<V> =  object | ((this: V) => object)
複製程式碼

同樣,我們也把 ComponentOption 與 testVue 稍作修改

interface ComponentOption<
  V,
  Data = DefaultData<V>,
  Methods = DefaultMethods<V>
> {
  data: Data
  methods?: Methods,
  created?(): void
}

declare function testVue<V extends Vue, Data, Methods> (
  option: ComponentOption<V, Data, Methods> & ThisType<V & Data & Methods>
): V & Data& Methods

複製程式碼

當 Data 是 Object 時,它能正常工作:

testVue({
  data: {
    testData: ''
  },
  created () {
    this.testData // 編譯通過
  }
})
複製程式碼

當我們傳入 Function 時,它並不能:

從 Vue typings 看 “this”

TypeScript 推斷出 Data 是 (() => { testData: string }),這並不是期望的 { testData: string },我們需要對函式引數 options 的型別做少許修改,當 Data 傳入為函式時,取函式返回值:

declare function testVue<V extends Vue, Data, Method>(
  option: ComponentOption<V, Data | (() => Data), Method> & ThisType<V & Data & Method>
): V  & Data & Method
複製程式碼

這時候編譯可以通過:

testVue({
  data () {
    return {
      testData: ''
    }
  },

  created () {
    this.testData // 編譯通過
  }
})
複製程式碼

Computed

Computed 的處理似乎有點棘手:它與 Methods 不同,當我們在 Methods 中定義了一個方法,this 也會含有相同名字的函式屬性,而在 Computed 中定義具有返回值的方法時,我們期望 this 含有函式返回值的同名屬性。

舉個例子:

new Vue({
  computed: {
    testComputed () {
      return ''
    }
  },
  methods: {
    testFunc () {}
  },

  created () {
    this.testFunc()   // testFunc 是一個函式
    this.testComputed // testComputed 是 string,並不是一個返回值為 string 的函式
  }
})

複製程式碼

我們需要一個對映型別,把定義在 Computed 內具有返回值的函式,對映為 key 為函式名,值為函式返回值的新型別:

type Accessors<T> = {
  [K in keyof T]: (() => T[K])
}
複製程式碼

Accessors<T> 將會把型別 T,對映為具有相同屬性名稱,值為函式返回值的新型別,在型別推斷時,此過程相反。

接著,我們補充上例:

// Computed 是一組 [key: string]: any 的集合
type DefaultComputed = Record<string, any>

interface ComponentOption<
  V,
  Data = DefaultData<V>,
  Computed = DefaultComputed,
  Methods = DefaultMethods<V>
> {
  data?: Data,
  computed?: Accessors<Computed>
  methods?: Methods,
  created?(): void
}

declare function testVue<V extends Vue, Data, Compted, Methods> (
  option: ComponentOption<V, Data | (() => Data), Compted, Methods> & ThisType<V & Data & Compted & Methods>
): V & Data & Compted & Methods

testVue({
  computed: {
    testComputed () {
      return ''
    }
  },
  created () {
    this.testComputed // string
  }
})

複製程式碼

當呼叫 testVue 時,我們傳入一個屬性為 testComputed () => '' 的 Computed,TypeScript 會嘗試將型別對映至 Accessors<T>,從而推匯出 Computed 即是 { testComputed: string }

此外,Computed 具有另一個寫法:get 與 set 形式,我們只需要把對映型別做相應補充即可:

interface ComputedOptions<T> {
  get?(): T,
  set?(value: T): void
}

type Accessors<T> = {
  [K in keyof T]: (() => T[K]) | ComputedOptions<T[K]>
}
複製程式碼

Prop

在上篇文章在 Vue 中使用 TypeScript 的一些思考(實踐)中,我們已經討論了 Prop 的推導,在此不再贅述。

最後

此篇文章是對 Vue typings 的一次簡單解讀,希望大家看得懂原始碼時,不要忘記了 Vue typings,畢竟 Vue typings 才是給程式行為以提示和約束的關鍵。

參考

  • https://github.com/Microsoft/TypeScript/pull/14141
  • http://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-1.html#mapped-types
  • https://github.com/vuejs/vue/blob/dev/types/options.d.ts

相關文章