vue3中的元编程&vue3代码抽离
前言
最近两天看了一篇文章, mp.weixin.qq.com/s/dHDaOSnSo…, 这篇文章讲的是react中元编程是怎样加速前端开发的, 我在看的时候受到启发, 能不能把里面的思想应用到vue中, 正好有了vue3, 而vue3对typescript的支持又很好, 我们可不可以更放肆的使用ts的诸多特性呢?
工具&插件
- vscode
- volar插件
- vue3
- typescript
- reflect-matedata
第一步: 目录结构
因为我比较喜欢前端的一个框架叫做Nest.js
, 这个框架可以简单理解为ts版本的express
, 有node中的Spring这一响亮的名号, 我们的目录结构就像Nest
一样来创建.
这里我是用vue cli来创建项目,vue create metadata-apply
, 在创建选项中, 选择vue3的版本, 以及typescript.
刚创建完的项目结构是下面这样的
├── App.vue
├── assets
│ └── logo.png
├── components
│ └── HelloWorld.vue
├── main.ts
├── router
│ └── index.ts
├── shims-vue.d.ts
└── views
├── About.vue
└── Home.vue
复制代码
首先我们需要把里面没用的文件先清理一下, 并且创建几个新的文件
├── App.vue
├── main.ts
├── router
│ └── index.ts
├── module
│ └── person
│ ├── person.model.ts
│ ├── person.service.ts
│ └── person.type.ts
├── utils
│ └── utils.ts
└── views
└── Home.vue
复制代码
我们来看一下各个文件中的内容
App.vue
<template>
<router-view/>
</template>
复制代码
router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import Home from '../views/Home.vue';
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'Home',
component: Home
}
];
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
});
export default router;
复制代码
views/Home.vue
<template>
</template>
<script lang="ts" setup>
</script>
复制代码
第二步: 项目依赖
- reflect-metadata
这里要用到ts中的装饰器, 且要用到元数据的特性, 所以要用一个reflect-metadata库
下载方式: npm i reflect-metadata --save
引入方式: 在对应的文件中加入以下代码即可(目前还用不到)import 'reflect-metadata';
- ant-design-vue
在示例中要用到一个ui框架, 刚开始本来想用element-plus, 但是后来发现并不是很好体现效果, 所以这里选用了ant-design-vue
下载方式: npm i --save ant-design-vue@next
引入方式: 在main.ts
文件中加入以下代码
import Antd from 'ant-design-vue';
import "ant-design-vue/dist/antd.css";
app.use(Antd);
复制代码
main.ts
完整代码:
import { createApp } from 'vue';
import App from './App.vue';
import Antd from 'ant-design-vue';
import "ant-design-vue/dist/antd.css";
import router from './router';
import store from './store';
const app = createApp(App);
app.use(Antd);
app.use(store);
app.use(router);
app.mount('#app');
复制代码
第三步: 分析模版
我们这里以ant-design-vue的table组件来做示例, 所以刚开始模版很简单, 只有一句代码(如果对这个组件不是很了解的话, 小伙伴们可以去ant-design-vue的官网看一下, 非常的简单2x.antdv.com/components/…).
<a-table
:data-source="data"
:columns="columns"
/>
复制代码
我们可以看到模版的a-table组件上有很多很多的属性, 那么这些属性是从哪里来的呢? 这里就要用到我们在创建项目时新建的那几个文件.
第四步: 定义工具方法
这里我们在utils.ts中定义两个工具
- 一个用来创建属性装饰器的方法, 返回创建的唯一键, 以及装饰函数
export function CreateProperDecorator<T>(): ICPD<T> {
const metaKey = Symbol();
function properDecoratorF(config: T): PropertyDecorator {
return function (target, propertyKey) {
Reflect.defineMetadata(metaKey, config, target, propertyKey);
}
}
return { metaKey, properDecoratorF };
}
复制代码
- 获取属性的元数据
export function getConfigMap<T>(target: any, cacheKey: symbol, metaKey: symbol): Map<string, T> {
if (target[cacheKey]) return target[cacheKey];
const instance = new target({});
// 获取到实例的所有属性
const keys = Object.keys(instance);
target[cacheKey] = keys.reduce((map, key) => {
const config = Reflect.getMetadata(metaKey, instance, key);
if (config) {
map.set(key, config);
}
return map;
}, new Map<string, T>());
return target[cacheKey];
}
复制代码
暂时不明白这两个方法的作用没有关系, 这里只需要知道这两个方法是相互对应的, 一个用来定义属性元数据, 一个用来获取属性元数据.
完整的utils.ts文件
import 'reflect-metadata';
import { ICPD } from '../module/person/person.type';
// 1.一个用来创建属性装饰器的方法, 返回创建的唯一键, 以及装饰函数
export function CreateProperDecorator<T>(): ICPD<T> {
const metaKey = Symbol();
function properDecoratorF(config: T): PropertyDecorator {
return function (target, propertyKey) {
Reflect.defineMetadata(metaKey, config, target, propertyKey);
}
}
return { metaKey, properDecoratorF };
}
// 获取属性的元数据
export function getConfigMap<T>(target: any, cacheKey: symbol, metaKey: symbol): Map<string, T> {
if (target[cacheKey]) return target[cacheKey];
const instance = new target({});
// 获取到实例的所有属性
const keys = Object.keys(instance);
target[cacheKey] = keys.reduce((map, key) => {
const config = Reflect.getMetadata(metaKey, instance, key);
if (config) {
map.set(key, config);
}
return map;
}, new Map<string, T>());
return target[cacheKey];
}
复制代码
ICPD
位于person.type.ts文件中, 这里引入会报错, 没有关系, 下一个部分就会说到.
第五步: 类型约束
person.type.ts
import { CreateProperDecorator } from "@/utils/utils";
// 类装饰器的约束: 可以根据业务 给类不同的数据
export interface ClassConfig {
size?: 'middle' | 'small';
bordered?: boolean;
pagination?: {
'show-less-items'?: boolean;
current?: number;
pageSize?: number;
total?: number;
};
}
// 属性装饰器的返回约束
export type ICPD<T> = { metaKey: symbol, properDecoratorF: (config: T) => PropertyDecorator };
// 后台返回字段约束
export interface Paginabale<T> {
total: number;
list: T[]
}
// 表格列的约束
export interface TableColumu {
title: string,
dataIndex: string,
key: string,
}
// 让TableColumu中的属性都是可选的
export type ColumnPropertyConfig = Partial<TableColumu>;
// 创建表格列的属性装饰器
export const columnConfig = CreateProperDecorator<ColumnPropertyConfig>();
// 拿到属性装饰器
export const Column = columnConfig.properDecoratorF;
// 表格抽象类
export abstract class TableBase {
static getColumns<T>(): TableColumu[] {
return []
}
static async getList<T>(): Promise<Paginabale<T>> {
return {total: 0, list:[]}
}
static getConfig: () => ClassConfig;
static change: (page, pageSize) => void;
}
复制代码
第六步: 创建数据模型
我们先来看一下person.model.ts
的完整代码, 我们再来一步一步分析.
import 'reflect-metadata';
import { TableBase, Column } from "./person.type";
import { getConfigMap } from "../../utils/utils";
import { getPersonListFromServer } from "./person.service";
import { ColumnPropertyConfig, columnConfig, TableColumu, ClassConfig, Paginabale } from './person.type';
// 2.类装饰器, 处理通过装饰器收集上来的元数据
export function EnhancedTableClass(config: ClassConfig) {
const cacheColumnConfigKey = Symbol('cacheColumnConfigKey');
const tableConfigKey = Symbol('config');
return function (Target) {
return class EnhancedTableClass extends Target {
constructor(data) {
super(data);
}
// 获取列上的元数据
static get columnConfig(): Map<string, ColumnPropertyConfig> {
return getConfigMap<ColumnPropertyConfig>(EnhancedTableClass, cacheColumnConfigKey, columnConfig.metaKey);
}
// 获取表格列
static getColumns(): TableColumu[] {
const list: TableColumu[] = [];
EnhancedTableClass.columnConfig.forEach(config => list.push(config as TableColumu));
return list;
}
// 获取表格数据
static async getList<T>(): Promise<Paginabale<T>> {
const result = await getPersonListFromServer();
return {
total: result.count,
list: result.data.map((item: T) => new EnhancedTableClass(item))
}
}
}
}
}
// @ts-ignore
@EnhancedTableClass({})
export class Person extends TableBase {
@Column({
title: '唯一标识',
dataIndex: 'id',
key: '0'
})
id: number = 0;
@Column({
title: '姓名',
dataIndex: 'name',
key: '1'
})
name: string = '';
@Column({
title: '年龄',
dataIndex: 'age',
key: '2'
})
age: number = 0;
@Column({
title: '性别',
dataIndex: 'sex',
key: '3'
})
sex: 'male' | 'female' | 'unknow' = 'unknow';
@Column({
title: '地址',
dataIndex: 'address',
key: '4'
})
address: string = '';
@Column({
title: 'key',
dataIndex: 'key',
key: '5'
})
key: string | number = '0';
constructor({ key, id, name, age, sex, address }) {
super();
this.id = id;
this.key = key;
this.name = name;
this.age = age;
this.sex = sex;
this.address = address;
}
}
复制代码
首先我们先来看EnhancedTableClass
这个方法, 意为提高表格类, 该方法是一个类装饰器的工厂方法(如果不了解工厂方法的话, 小伙伴们可以看一下这篇文章juejin.cn/post/697242…), 该工厂方法的作用为扩展类的静态方法以及静态属性, 我们暂且先不管都新增了哪些方法, 我们再来看Person
类.
Person类有三个关键点:
- 使用
EnhancedTableClass
方法装饰, 该方法的作用刚刚已经聊过, 主要是为了扩展类的静态方法以及属性的(实现TableBase抽象类). - 继承自
TableBase
, TableBase是一个抽象类, 规定当前类需要实现的方法, 为什么这里会加了一个抽象类呢? 因为静态属性和方法都是通过装饰器来添加的, 所以在使用中ide是没有代码提示的, 为了避免出错, 故而加了一个抽象类来约束. - 类中的属性被
Column
装饰器所装饰, 该装饰器的作用是定义数据列的元数据, 例如ant-desgin-vue中需要的这样.
好了, 到了这里我们所需的东西就已经定义好了, 上面只讲了定义但是没有讲具体的逻辑, 因为我想放到实际应用中来讲, 因为我觉得这样更好理解一点.
第七步: 数据
person.service.ts
该文件的作用就是模拟数据返回
export const getPersonListFromServer: any = async (): Promise<any> => {
return new Promise(resolve => {
setTimeout(() => {
resolve({
data: [
{ key: 1, id: 10, name: 'veloma', age: 20, sex: 'male', address: '山东省青岛市' },
{ key: 2, id: 11, name: 'timer', age: 22, sex: 'female', address: '山东省青岛市' },
{ key: 3, id: 11, name: 'timer', age: 22, sex: 'female', address: '山东省青岛市' },
{ key: 4, id: 11, name: 'timer', age: 22, sex: 'female', address: '山东省青岛市' },
{ key: 5, id: 11, name: 'timer', age: 22, sex: 'female', address: '山东省青岛市' },
{ key: 6, id: 11, name: 'timer', age: 22, sex: 'female', address: '山东省青岛市' },
{ key: 7, id: 11, name: 'timer', age: 22, sex: 'female', address: '山东省青岛市' },
{ key: 8, id: 11, name: 'timer', age: 22, sex: 'female', address: '山东省青岛市' }
],
count: 2
})
}, 500);
});
}
复制代码
第八步: 初见成效
我们回到Home.vue
中来应用一下, 我们先来看一下Home.vue
的完整代码
<template>
<a-table
:data-source="data"
:columns="columns"
/>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { Person } from '@/module/person/person.model';
const columns = Person.getColumns<Person>();
const data = ref<Array<Person>>([]);
const getData = async () => {
const response = await Person.getList<Person>();
data.value = response.list;
}
onMounted(() => getData());
</script>
复制代码
有效代码只有8行, 我们来看一下页面效果, 是不是很cool?
这里我们可以看到最后一列key, 我们并不想让他展示, 该怎么办呢? 非常的简单, 只要字段不被Column装饰器装饰即可.
这个时候再回到页面会发现, 最后一列key消失了
如果想要给a-table添加其他的属性呢? 例如ant-design-vue官网给出的这些属性.
其实非常非常的简单, 我们只需要把需要的数据传给EnhancedTableClass即可.
而我们只需要在EnhancedTableClass的constructor中接收即可, 而且我们还要提供一个获取的方法.
我们来看一下现在的Home.vue
<template>
<a-table
:size="size"
:bordered="bordered"
:pagination="pagination"
:data-source="data"
:columns="columns"
@change="change"
/>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { Person } from '@/module/person/person.model';
const columns = Person.getColumns<Person>();
const data = ref<Array<Person>>([]);
const change = Person.change;
const { size, bordered, pagination } = Person.getConfig();
const getData = async () => {
const response = await Person.getList<Person>();
data.value = response.list;
}
onMounted(() => getData());
</script>
复制代码
是不是感觉很简洁呢? 由于组件属性可能有很多很多, 如果我们一个一个的赋值上去 是很浪费时间的, 所以我们可以采用v-bind的方式来绑定动态属性.
<template>
<a-table
v-bind="config"
:data-source="data"
:columns="columns"
@change="change"
/>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { Person } from '@/module/person/person.model';
const columns = Person.getColumns<Person>();
const data = ref<Array<Person>>([]);
const change = Person.change;
const config = Person.getConfig();
const getData = async () => {
const response = await Person.getList<Person>();
data.value = response.list;
}
onMounted(() => getData());
</script>
复制代码
这里我要强调一下change方法, 这里的change方法是分页变化的时候会触发, 那么分页触发的时候 我们怎样更新metadata中的数据呢? 这里要利用vue3中composition API的特性来解决.
当定义元数据的时候就要使用ref, 修改的时候只需修改value中对应的属性即可, 我们来看一下最后的页面效果.
是不是挺好玩的, 嘿嘿?