Metaprogramming in VUE3 & VuE3 Code extraction

preface

In recent two days to see an article, mp.weixin.qq.com/s/dHDaOSnSo… This article is about how metaprogramming in React speeds up front-end development. I was inspired to see if we could apply some of these ideas to Vue, which happens to be vue3, which has excellent typescript support. Could we use ts features more freely?

Tools & Plug-ins

  • vscode
  • Volar plug-in
  • vue3
  • typescript
  • reflect-matedata

Step 1: Directory structure

Because I prefer a framework on the front end called Nest. Js, which can be simply understood as a TS version of Express, with the bright name Spring in Node, our directory structure is created just like Nest. Here I create the project using vue CLI,vue create metadata-apply, in the create option, select the version of vue3, and typescript. The structure of the newly created project looks like this

├ ─ ─ App. Vue ├ ─ ─ assets │ └ ─ ─ logo. The PNG ├ ─ ─ components │ └ ─ ─ the HelloWorld. Vue ├ ─ ─ main. Ts ├ ─ ─ the router │ └ ─ ─ but ts ├ ─ ─ └── Views ├── About.vue ├─ home.vueCopy the code

First of all, we need to clean up the unused files and create several new files

├ ─ ─ App. Vue ├ ─ ─ main. Ts ├ ─ ─ the router │ └ ─ ─ but ts ├ ─ ─module│ └ ─ ─ person │ ├ ─ ─ person. Model. The ts │ ├ ─ ─ person. The service. The ts │ └ ─ ─ person. The ts ├ ─ ─ utils │ └ ─ ─ utils. Ts └ ─ ─ views └ ─ ─ Home.vueCopy the code


Let’s take a look at the contents of each file app.vue

<template>
  <router-view/>
</template>
Copy the code


The 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;
Copy the code


Views/Home. Vue

<template>

</template>

<script lang="ts" setup>

</script>
Copy the code

Step 2: Project dependencies

  • reflect-metadata

The decorators in TS are used here, and the metadata feature is used, so use a reflect-metadata library

NPM I reflect-metadata import ‘reflect-metadata’; NPM I reflect-metadata import ‘reflect-metadata’;

  • ant-design-vue

In this example, we will use a UI framework. Initially, we wanted to use Element-Plus, but it turned out not to work well, so we chose Ant-Design-Vue

Download: NPM I –save ant-design-vue@next Import: add the following code to the main.ts file

import Antd from 'ant-design-vue';
import "ant-design-vue/dist/antd.css";
app.use(Antd);
Copy the code

main.tsComplete code:

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');
Copy the code

Step 3: Analyze the template

We use the ant-design-Vue table component as an example, so the template is very simple at first, only one code. (If you are not familiar with this component, you can go to the official website of Ant-design-Vue to see.) Very simple 2x.antdv.com/components/…) .

  <a-table
    :data-source="data"
    :columns="columns"
  />
Copy the code

We can see that there are many, many properties on the A-Table component of the template, so where do these properties come from? We’re going to use the files that we created when we created the project.

Step 4: Define the tool method

Here we define two tools in utils.ts

  1. A method used to create a property decorator that returns the unique key created and the decorator function
export function CreateProperDecorator<T> () :ICPD<T> {
  const metaKey = Symbol(a);function properDecoratorF(config: T) :PropertyDecorator {
    return function (target, propertyKey) {
      Reflect.defineMetadata(metaKey, config, target, propertyKey); }}return { metaKey, properDecoratorF };
}
Copy the code
  1. Gets the metadata for the attribute
export function getConfigMap<T> (target: any, cacheKey: symbol, metaKey: symbol) :Map<string.T> {
  if (target[cacheKey]) return target[cacheKey];
  const instance = new target({});
  // Get all attributes of the instance
  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];
}
Copy the code

There is no relation between the two methods, but it is necessary to know that the two methods correspond to each other. One is used to define the attribute metadata, and the other is used to obtain the attribute metadata. The complete utils. Ts file

import 'reflect-metadata';
import { ICPD } from '.. /module/person/person.type';


// 1. A method to create a property decorator that returns the unique key created and the decorator function
export function CreateProperDecorator<T> () :ICPD<T> {
  const metaKey = Symbol(a);function properDecoratorF(config: T) :PropertyDecorator {
    return function (target, propertyKey) {
      Reflect.defineMetadata(metaKey, config, target, propertyKey); }}return { metaKey, properDecoratorF };
}

// Get the metadata for the attribute
export function getConfigMap<T> (target: any, cacheKey: symbol, metaKey: symbol) :Map<string.T> {
  if (target[cacheKey]) return target[cacheKey];
  const instance = new target({});
  // Get all attributes of the instance
  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];
}
Copy the code

ICPD is in the person.type.ts file, and if you introduce it here, it will cause an error, but it doesn’t matter, that will be covered in the next section.

Step 5: Type constraints

Person. The ts

import { CreateProperDecorator } from "@/utils/utils";

// Class decorator constraint: can give different data to the class according to the business
export interfaceClassConfig { size? :'middle' | 'small'; bordered? :boolean; pagination? : {'show-less-items'? :boolean; current? :number; pageSize? :number; total? :number;
  };
}

// The return constraint of the property decorator
export type ICPD<T> = { metaKey: symbol, properDecoratorF: (config: T) = > PropertyDecorator };

// Background returns field constraints
export interface Paginabale<T> {
  total: number;
  list: T[]
}

// Table column constraints
export interface TableColumu {
  title: string.dataIndex: string.key: string,}// Make the TableColumu properties optional
export type ColumnPropertyConfig = Partial<TableColumu>;

// Create a property decorator for the table column
export const columnConfig = CreateProperDecorator<ColumnPropertyConfig>();
// Get the property decorator
export const Column = columnConfig.properDecoratorF;

// Table abstract class
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;
}
Copy the code

Step 6: Create the data model

Let’s look at it firstperson.model.tsThe complete code, we will step by step analysis.

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. Class decorator, which handles metadata collected through decorators
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);
      }

      // Get the metadata on the column
      static get columnConfig() :Map<string.ColumnPropertyConfig> {
        return getConfigMap<ColumnPropertyConfig>(EnhancedTableClass, cacheColumnConfigKey, columnConfig.metaKey);
      }

      // Get the table column
      static getColumns(): TableColumu[] {
        const list: TableColumu[] = [];
        EnhancedTableClass.columnConfig.forEach(config= > list.push(config as TableColumu));
        return list;
      }

      // Get table data
      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: 'Unique identification'.dataIndex: 'id'.key: '0'
  })
  id: number = 0;

  @Column({
    title: 'name'.dataIndex: 'name'.key: '1'
  })
  name: string = ' ';

  @Column({
    title: 'age'.dataIndex: 'age'.key: '2'
  })
  age: number = 0;

  @Column({
    title: 'gender'.dataIndex: 'sex'.key: '3'
  })
  sex: 'male' | 'female' | 'unknow' = 'unknow';

  @Column({
    title: 'address'.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(a);this.id = id;
    this.key = key;
    this.name = name;
    this.age = age;
    this.sex = sex;
    this.address = address; }}Copy the code

First, let’s look at the EnhancedTableClass method, which is a factory method for the class decorator. (If you don’t know about factory methods, check out juejin.cn/post/697242…) The factory method is used to extend the static methods and static properties of the class. Let’s leave aside which methods have been added and look at the Person class. The Person class has three key points:

  1. useEnhancedTableClassThe purpose of this method is to extend the static methods and properties of the class (implementing the TableBase abstract class).
  2. Inherited fromTableBaseTableBase is an abstract class that specifies the methods that the current class needs to implement. Why do we have an abstract class here? Because static properties and methods are added via decorators, there is no code hint for the IDE in use. To avoid errors, an abstract class is added.
  3. Properties in the class areColumnDecorated by a decorator that definesMetadata for the data column, e.g.ant-desgin-vueIn this case.

All right, so at this point we’ve defined what we need, so it’s just the definition but it doesn’t give us any logic, because I want to put it into practice, because I think it makes a little bit more sense.

Step 7: Data

Person.service. ts is used to simulate data return

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: 'Qingdao, Shandong Province' },
          { key: 2.id: 11.name: 'timer'.age: 22.sex: 'female'.address: 'Qingdao, Shandong Province' },
          { key: 3.id: 11.name: 'timer'.age: 22.sex: 'female'.address: 'Qingdao, Shandong Province' },
          { key: 4.id: 11.name: 'timer'.age: 22.sex: 'female'.address: 'Qingdao, Shandong Province' },
          { key: 5.id: 11.name: 'timer'.age: 22.sex: 'female'.address: 'Qingdao, Shandong Province' },
          { key: 6.id: 11.name: 'timer'.age: 22.sex: 'female'.address: 'Qingdao, Shandong Province' },
          { key: 7.id: 11.name: 'timer'.age: 22.sex: 'female'.address: 'Qingdao, Shandong Province' },
          { key: 8.id: 11.name: 'timer'.age: 22.sex: 'female'.address: 'Qingdao, Shandong Province'}].count: 2})},500);
  });
}
Copy the code

Step 8: Start seeing results

Let’s go back to home.vue. Let’s first look at the complete code for 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>
Copy the code

There are only 8 lines of valid code, so let’s take a look at the page, isn’t that cool?

Here we see the last columnkeyWe don’t want him to show. What should we do? Very simple, as long as the fields are not decorated by the Column decorator.

At this point, if you go back to the page, the last key column has disappeared

What if you want to add other attributes to the A-table? For example, the ant-design-Vue website gives these properties.

It’s very, very simple, we just need to pass the data we need to EnhancedTableClass.

We only need to receive it in constructor of EnhancedTableClass, and we also need to provide a method to get it.

Let’s look at home.vue now

<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>
Copy the code

Does it feel simple? Since there are many, many components, it is a waste of time to assign them one by one, so we can use v-bind to bind dynamic attributes.

<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>
Copy the code

I want to emphasize the change method here. The change method is triggered when the page changes. How do we update the metadata when the page changes? Take advantage of the features of the Composition API in Vue3 to solve this problem.

Use the ref when you define the metadata, and when you modify it, you only need to modify the corresponding attribute in the value. Let’s look at the final page effect.

Is not quite fun, hey hey 😁