使用 vue-class-setup 編寫 class 風格組合式API,支援Vue2和Vue3

狼族小狽發表於2022-09-23

前言

我司基於vue-class-component開發的專案有上百個,其中部署的 SSR 服務也接近100個,如此龐大體量的專案一開始的時候還幻想著看看是否要升級Vue3,結果調研一番下來,才發現vue-class-component對Vue3的支援,最後一個版本釋出都過去兩年了,遲遲還沒有釋出正式版本。目前基本上處於無人維護的狀態,而且升級存在著大量的破壞性更新,對於未來是否還要繼續使用Vue3現在還是持保留意見,但是不妨礙我們先把元件庫做成Vue2和Vue3通用,於是就有了本文。

在過去的三年裡,vue-class-component最大的問題是就是無法正確的校驗元件的傳參,事件型別,這給我帶來了巨大的陰影,在經過一番調研後,驚喜的發現使用defineComponent定義的元件,在Vue2.7和3.x都可以正確的識別型別,所以先計劃內部的元件庫先做到同時支援Vue2和Vue3,如果後面還要繼續採用Vue3就變得容易得多。

於是,回到了開頭,調研了一番vue-class-component在Vue3的支援,目前最新的版本是8.0.0-rc.1,結果大失所望,目前基本上處於無人維護的狀態,社群內又沒有一個能滿足我需求的,同時支援Vue2和Vue3的。

誕生想法

鑑於vue-class-component元件目前無法做到正確的元件型別檢驗,當我驚喜的發現組合式API寫出來的程式碼可以被正確的識別型別時,誕生了一個使用 class 風格來編寫組合式API的想法,於是花費一個月的實踐,踩遍了所有的坑,終於誕生了vue-class-setup,一個使用 class 風格來編寫程式碼的庫,它gzip壓縮後,1kb大小。

快速開始

npm install vue-class-setup
<script lang="ts">
import { defineComponent } from 'vue';
import { Setup, Context } from 'vue-class-setup';

// Setup 和 Context 必須一起工作
@Setup
class App extends Context {
    private _value = 0;
    public get text() {
        return String(this._value);
    }
    public set text(text: string) {
        this._value = Number(text);
    }
    public onClick() {
        this._value++;
    }
}
export default defineComponent({
    // 注入類例項的邏輯
    ...App.inject(),
});
</script>
<template>
    <div>
        <p>{{ text }}</p>
        <button @click="onClick()"></button>
    </div>
</template>

嘗試多很多種方案,最終採用了上面的形式為最佳實踐,它無法做到export default直接匯出一個類,必須使用defineComponent 來包裝一層,因為它只是一個組合類(API),並非是一個元件。

最佳實踐

<script lang="ts">
import { defineComponent } from 'vue';
import { Setup, Define } from 'vue-class-setup';

// 傳入元件的 Props 和 Emit,來讓組合類獲取正確的 `Props` 和 `Emit` 型別
@Setup
class App extends Define<Props, Emit> {
    // ✨ 你可以直接這裡定義Props的預設值,不需要像 vue-property-decorator 那樣使用一個 Prop 裝飾器來定義
    public readonly dest = '--';
    // 自動轉換成 Vue 的 'computed'
    public get text() {
        return String(this.value);
    }
    public click(evt: MouseEvent) {
        // 發射事件,可以正確的識別型別
        this.$emit('click', evt);
    }
}
/**
 * 這裡提供了另外一種在 setup 函式中使用的例子,預設推薦使用 `defineComponent`
 * 如果有多個類例項,也可以在 setup 中例項化類
 * <script lang="ts" setup>
 *      const app = new App();
 * <\/script>
 * <template>
 *      <div>{{ app.text }}</div>
 * </template>
 */
export default defineComponent({
    ...App.inject(),
});
</script>
<script lang="ts" setup>
// 如果在 setup 中定義型別,需要匯出一下
export interface Props {
    value: number;
    dest?: string;
}
export interface Emit {
    (event: 'click', evt: MouseEvent): void;
}
// 這裡不再需要使用變數來接收,可以利用 Vue 的編譯宏來為元件生成正確的 Props 和 Emit
// ❌ const props = defineProps<Props>();
// ❌ const emit = defineEmits<Emit>();
defineProps<Props>(); //  ✅
defineEmits<Emit>(); //  ✅

// 這種預設值的定義,也不再推薦,而是直接在類上宣告
// ❌ withDefaults(defineProps<Props>(), { dest: '--' });
// ✅ @Setup
// ✅ class App extends Define<Props, Emit> {
// ✅     public readonly dest = '--'
// ✅ }

// Setup 裝飾器,會在類例項化時,自動 使用 reactive 包裝類,
// 如果你在 setup 手動例項化,則不需要再執行一次 reactive 
// const app = reactive(new App()); // ❌
// const app = new App();           // ✅
</script>
<template>
    <button class="btn" @click="click($event)">
        <span class="text">{{ text }}</span>
        <span class="props-dest">{{ dest }}</span>
        <span class="props-value">{{ $props.value }}</span>
    </button>
</template>

多個類例項

在一些複雜的業務時,有時需要多個例項

<script lang="ts">
import { onBeforeMount, onMounted } from 'vue';
import { Setup, Context, PassOnTo } from 'vue-class-setup';

@Setup
class Base extends Context {
    public value = 0;
    public get text() {
        return String(this.value);
    }
    @PassOnTo(onBeforeMount)
    public init() {
        this.value++;
    }
}

@Setup
class Left extends Base {
    public left = 0;
    public get text() {
        return String(`value:${this.value}`);
    }
    public init() {
        super.init();
        this.value++;
    }
    @PassOnTo(onMounted)
    public initLeft() {
        this.left++;
    }
}

@Setup
class Right extends Base {
    public right = 0;
    public init() {
        super.init();
        this.value++;
    }
    @PassOnTo(onMounted)
    public initLeft() {
        this.right++;
    }
}
</script>
<script setup lang="ts">
const left = new Left();
const right = new Right();
</script>
<template>
    <p class="left">{{ left.text }}</p>
    <p class="right">{{ right.text }}</p>
</template>

PassOnTo

在類例項準備就緒後,PassOnTo 裝飾器,會將對應的函式,傳遞給回撥,這樣我們就可以順利的和 onMounted 等鉤子一起配合使用了

import { onMounted } from 'vue';
@Setup
class App extends Define {
    @PassOnTo(onMounted)
    public onMounted() {}
}

Watch

在使用 vue-property-decoratorWatch 裝飾器時,他會接收一個字串型別,它不能正確的識別類例項是否存在這個欄位,但是現在 vue-class-setup 能檢查你的型別是否正確,如果傳入一個類例項不存在的欄位,型別將會報錯

<script lang="ts">
import { Setup, Watch, Context } from 'vue-class-setup';

@Setup
class App extends Context {
    public value = 0;
    public immediateValue = 0;
    public onClick() {
        this.value++;
    }
    @Watch('value')
    public watchValue(value: number, oldValue: number) {
        if (value > 100) {
            this.value = 100;
        }
    }
    @Watch('value', { immediate: true })
    public watchImmediateValue(value: number, oldValue: number | undefined) {
        if (typeof oldValue === 'undefined') {
            this.immediateValue = 10;
        } else {
            this.immediateValue++;
        }
    }
}
</script>
<script setup lang="ts">
const app = new App();
</script>
<template>
    <p class="value">{{ app.value }}</p>
    <p class="immediate-value">{{ app.immediateValue }}</p>
    <button @click="app.onClick()">Add</button>
</template>

defineExpose

在一些場景,我們希望可以暴露元件的一些方法和屬性,那麼就需要使用 defineExpose 編譯宏來定義匯出了,所以提供了一個.use的類靜態方法幫你獲取當前注入的類例項

<script lang="ts">
import { defineComponent } from 'vue';
import { Setup, Context } from 'vue-class-setup';

@Setup
class App extends Context {
    private _value = 0;
    public get text() {
        return String(this._value);
    }
    public set text(text: string) {
        this._value = Number(text);
    }
    public addValue() {
        this._value++;
    }
}
export default defineComponent({
    ...App.inject(),
});
</script>
<script lang="ts" setup>
const app = App.use();

defineExpose({
    addValue: app.addValue,
});
</script>
<template>
    <div>
        <p class="text">{{ text }}</p>
        <p class="text-eq">{{ app.text === text }}</p>
        <button @click="addValue"></button>
    </div>
</template>

為什麼使用 class ?

其實不太想討論這個問題,喜歡的自然會喜歡,不喜歡的自然會不喜歡,世上本無路,走的人多了,就有了路。

最後

不管是 選項 API 還是 組合式API,程式碼都是人寫出來的,別人都說 Vue 無法勝任大型專案,但是在我司的實踐中經受住了實踐,基本上沒有產生那種數千行的元件程式碼。

如果喜歡使用 class 風格來編寫程式碼的,不妨來關注一下

如果你的業務複雜,需要使用 SSR 和微服務架構,不妨也關注一下

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章