R·ex / Zeng


音遊狗、安全狗、攻城獅、業餘設計師、段子手、苦學日語的少年。

TypeScript+Vue 元件庫的細節問題

背景

為了 第四季度的 KPI(劃掉) 配合全組前端專案的 UI/UX 改版,我們需要自己寫一套元件庫。第一個吃螃蟹的專案是用 Vue 寫的,所以我們決定先寫一個 Vue 的元件庫。因為我剛好比較閒,於是搭腳手架的任務就交給了我。

技術選型

我個人的觀點是:TypeScript(或其它靜態型別的語言)是未來的趨勢,但 Vue 對 TypeScript 的支援並不是那麼好。

經過再三查閱文件,我發現 官方文件 提到可以用 ES6 Class 或者傳統的 Object。我試了一下:

  • Object 無法優雅的提示與 this 相關的錯誤。畢竟 Vue.extend({ xxx }) 這種寫法雖然能校驗引數型別,但在 TypeScript 看來,這兒壓根就沒有 this
  • Class 的寫法配合上 Ant Design 的 vue-types 表現還不錯,甚至不需要額外的外掛(例如 Vetur)就可以補全,加上最新的 vue-cli 預設建立的 TypeScript 專案也用了 Class 的寫法,於是最終決定用 Class。

在查詢資料的過程中,偶然看到了 vue-property-decorator 這個專案,是把官方維護的 vue-class-component 又包了一層,用起來更舒服,於是也決定用它了。

至於編譯,我用了 Rollup,因為它跟 Webpack 比起來,配置檔案極短、原生支援 ES Module、原生支援多 Input 多 Output 還能自動拆包,幾乎不需要耗費任何精力。除掉一些工具函式以外,配置檔案就長這樣:

export default {
    input: getEntries(),
  output: [
      { dir: 'es', format: 'esm', exports: 'named' },
    { dir: 'lib', format: 'cjs', exports: 'named' },
  ],
  // 使用正則來確保擴充套件性
  external: id => /^vue$/.test(id),
  plugins: [
    nodeResolve({ preferBuiltins: false }),
    commonjs(),
    globals(),
    builtins(),
    typescript({ target: 'es5' }),
    vue(),
    less({
        output: (css, file) => {
        // 一些程式碼
      },
    }),
  ],
};

之所以要生成一個 es 和一個 lib,是為了能像 Element 和 Ant Design 一樣支援按需引入,less.output 的配置也是為了這個,但是這是個函式,稍微麻煩了一些。

元件示例

一個典型的元件大概是這樣子,裝飾器可以區分 Data、Computed、Method 和生命週期函式,剩下的就交給上面的 Vue.extend 了:

<template>
  <div>
    <span>{{ displayNum }} {{ innerState }}</span>
    <another-component />
    <button @click="onClick">Click me</button>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import PropTypes from '../../utils/vue-types'; // 從 antd 複製過來的

import AnotherComponent from './AnotherComponent';

const CompProps = Vue.extend({
  components: {
    anotherComponent: AnotherComponent,
  },
  props: {
    num: PropTypes.number,
    str: PropTypes.string.def('default value'),
    width: PropTypes.oneOfType([
      PropTypes.string,
      PropTypes.number,
    ]),
    align: PropTypes.oneOf(['start', 'center', 'end']),
    date: PropTypes.instanceOf(Date),
  },
});

@Component
export default class Comp extends CompProps {
  // data
  innerState = 0;
  // computed
  get displayNum () {
    return this.num || 0;
  }
  // method
  onClick (e: MouseEvent) {
    this.innerState++;
  }
  // 生命週期鉤子
  mounted () {
    console.log('mounted');
  }
};
</script>

vue-property-decorator 官方推薦將上面的 Vue.extend 的內容寫到 @Component 裡面,但對於 TypeScript,只有直接 Extend 一個 Vue.extend 的值,Class 才能帶上這些 Props,官方的 示例程式碼 也是這麼寫的。

對於 PropTypes 的校驗

由於 Ant Design 的 vue-types 是用 JavaScript 寫的,所以我必須要給它補上型別,否則不管怎麼搞,所有的 Props 全是 any,那還寫個錘子?

vue-types 的本質其實就是生成一個 Vue 中用來校驗的 Object,例如:

export default {
  props: {
    str: {
      type: String,
      default: 'default value',
    },
  },
}

去讀 Vue 的型別定義檔案,發現 props 這個物件的型別應該是 PropOptions

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

之後發現 PropOptions 會隨著 PropValidatorRecordPropsDefinitionThisTypedComponentOptionsWithRecordPropsVueConstructor 一層層將裡面的 T 傳遞到 V extends Vue 中。舉個例子,我們只需要讓 PropTypes.string 返回一個 PropOptions<string>,那麼在元件中寫的 this.str 就會被認為是個字串啦!

簡單型別

有了這個思路,所有的基礎型別都很好寫(程式碼完全照抄的 Ant Design,我只是在裡面加了型別。為了節約篇幅,只著重寫型別相關的部分):

const VuePropTypes = {
  // 基礎型別開始
  get any () { return xxx as PropOptions<any>; },
  get func () { return xxx as PropOptions<Function>; },
  get bool () { return xxx as PropOptions<boolean>; },
  get string () { return xxx as PropOptions<string>; },
  get number () { return xxx as PropOptions<number>; },
  get array () { return xxx as PropOptions<any[]>; },
  get object () { return xxx as PropOptions<{ [key: string]: any }>; },
  get integer () { return xxx as PropOptions<number>; },
  get symbol () { return xxx as PropOptions<symbol>; },
  // 基礎型別結束
};

複雜型別

至於下面幾個比較複雜的東西,就要用到泛型了:

const VuePropTypes = {
  // 範型開始
  oneOf<T> (arr: T[]) { return xxx as PropOptions<T>; },
  arrayOf<T> (type: PropOptions<T>) { return xxx as PropOptions<T[]>; },
  objectOf<T> (type: PropOptions<T>) { return xxx as PropOptions<{ [key: string]: T }>; },
  // 範型結束
};

對 infer 的使用

有個比較有意思的型別是 instanceOf,因為在 JavaScript 中,new Date() instanceof Date === true,但如果我讓這個函式的返回值是 PropOptions<T> 的話,這個 Prop 就真的只能傳 Date 的建構函式而不是一個真正的例項了。例如,我只能寫 this.date.now()Date 的靜態方法)而不能寫 this.date.getYear()Date 例項的方法)。正常人應該不會往 Prop 裡面塞建構函式,因此基本可以斷定,instanceOf 就應當返回 PropOptions<InstanceType<T>>,也就是 T 的例項型別。

那麼 InstanceType 具體該怎麼寫呢?我們注意到所有的建構函式都帶有 prototype,而這其中的 Key 都會出現在例項中。因此我們可以寫一個提取 prototype 的型別的工具型別:

interface ConstructorType<T> {
  prototype: T;
}
type InstanceType<T> = T extends ConstructorType<infer R> ? R : T;

這兒用了 infer 關鍵字來提取型別。此時 InstanceType 的作用就是:如果 Tprototype 屬性,則返回這個屬性對應的型別,否則就返回 T 本身。

至於 oneOfTypeshape 這兩個函式,我們需要先將 T = PropOptions<R> 裡面的 R(可能是基礎型別,也可能是元組)摘出來,於是就有了這麼個工具型別:

type RemovePropOptions<T> = NonNullable<T extends PropOptions<infer R> ? R : null>;

對於 oneOfType,只需要先將傳入的帶有 PropOptions 的元組轉換為聯合型別(使用 TypeScript 自帶的 ValueOf 工具型別),然後將 PropOptions 剝除,就是需要校驗的資料型別了:

type ArrayType = Array<PropOptions<any>>;
type OneOfTypePropOptions<T> = T extends ArrayType ? RemovePropOptions<ValueOf<T>> : T;

const VuePropTypes = {
  // oneOfType 開始
  oneOfType<T> (arr: T) { return xxx as PropOptions<OneOfTypePropOptions<T>>; }
  // oneOfType 結束
};

對於 shape 來說,我們只需要將傳入的 T 中的每一項(使用 keyof 關鍵字來列舉)的 PropOptions 都剝掉,就得到了需要校驗的資料型別:

interface ShapeType {
  [key: string]: PropOptions<any>;
}
type ShapePropOptions<T> = T extends ShapeType ? { [K in keyof T]: RemovePropOptions<T[K]> } : T;

const VuePropTypes = {
  // shape 開始
  shape<T> (obj: T) { return xxx as PropOptions<ShapePropOptions<T>>; }
  // shape 結束
};

至此,所有跟 vue-types 相關的工作就搞定了。

小插曲:第三方庫的型別問題

我們幾個同事一致決定用 vue-popper 來管理 Select、Dropdown、Tooltip 之類的彈框,但這個庫是一個純 JavaScript 庫,並沒有型別檔案。雖然我們不太介意這個,因為用法比較少,可以直接看文件;但 TypeScript 會報錯:Cannot find module 'vue-popperjs'.。作為一個臨時解決方案,我補了一個檔案:

// TODO: 為 vue-popperjs 新增真實的型別
declare module 'vue-popperjs';

然後在需要的元件中引入:

import '../../vue-popperjs-types.d.ts';

結果編譯報錯了:Error: Could not resolve '../../vue-popperjs-types.d' from src/components/dropdown/dropdown.vue?rollup-plugin-vue=script.ts

究其原因,應該是 Rollup 的 TypeScript 外掛在編譯完 .d.ts 之後發現沒有內容,於是就沒有生成具體的 JavaScript 檔案以供後續的 Vue 外掛使用。我又想到了 TypeScript 裡面有個特別具有微軟風格的東西:三斜線指令。它可以用來引用一個檔案,於是我試驗了一下:

/// <reference path="../../vue-popperjs-types.d.ts" />

然後就可以了……

總結

在上面一通蛇皮操作下,Script 的部分已經能完全利用上 TypeScript 的特性,但對於 Template 部分,以及在第三方專案中使用這個庫,就只能寄期望於 vetur 的更新了。目前如果想要做到這點,需要專門寫一個 JSON 檔案,但權衡了一番之後發現不值得。如果 vetur 可以直接讀取 TypeScript 的型別定義檔案,那就完美了。


Update 2019-12-12

完善了 oneOfTypeshape 的型別推導,不需要再手動指定型別了。

Disqus 載入中……如未能載入,請將 disqus.com 和 disquscdn.com 加入白名單。

這是我們共同度過的

第 3860 天