在 Vue 中使用 TypeScript 的一些思考(實踐)

三毛發表於2018-07-06

Vue.extend or vue-class-component

使用 TypeScript 寫 Vue 元件時,有兩種推薦形式:

  • Vue.extend():使用基礎 Vue 構造器,建立一個“子類”。此種寫法與 Vue 單檔案元件標準形式最為接近,唯一不同僅是元件選項需要被包裹在 Vue.extend() 中。
  • vue-class-component:通常與 vue-property-decorator 一起使用,提供一系列裝飾器,能讓我們書寫類風格的 Vue 元件。

兩種形式輸出結果一致,同是建立一個 Vue 子類,但在書寫元件選項如 props,mixin 時,有些不同。特別是當你使用 Vue.extend() 時,為了讓 TypeScript 正確推斷型別,你將不得不做一些額外的處理。接下來,我們來聊一聊它們的細節差異。

Prop

由於元件例項的作用域是孤立的,當從父元件傳遞資料到子元件時,我們通常使用 Prop 選項。同時,為了確保 Prop 的型別安全,我們會給 Prop 新增指定型別驗證,形式如下:

export default {
  props: {
    someProp: {
      type: Object,
      required: true,
      default: () => ({ message: 'test' })
    }
  }
}
複製程式碼

我們定義了一個 someProp,它的型別是 Object。

使用 JavaScript 時,這並沒有什麼不對的地方,但當你使用 TypeScript 時,這有點不足,我們並不能得到有關於 someProp 更多有用的資訊(比如它含有某些屬性),甚至在 TypeScript 看來,這將會是一個 any 型別:

在 Vue 中使用 TypeScript 的一些思考(實踐)

這意味著我們可以使用 someProp 上的任意屬性(存在或者是不存在的)都可以通過編譯。為了防止此種情況的發生,我們將會給 Prop 新增型別註釋。

Vue.extend()

使用 Vue.extend() 方法新增型別註釋時,需要給 type 斷言:

import Vue from 'vue'

interface User {
  name: string,
  age: number
}

export default Vue.extend({
  props: {
    testProps: {
      type: Object as () => User
    }
  }
})

複製程式碼

當元件內訪問 testProps 時,便能得到相關提示:

在 Vue 中使用 TypeScript 的一些思考(實踐)

然而,你必須以函式返回值的形式斷言,並不能直接斷言:

export default Vue.extend({
  props: {
    testProps: {
      type: Object as User
    }
  }
})
複製程式碼

它會給出錯誤警告,User 介面並沒有實現原生 Object 建構函式所執行的方法: Type 'ObjectConstructor' cannot be converted to type 'User'. Property 'id' is missing in type 'ObjectConstructor'.

實際上,我們可從 Prop type declaration

export type Prop<T> = { (): T } | { new (...args: any[]): T & object }

export type PropValidator<T> = PropOptions<T> | Prop<T> | Prop<T>[];

export interface PropOptions<T=any> {
  type?: Prop<T> | Prop<T>[];
  required?: boolean;
  default?: T | null | undefined | (() => object);
  validator?(value: T): boolean;
}

複製程式碼

可知 Prop type 可以以兩種不同方式出現:

  • 含有一個呼叫簽名的範型 type,該簽名返回 T;
  • 一個範型建構函式簽名,該函式建立指定型別 T 物件 (返回值 T & object 用於降低優先順序,當兩種方式同時滿足時取第一種,其次它還可以用於標記建構函式不應該返回原始型別)。

當我們指定 type 型別為 String/Number/Boolean/Array/Object/Date/Function/Symbol 原生建構函式時,Prop 會返回它們各自簽名的返回值。

當 type 型別為 String 建構函式時,它的呼叫簽名返回為 string:

// lib.es5.d.ts
interface StringConstructor {
  new(value?: any): String;
  (value?: any): string;
  readonly prototype: String;
  fromCharCode(...codes: number[]): string;
}
複製程式碼

而這也是上文中,當指定 type 型別為 Object 建構函式時,經過 Vue 的宣告檔案處理,TypeScript 推斷出為 any 型別的原因:

interface ObjectConstructor {
  new(value?: any): Object;
  (): any;
  (value: any): any;
  // 其它屬性 ....
}

複製程式碼

類似的,當我們使用關鍵字 as 斷言 Object 為 () => User 時,它能推斷出為 User 。

從 type 第二部分可知,除傳入原生建構函式外,我們還可傳入自定義類:

在 Vue 中使用 TypeScript 的一些思考(實踐)

此外,這裡有個 PR 暴露一個更直觀的型別( Vue 2.6 版本才可以用):

props: {
  testProp: Object as PropTypes<{ test: boolean }>
}
複製程式碼

vue-class-component

得益於 vue-propperty-decorator Prop 修飾器,當給 Prop 增加型別推斷時,這些將變得簡單:

import { Component, Vue, Prop } from 'vue-property-decorator'

@Component
export default class Test extends Vue {
  @Prop({ type: Object })
  private test: { value: string }
}
複製程式碼

當我們在元件內訪問 test 時,便能獲取它正確的型別資訊。

mixins

mixins 是一種分發 Vue 元件中可複用功能的一種方式。當在 TypeScript 中使用它時,我們希望得到有關於 mixins 的型別資訊。

當你使用 Vue.extends() 時,這有點困難,它並不能推斷出 mixins 裡的型別:

// ExampleMixin.vue
export default Vue.extend({
  data () {
    return {
      testValue: 'test'
    }
  }
})

// other.vue
export default Vue.extend({
  mixins: [ExampleMixin],
  created () {
    this.testValue // error, testValue 不存在!
  }
})
複製程式碼

我們需要稍作修改:

// other.vue
export default ExampleMixin.extend({
  mixins: [ExampleMixin],
  created () {
    this.testValue // 編譯通過
  }
})

複製程式碼

但這會存在一個問題,當使用多個 mixins 且推斷出型別時,這將無法工作。而在這個 Issuse 中官方也明確表示,這無法被修改。

使用 vue-class-component 這會方便很多:

// ExampleMixin.vue
import Vue from 'vue'
import Component from 'vue-class-component'

@Component
export class ExampleMixin extends Vue {
  public testValue = 'test'
}

// other.vue
import Component, { mixins } from 'vue-class-component'
import ExampleMixin from 'ExampleMixin.vue'

@Component({
  components: {
    ExampleMixin
  }
})
export class MyComp extends mixins(ExampleMixin) {
  created () {
    console.log(this.testValue) // 編譯通過
  }
}

複製程式碼

也支援可以傳入多個 mixins。

一些其它

做為 Vue 中最正統的方法(與標準形式最為接近),Vue.extends() 有著自己的優勢,在 VScode Vetur 外掛輔助下,它能正確提示子元件上的 Props:

在 Vue 中使用 TypeScript 的一些思考(實踐)

而類做為 TypeScript 特殊的存在(它既可以作為型別,也可以作為值),當我們使用 vue-class-component 並通過 $refs 繫結為子類元件時,便能獲取子元件上暴露的型別資訊:

在 Vue 中使用 TypeScript 的一些思考(實踐)

匯入 .vue 時,為什麼會報錯?

當你在 Vue 中使用 TypeScript 時,所遇到的第一個問題即是在 ts 檔案中找不到 .vue 檔案,即使你所寫的路徑並沒有問題:

在 Vue 中使用 TypeScript 的一些思考(實踐)

在 TypeScript 中,它僅識別 js/ts/jsx/tsx 檔案,為了讓它識別 .vue 檔案,我們需要顯式告訴 TypeScript,vue 檔案存在,並且指定匯出 VueConstructor:

declare module '*.vue' {
  import Vue from 'vue'
  export default Vue
}
複製程式碼

但是,這引起了另一個問題,當我們匯入一個並不存在的 .vue 檔案時,也能通過編譯:

在 Vue 中使用 TypeScript 的一些思考(實踐)

是的,這在情理之中。

當我嘗試在 .vue 檔案中匯入已存在或者不存在的 .vue 檔案時,卻得到不同的結果:

檔案不存在時:

在 Vue 中使用 TypeScript 的一些思考(實踐)

檔案存在時:

在 Vue 中使用 TypeScript 的一些思考(實踐)

檔案不存在時,引用 Vue 的宣告檔案。檔案存在時,引用正確的檔案定義。

這讓人很困惑,而這些都是 Vetur 的功勞。

在這個 PR 下,我找到相關解答:這個 PR 裡,Vetur 提供解析其他 .vue 檔案的功能,以便能獲取正確的資訊,當 .vue 檔案不存在時,會讀取 .d.ts 裡的資訊。

參考

  • https://github.com/vuejs/vue/pull/5887
  • https://github.com/vuejs/vue/issues/7211
  • https://github.com/vuejs/vue/pull/6856
  • https://github.com/vuejs/vue/pull/5887/files/1092efe6070da2052a8df97a802c9434436eef1e#diff-23d7799dcc9e9be419d28a15348b0d99
  • https://github.com/Microsoft/TypeScript/blob/8e47c18636da814117071a2640ccf87c5f16fcfd/src/compiler/types.ts#L3563-L3583
  • https://github.com/vuejs/vetur/pull/94

相關文章