背景
為了 第四季度的 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 會隨著 PropValidator、RecordPropsDefinition、ThisTypedComponentOptionsWithRecordProps、VueConstructor 一層層將裡面的 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 的作用就是:如果 T 有 prototype 屬性,則返回這個屬性對應的型別,否則就返回 T 本身。
至於 oneOfType 和 shape 這兩個函式,我們需要先將 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
完善了 oneOfType 和 shape 的型別推導,不需要再手動指定型別了。