vue3中的元编程&代码抽离

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-vuetable组件来做示例, 所以刚开始模版很简单, 只有一句代码(如果对这个组件不是很了解的话, 小伙伴们可以去ant-design-vue的官网看一下, 非常的简单2x.antdv.com/components/…).

  <a-table
    :data-source="data"
    :columns="columns"
  />
复制代码

我们可以看到模版的a-table组件上有很多很多的属性, 那么这些属性是从哪里来的呢? 这里就要用到我们在创建项目时新建的那几个文件.

第四步: 定义工具方法

这里我们在utils.ts中定义两个工具

  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 };
}
复制代码
  1. 获取属性的元数据
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类有三个关键点:

  1. 使用EnhancedTableClass方法装饰, 该方法的作用刚刚已经聊过, 主要是为了扩展类的静态方法以及属性的(实现TableBase抽象类).
  2. 继承自TableBase, TableBase是一个抽象类, 规定当前类需要实现的方法, 为什么这里会加了一个抽象类呢? 因为静态属性和方法都是通过装饰器来添加的, 所以在使用中ide是没有代码提示的, 为了避免出错, 故而加了一个抽象类来约束.
  3. 类中的属性被Column装饰器所装饰, 该装饰器的作用是定义数据列的元数据, 例如ant-desgin-vue中需要的这样.

image.png

好了, 到了这里我们所需的东西就已经定义好了, 上面只讲了定义但是没有讲具体的逻辑, 因为我想放到实际应用中来讲, 因为我觉得这样更好理解一点.

第七步: 数据

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?
image.png
这里我们可以看到最后一列key, 我们并不想让他展示, 该怎么办呢? 非常的简单, 只要字段不被Column装饰器装饰即可.

image.png

这个时候再回到页面会发现, 最后一列key消失了

image.png

如果想要给a-table添加其他的属性呢? 例如ant-design-vue官网给出的这些属性.

image.png
其实非常非常的简单, 我们只需要把需要的数据传给EnhancedTableClass即可.

image.png

而我们只需要在EnhancedTableClass的constructor中接收即可, 而且我们还要提供一个获取的方法.

image.png

我们来看一下现在的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的特性来解决.

image.png
当定义元数据的时候就要使用ref, 修改的时候只需修改value中对应的属性即可, 我们来看一下最后的页面效果.

这个钱花得值的山竹-2021-06-06-09.39.09.gif
是不是挺好玩的, 嘿嘿?

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享