vue组件-vue3+elementPlus使用json schema模式二次封装el-table

1,239 阅读8分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第3篇文章,点击查看活动详情

前言

开发 B 端项目, CURD 是绕不过去的坎,而要做 CURD 当然少不了无数个 table。

JSON Schema是一个帮助你定义、校验甚至是修复json数据格式的解决方案。它定义了一整套规则,允许我们通过定义一个schema(本身也是JSON)来描述一个JSON串的数据格式。

我常用的 UI 框架是 element 系列的,最近在使用 elementPlus 的时候对 el-table 做了一下简单的封装,通过定义一套 schema 并将 json 传入组件实现表格的渲染和交互。

这篇文章的目的也是记录一下开发过程和其中的所思所感。

需求拆解

  1. 定义一套 json 格式的数据结构,用来被组件解析,生成 dom。
  2. 对 el-table 进行改造,支持 通过 对json的解析,渲染 不同的列,并绑定交互逻辑。
  3. 支持 json 结构的可持续集成,尽量降低 集成成本。

开发思路

下面这段代码,大家应该是非常非常的熟悉。我也是是从文档里面粘过来的. . .

<template>
  <el-table :data="tableData" style="width: 100%">
    <el-table-column prop="date" label="Date" width="180" />
    <el-table-column prop="name" label="Name" width="180" />
    <el-table-column prop="address" label="Address" />
  </el-table>
</template>

<script lang="ts" setup>
    const tableData = [
      {
        date: '2016-05-03',
        name: 'Tom',
        address: 'No. 189, Grove St, Los Angeles',
      },
      {
        date: '2016-05-02',
        name: 'Tom',
        address: 'No. 189, Grove St, Los Angeles',
      },
      {
        date: '2016-05-04',
        name: 'Tom',
        address: 'No. 189, Grove St, Los Angeles',
      },
      {
        date: '2016-05-01',
        name: 'Tom',
        address: 'No. 189, Grove St, Los Angeles',
      },
    ]
</script>

其实,element 团队做的 table 组件其实已经非常好用了,之所以动了对它进行一次封装的原因有如下几点原因:

  1. 懒,如果能减少一些重复的代码就好了
  2. 同一字段出现在多个表格中,改动起来就很麻烦了
  3. 如果列中的结构比较复杂,整个表格的结构会很臃肿,难以维护
  4. 如3中所说,如果在 3 的基础上,复杂的结构再多次复用,更加恐怖
  5. 有人试过一个表格有 40+ 列嘛? 还有一大堆的 filter ...
  6. 。。。

上面这些场景都遇到过,最惨的是有一个字段在10几个表格中都遇到过,而且在开发阶段这个字段 改了n次 . . .

走远了,拉回来!!!


步入正题:

其实这个封装很简单,将整个封装结构分为三个部分:

  1. 表格基础配置

表格的基础配置主要是表格的整体风格,以及分页组件的引用、序号列、多选框这几项内容。

虽然序号列和多选框其实是表格列的内容,但是这两列正常情况下都是放在第一列的位置,所以在我的逻辑里面,我把它放在了表格的基础配置项中,而且对于交互逻辑也更容易和表格列进行拆解,毕竟这俩字段都是以整个表格作为交互对象的。

  1. 表格列的扩展

表格列实际上分为三部分,但是因为序号列和多选框放在了表格基础配置上,所以只剩下了两个部分:数据列和操作列。

数据列 就是从原始数据中的需要显示的字段对应的列。 操作列 则是操作按钮所在的列。

其实核心的部分就是这两个。

  1. 表格数据的导入方式

数据导入主要涉及到初次渲染和数据更新两个场景,更新的场景涉及到三个方面,切换分页、触发搜索和新增数据触发的更新。

三个场景拆解完成以后,具体考虑一下该怎么做:

表格基础项

这里其实主要是做了 el-table 已有的几个配置的重写,然后使用 object 格式的 json 数据重新写入。

在此基础上增加了 序号列和多选框列,可以使用 boolean 传入。

json-schema 表格列

这里是最核心的部分,在原来的封装逻辑里面有两个分歧,一个是通篇使用 if-else 进行判断,另一个是使用 component 动态组件进行渲染。

但是不可避免的会遇到插槽的问题,对插槽的处理则涉及到三个分歧:

  1. 写入组件内使用 if-else 进行分支渲染
  2. 留下一个动态具名插槽,在页面上开发
  3. 作为一个独立的组件,使用 component 动态组件进行渲染

这里我选择的是 使用 component 动态组件渲染,我认为的最优解。一方面提高可维护性,另一方面提高表格的可扩展性。同时也可以一步步的使封装后的 table 更加丰富,能满足更多的场景。

然后就是操作列的问题了,操作列我层京尝试过将它作为一个配置列,配置在json中,但是随着项目越做越多,按钮类型也不断的在变化,配置在 json 中也增加了新加入进来的同学的学习成本。所以我把这一列单独作为了一个 名为 tools 的插槽提出在页面里面。这样的好处有两点:一是提高代码的可读性,降低维护成本;另一方面按钮的操作一版都会涉及到页面级别的交互,例如:打开一个弹框、页面进行跳转等,写在页面里面就可以 免去 从 page 中传入 同级别的其他组件这一过程,直接操作页面。

涉及到复用的问题,就可以把复用性按钮单独提出来,但更多的情况下,复用的场景即便出现了,场景也比较单一。

数据导入

数据导入这里也是有分歧的,这个分歧其实并不是针对表格产生的,以往的时候经常会把 表格头部的搜索栏和工具栏也一起封装在一起。所以会把 ajax 也同步封装进去,这样的话如果是本地数据或者需要对数据进行一次处理,就需要封装更多的东西进去。

为了满足 高内聚、低耦合 的设计原则,我并没有这样做,我把 搜索 部分单独提出来作为另一个公共组件(searcher)来使用。而数据的获取则传入一个 object 并暴露一个 initTableData 的方法给 page 调用。。

代码实现

这次例外的没有把最终的代码粘贴进来,更多的是提供一种思路,但是大多数的代码还是真实的。

一、page.vue

依旧是以结果为导向,这里是具体的使用方法,逆推回去:

<template>
  <div class="page-main">
    <base-table ref="tableRef" :table-config="tableConfig" :table-columns="tableColumns">
      <template #tools="{ scope }">
        <el-button size="small" type="text" style="color: red" @click="handleDelete(scope.row.id)">删除</el-button>
        <el-button size="small" type="text" @click="handleOpenDialog('edit', scope.row)">编辑</el-button>
        <el-button size="small" type="text" @click="handleOpenDialog('show', scope.row)">查看</el-button>
      </template>
    </base-table>
  </div>
<Loger ref="companyLoger" :size="'small'"></Loger>
</template>

<script lang="ts" setup>

  import { TableConfigType } from '@/components/common-table/common-table-type';
  import { reactive, ref, nextTick } from 'vue';
  import { companyTableColumns } from '../utils/table-items';
  import { companyFormItems } from '../utils/form-items';
  import { FormConfigType } from '@/components/common-form/common-form-type';
  import { Http } from '@/utils/http';
  import Message from '@/utils/message';
  import { ElMessageBox } from 'element-plus';

  const module = 'company';

  // 声明ref
  const tableRef = ref();
  const companyLoger = ref();
  const companyForm = ref();
  // 声明数据
  // 表格
  const tableConfig = reactive<TableConfigType>({
    url: `/${module}/list`,
    border: true,
    index: true,
    check: false,
    tools: true,
    toolsWidth: 200,
  });
  const tableColumns = ref(companyTableColumns);
  // loger
  const logerType = ref<'new' | 'edit' | 'show'>('new');

  // 打开弹框
  const handleOpenDialog = (type: 'new' | 'edit' | 'show', row?: any) => {
    companyLoger.value.open(title);
  };

  // 表格删除
  const handleDelete = (id: { id: number }) => {
    ElMessageBox.confirm('您确定要删除该企业吗?', '提示', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
      .then(() => {
        Http.post(`/${module}/delete`, { id }).then(() => {
          Message.success('删除成功!');
          tableRef.value.initTableData();
        });
      })
      .catch(() => {
        Message.info('您点击了取消');
      });
  };

</script>

如代码所示,base-table 传入了两个 props: tableConfig 和 tableColumns。

tableConfig 的数据类型为:

export interface TableConfigType {
  url: string;
  query?: { [key: string]: any };
  border?: boolean; 
  index?: boolean;  // 序号列
  check?: boolean;  // 多选框列
  toolsWidth?: number;
  toolsFixed?: string;
  tools?: boolean;  // 是否有操作列
}

分页组件做了一下简单的处理,只有数据大于一页以上才会显示。而且一般来说全局上的分页风格都是一致的,所以这一版上并没有做 page 的相关绑定。

另一块,序号列、多选框列、操作列都使用 boolean 值进行判断是否显示。

数据默认使用 post 请求,传入一个 url 就好了,而且搜索的时候也是使用同一个接口。

二、table.vue

这段代码是可以 CV 的。

<template>
  <el-table ref="multipleTableRef" stripe :data="tableData" style="width: 100%" :border="tableConfig.border" @selection-change="handleSelectionChange">
    <el-table-column v-if="tableConfig.check" type="selection" width="55" />
    <el-table-column v-if="tableConfig.index" label="序号" :width="60" type="index" align="center" :index="indexMethod" />

    <component :is="ItemMap[item.name]" v-for="item in tableColumns" :item="item" :key="item.prop" @handle="handle" />

    <el-table-column v-if="tableConfig.tools" label="操作" :width="tableConfig.toolsWidth || 120" :fixed="tableConfig.toolsFixed || 'right'">
      <template #default="scope">
        <slot :scope="scope" name="tools"></slot>
      </template>
    </el-table-column>

    <template #empty>
      <el-empty />
    </template>
  </el-table>
  <div style="margin-top: 16px; overflow: hidden" v-if="page.total > page.size">
    <el-pagination
      v-model:currentPage="page.num"
      v-model:page-size="page.size"
      :page-sizes="[10, 20, 30, 40, 50]"
      layout="total, sizes, prev, pager, next"
      style="float: right"
      :total="page.total"
      background
      @size-change="handleSizeChange"
      @current-change="handleCurrentChange"
    />
  </div>
</template>

<script lang="ts" setup>
  import { PropType, toRefs, ref, reactive, onMounted } from 'vue';
  import { BaseColumnType, TableConfigType } from '@/components/common-table/common-table-type';
  import { ItemMap } from './columns';
  import type { ElTable } from 'element-plus';
  import { Http } from '@/utils/http';
  import Message from '@/utils/message';

  // 随便定义一个 表格数据类型
  interface TypeTableData {
    [key: string]: any;
  }
  interface resType {
    total: number;
    list: TypeTableData[];
  }

  //  props 接收数据并转换为双向绑定
  const props = defineProps({
    tableConfig: {
      type: Object as PropType<TableConfigType>,
      default: () => ({ border: true, index: false, check: false }),
    },
    tableColumns: {
      type: Array as PropType<BaseColumnType[]>,
      default: () => [],
    },
  });
  const { tableConfig, tableColumns } = toRefs(props);
  // 声明多选结果
  const multipleSelection = ref([]);
  const multipleTableRef = ref<InstanceType<typeof ElTable>>();
  const tableData = ref<{}>([]);

  //分页组件数据
  let page = reactive({
    total: 0,
    num: 1,
    size: 10,
  });

  // 更新列表
  const initTableData = (query?: TypeTableData) => {
    const { size, num } = page;
    const params = {
      size,
      num,
      query,
    };

    // TODO: takeTableData
    Http.post(tableConfig.value.url, params).then((res) => {
      if (!res) {
        return false;
      }
      Message.success('更新列表成功!');
      tableData.value = (res as unknown as resType).list;
      page.total = (res as unknown as resType).total;
    });
  };

  const resetTable = (query?: TypeTableData) => {
    page.num = 1;
    initTableData(query);
  };

  // 序号方法
  const indexMethod = (i: number) => (page.num - 1) * page.size + i + 1;

  // 表格方法 - 多选勾选
  const handleSelectionChange = (val: any) => {
    multipleSelection.value = val;
    console.log(multipleSelection.value);
  };

  // 分页组件方法
  const handleSizeChange = (val: number) => {
    page.size = val;
    initTableData();
  };
  const handleCurrentChange = (val: number) => {
    page.num = val;
    initTableData();
  };

  // emit
  const emits = defineEmits({
    handleMethod: null,
  });
  const handle = (o: any) => {
    console.log('baseTable :: ', o);
    emits('handleMethod', o);
  };

  // 生命周期 mounted
  onMounted(() => {
    initTableData();
  });

  // 对外暴露属性
  defineExpose({
    multipleSelection,
    page,
    initTableData,
    resetTable,
  });
</script>

<style scoped lang="scss">
  .el-pagenation {
    --el-pagination-hover-color: red !important;
  }
</style>

并没有什么需要关注的,主要是 initTableData 方法,会传入 query 作为搜索条件使用。这样就可以实现刷新表格了。

而且可以看到的是,我将整个 item 都传入了子组件,所以,在 column-item 中就比较灵活了,而且不论子组件有多么的复杂,只要 数据结构 能匹配上 都可以成功渲染,不用对table做改动。

三、table-item.vue

这里就是可持续集成的核心部分了。

这里涉及到了几个方面,一方面是动态组件的声明方式,另一方面就是具体逻辑的实现。

类型声明:


// 基础列 类型
// 其它列 都是集成这个类型
export interface BaseColumnType {
  name: string;  // 列名,列组件的 name
  prop: string;  // 渲染字段
  label: string;  // title 内容
  align: string;  //  对齐
  fixed?: string;  //  固定在 左 | 右
  width?: string | number; 
  minWidth?: string | number;
  formatter?(val: any): any;  // 数据预处理
}

// 点击列,也是链接列
export interface ClickColumnType extends BaseColumnType {
  handle(row: any): void;
}

具体组件:

// click-column.vue

<template>
  <el-table-column :prop="item.prop" :label="item.label" :width="item.width">
    <template #default="{ row }">
      <a href="javascript:void(0);" @click="handleClick({ handle: item.handle, row })">{{ row[item.prop] }}</a>
    </template>
  </el-table-column>
</template>

<script setup lang="ts">
  import { PropType, toRefs } from 'vue';

  import { ClickColumnType } from '../common-table-type';

  const props = defineProps({
    item: {
      type: Object as PropType<ClickColumnType>,
      default: () => ({}),
    },
  });

  const { item } = toRefs(props);

  const handleClick = ({ handle, row }: { handle: (row: any) => void; row: any }) => {
    handle(row);
  };
</script>

挂载在 ItemMap 上,供 table 引用:

// index.ts

import { Component } from 'vue';

import BaseColumn from './BaseColumn.vue';
import ClickColumn from './ClickColumn.vue';
import SwitchColumn from './SwitchColumn.vue';
import TagsColumn from './TagsColumn.vue';

interface Item {
  [key: string]: Component;
}

export const ItemMap: Item = {
  'base-column': BaseColumn,
  'click-column': ClickColumn,
  'switch-column': SwitchColumn,
  'tags-column': TagsColumn,
};

至此,整个封装过程完成,项目结构如下:

image.png

对项目结构进行解释一下:
最底层是 column-item
在index.ts中引入并设置 别名,供 table 使用
base-table 中 组装整个 table 结构
globalPlugin.ts 中将 base-table 注册为全局组件
在 page 中使用 base-table

结语

记得前段时间在一个大佬的文章的评论区看到好多人对二次封装这件事情嗤之以鼻,还有好多大佬都对原理性的东西很是痴迷,我很佩服。

我是个懒人,封装这个东西的目的很简单,就是为了能够在更短的时间内完成开发任务。正所谓没有买卖就没有杀害,有需求才有的市场。这个问题不讨论了。

躺平 ~~~