<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进行查看。