教你在Vue3中使用Class组件

<template>
    <div>
        {{ name }}
        <input ref="inputRef"></input>
        <button @click="handleFocus">focus</button>
        {{ count }}
        <button @click="handleCoundAdd">+</button>
    </div>
</template>

<script lang="ts">
import {
    Vue,
    Component,
    toComponent,
    Watch,
    WatchEffect,
    Setup,
} from '@aimmarc/vue3-class-component';
import { type Ref, ref } from 'vue';

@Component
class TestClass extends Vue {
    name = 'TestClass';
    count = 0;
    @Setup(() => ref())
    inputRef!: Ref<HTMLInputElement>;

    handleFocus() {
        console.log('this.inputRef', this.inputRef);
        this.inputRef?.value?.focus();
    }

    handleCoundAdd() {
        this.count += 1;
    }

    @Watch('count')
    watchCount(val: number, oldVal: number) {
        console.log(val, oldVal);
    }

    @WatchEffect({
        flush: 'post',
    })
    watchEffect() {
        console.log('watchEffect', this.count);
    }
}

export default toComponent(TestClass);
</script>

如图所示是一个使用了Class风格的Vue3组件,与函数式组件最大的不同就是组件的逻辑都被包含在一个Class中,最终导出的是被toComponent包装的返回值。这是怎么实现的呢?为什么通过toComponent的一层包装,就可以在Vue3中实现Class风格组件?接下来跟着我一步步实现吧!

转换器

toComponent的作用类似一个转换器,用于将Class组件转换为函数式组件。它的大致结构如下:

export function toComponent<P = any, T = any>(
    ViewConstructor: Constructor<T>
): DefineComponent<P, T, {}, {}, {}> {
    return defineComponent<P, T>({
        ...options,
        setup(props, ctx) {
            // 首先实例化class组件,并且传入props、ctx
            let viewInstance: any = new ViewConstructor(props, ctx);
            // 如果存在setup钩子,直接执行
            if (typeof viewInstance.setup === "function")
                viewInstance.setup(props, ctx);
            
            return viewInstance as T;
        },
    }) as DefineComponent<P, T, {}, {}, {}>;
}

很简单吧?寥寥数行代码就实现了主要功能。看到这里,聪明的小伙伴已经明白了,所谓的转换器实际上就是返回了一个DefineComponent对象,至于传入的Class则会在DefineComponent的setup中被实例化,并最终返回这个实例。至此,我们就实现了Class组件的第一步。

Vue基类

接下来实现Vue基类,所有的Class组件都应该继承自这个基类,实现在Class组件中通过this来调用一些Vue3的原生API。

abstract class Lifecycle {
    /**
     * 生命周期 setup
     * 提供一个setup钩子,满足组件需要在setup阶段进行的操作
     */
    protected setup() {}

    protected render?(): JSX.Element | VNode;

    /**
     * 生命周期 onBeforeMount
     * 组件挂载到节点上之前执行的函数
     *
     * @protected
     * @memberof Lifecycle
     */
    protected beforeMount() {}

    /**
     * 生命周期 onMounted
     * 组件挂载完成后执行的函数
     *
     * @protected
     * @memberof Lifecycle
     */
    protected mounted() {}

    /**
     * 生命周期 onBeforeUpdate
     * 组件更新之前执行的函数
     *
     * @protected
     * @memberof Lifecycle
     */
    protected beforeUpdate() {}

    /**
     * 生命周期 onUpdated
     * 组件更新完成之后执行的函数
     *
     * @protected
     * @memberof Lifecycle
     */
    protected updated() {}

    /**
     * 生命周期 onBeforeUnmount
     * 组件卸载之前执行的函数
     *
     * @protected
     * @memberof Lifecycle
     */
    protected beforeUnmount() {}

    /**
     * 生命周期 onUnmounted
     * 组件卸载完成后执行的函数
     *
     * @protected
     * @memberof Lifecycle
     */
    protected unmounted() {}

    /**
     * 生命周期 onActivated
     * 被包含在 <keep-alive> 中的组件,会多出两个生命周期钩子函数,被激活时执行
     *
     * @protected
     * @memberof Lifecycle
     */
    protected activated() {}

    /**
     * 生命周期 onDeactivated
     * 比如从 A 组件,切换到 B 组件,A 组件消失时执行
     *
     * @protected
     * @memberof Lifecycle
     */
    protected deactivated() {}

    /**
     * 生命周期 onErrorCaptured
     * 当捕获一个来自子孙组件的异常时激活钩子函数
     *
     * @protected
     * @memberof Lifecycle
     */
    protected errorCaptured() {}
}

import * as vue from 'vue';

export abstract class Vue<P = any, E = any> extends Lifecycle {
    protected emit: vue.SetupContext<E>["emit"];
    protected attrs: vue.SetupContext["attrs"];
    protected expose: vue.SetupContext["expose"];
    protected slots: vue.SetupContext<E>["slots"];
    protected props: P;
    constructor(props: P, ctx: vue.SetupContext<E>) {
        super();
        this.props = props;
        this.emit = ctx.emit;
        this.attrs = ctx.attrs;
        this.expose = ctx.expose;
        this.slots = ctx.slots;
        // 注册生命周期
        vue.onBeforeMount(() => {
            this.beforeMount();
        });
        vue.onMounted(() => {
            this.mounted();
        });
        vue.onBeforeUpdate(() => {
            this.beforeUpdate();
        });
        vue.onUpdated(() => {
            this.updated();
        });
        vue.onBeforeUnmount(() => {
            this.beforeUnmount();
        });
        vue.onUnmounted(() => {
            this.unmounted();
        });
        vue.onActivated(() => {
            this.activated();
        });
        vue.onDeactivated(() => {
            this.deactivated();
        });
        vue.onErrorCaptured(() => {
            this.errorCaptured();
        });
    }
}

当组件继承Vue基类后,就可以通过this调用emit、attrs等原生API,也可以在Class中直接使用mounted、update等生命周期钩子。如:

class MyComponent extends Vue {
    mounted() {
        console.log('component mounted!');
    }
}

Component装饰器

Component装饰器用于表明被装饰的class是一个组件,同时使用Reflect.defineMetadata将组件的options存储起来,供转换器初始化的时候使用。

import {
    ComponentOptions as ComponentOptionsVue3
} from 'vue';

type BaseComponentOptions<P = any> = ComponentOptionsVue3<P> & {
    setup?: undefined;
};

export function Component<P = any>(
    options: BaseComponentOptions<P> | any = {}
): any {
    if (typeof options === "function") {
        return Component()(options);
    }
    return function (target: any) {
        Reflect.defineMetadata(MetadataKey.VUE3_OPTIONS, options || {}, target);
    };
}

这样,我们就可以通过Component去装饰Class组件了。

@Component
class MyComponent extends Vue {
    mounted() {
        console.log('component mounted!');
    }
}

@Component({
    name: 'my-component',
})
class MyComponent extends Vue {
    mounted() {
        console.log('component mounted!');
    }
}

接下来我们来改写转换器toComponent:

export function toComponent<P = any, T = any>(
    ViewConstructor: Constructor<T>
): DefineComponent<P, T, {}, {}, {}> {
    // 取出Component装饰器中存储的options
    const options: Record<string, any> = Reflect.getMetadata(
        MetadataKey.VUE3_OPTIONS,
        ViewConstructor
    );
    // 没有添加Component装饰器,说明不是Class组件,直接抛出异常
    if (!options) throw "this constructor is not decoreted by 'Component'";
    const props = propsResolver(ViewConstructor); // 获取通过@Prop定义的props
    options.props = {
        ...options.props,
        ...props,
    };
    return defineComponent<P, T>({
        ...options,
        setup(props, ctx) {
            // 首先实例化class组件,并且传入props、ctx
            let viewInstance: any = new ViewConstructor(props, ctx);
            // 如果存在setup钩子,直接执行
            if (typeof viewInstance.setup === "function")
                viewInstance.setup(props, ctx);
            
            return viewInstance as T;
        },
    }) as DefineComponent<P, T, {}, {}, {}>;
}

通过在转换器中添加options的取出和使用,就可以将Component装饰器中传入的options运用到组件中并生效,如添加name、注册组件等。
至此,Class组件的主要功能基本结束,通过结合Vue自身API使用,到这一步其实就可以结束了。如果你还想了解如何处理setup阶段的逻辑,如何通过装饰器添加props、watch、ref,如何实现响应式,可以接着往下看。

Class属性的响应式

vue最大的特点就是响应式,在上述实现的基础上,如何在Class组件中实现属性的响应式?首先可以通过引入vue的ref来实现。

import { ref } from 'vue';

@Component
class MyComponent extends Vue {
    count = ref(0);

    handleAdd() {
        this.count.value += 1;
    }
}

上述例子的count就是一个响应式的属性,因为直接给count赋上了一个ref。这样的使用方式还是略显复杂,首先需要从vue引入ref,其次在更改this.count或使用的时候需要通过this.count.value去调用,即使是在template中!这并不符合直觉,因为vue3函数式组件中是不需要通过count.value去调用的,直接使用count即可,这是由于我们封装了一层Class导致Vue官方的编译优化失效了。怎么解决这个问题?
其实很简单,我们将整个Class的实例通过一个reactive进行包装再返回,不就得到了一个天然的响应式对象吗?接下来继续改造toComponent。

import { reactive } from 'vue';

export function toComponent<P = any, T = any>(
    ViewConstructor: Constructor<T>
): DefineComponent<P, T, {}, {}, {}> {
    // 取出Component装饰器中存储的options
    const options: Record<string, any> = Reflect.getMetadata(
        MetadataKey.VUE3_OPTIONS,
        ViewConstructor
    );
    // 没有添加Component装饰器,说明不是Class组件,直接抛出异常
    if (!options) throw "this constructor is not decoreted by 'Component'";
    options.props = {
        ...options.props,
    };
    return defineComponent<P, T>({
        ...options,
        setup(props, ctx) {
            // 首先实例化class组件,并且传入props、ctx
            let viewInstance: any = new ViewConstructor(props, ctx);
            // 将实例转换为reactive,让属性具备响应式
            let instance = reactive(viewInstance);
            // 如果存在setup钩子,直接执行
            if (typeof viewInstance.setup === "function")
                viewInstance.setup(props, ctx);
            
            return instance as T;
        },
    }) as DefineComponent<P, T, {}, {}, {}>;
}

只添加了一行代码,就是let instance = reactive(viewInstance);,返回被包装后的reactive对象,Class中的属性就具备响应式了。我们的例子就可以改写为下面这样:

@Component
class MyComponent extends Vue {
    count = 0;

    handleAdd() {
        this.count += 1;
    }
}

这样就减少了代码量,而且也不需要通过count.value去调用,同时也不用再引入vue的原生API了。

Props处理

props我设计了两种方式进行注入,一种是在Component装饰器中进行定义,这种注入方式需要通过this.props.foo的形式进行调用,略显不方便;另一种是通过封装一个Prop装饰器,直接将props的值赋给Class组件的某个属性,就像下面例子展示的这样:

@Component
class MyComponent extends Vue {
    @Prop({
        type: Number,
        default: 5,
    })
    foo!: number;
}

接下来我们就来实现这个Prop装饰器,还是需要用到Reflect.defineMetadata:

export function Prop(options: PropOptions<any> = {}) {
    return function (target: any, propertyKey: string) {
        const providers: Map<any, any> =
            Reflect.getMetadata(MetadataKey.VUE3_PROP, target.constructor) ||
            new Map();
        providers.set(`${propertyKey}${MetadataKey.VUE3_PROP}`, {
            propertyKey,
            options,
        });
        Reflect.defineMetadata(
            MetadataKey.VUE3_PROP,
            providers,
            target.constructor
        );
    };
}

这个装饰器只做了一件事,就是把prop的key和options存到元数据中,供转换器使用,下面是toComponent的改造:

export function propsResolver(target: any) {
    try {
        const propsMap = Reflect.getMetadata(MetadataKey.VUE3_PROP, target);
        if (!propsMap) return;
        const props: Record<string, any> = {};
        for (const [_, { propertyKey, options }] of propsMap) {
            props[propertyKey] = options;
        }
        return props;
    } catch (err) {
        console.log("provideResolver err:", err);
    }
}

import { reactive } from 'vue';

export function toComponent<P = any, T = any>(
    ViewConstructor: Constructor<T>
): DefineComponent<P, T, {}, {}, {}> {
    // 取出Component装饰器中存储的options
    const options: Record<string, any> = Reflect.getMetadata(
        MetadataKey.VUE3_OPTIONS,
        ViewConstructor
    );
    // 没有添加Component装饰器,说明不是Class组件,直接抛出异常
    if (!options) throw "this constructor is not decoreted by 'Component'";
    const props = propsResolver(ViewConstructor); // 获取通过@Prop定义的props
    options.props = {
        ...options.props,
        ...props,
    };
    return defineComponent<P, T>({
        ...options,
        setup(props, ctx) {
            // 首先实例化class组件,并且传入props、ctx
            let viewInstance: any = new ViewConstructor(props, ctx);
            // 将实例转换为reactive,让属性具备响应式
            let instance = reactive(viewInstance);
            // 如果存在setup钩子,直接执行
            if (typeof viewInstance.setup === "function")
                viewInstance.setup(props, ctx);
            
            return instance as T;
        },
    }) as DefineComponent<P, T, {}, {}, {}>;
}

添加了propsResolver方法用于取出所有的props配置,然后合并到options的props中,传入defineComponent进行初始化,最终就能在Class组件直接使用props了。至此,通过同样的方法,即可实现Watch、Computed等装饰器的实现,进而完善Class组件的功能,在此就不一一介绍了,如果感兴趣,可以前往https://github.com/aimmarc/free-decorators进行查看。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容