基于Vue设计一个动态表格

1,275 阅读3分钟

日常前端开发中肯定会用到表格,写一个 table 的流程如下:

  1. 写 el-table 和 el-table-column 标签,绑定参数;如果需要把值处理后再显示,还需要调用函数或者自定义组件来处理数据
  2. 在 mounted 或者 created 生命周期中调接口获取数据,然后把数据传给 table 的 data 属性
  3. 表格右边往往会有操作列,需要定义好操作的名称和对应的回调函数

每次用到 element table 表格组件时,都要编写一大堆这样的模板代码,很重复繁琐。

于是,我们希望有一种更配置化的开发方式,通过传入元数据,来动态生成表格。

设计

如何设计出配置化的动态表格组件呢?

我们先对目前的表格代码进行分析,发现常见的表格包括几部分内容:

  1. 表头
  2. 普通的单元格,简单点就直接显示该字段的值,复杂点可能需要使用其他组件,或者调用函数来显示值
  3. 操作列:有几个操作按钮,如果按钮数量比较多,还可能需要折叠起来
  4. 在业务组件的 mounted 或者 created 生命周期中调接口获取数据,传给 data 属性
  5. 表格需要分页

如何把这几部分内容进行封装呢?

  • 对于表头,可以采用传入 columns 数组的方式。每个 column 对象定义了以下属性:
属性说明类型
prop从 data 中读取的字段String
label表头显示的属性名String
align同 table 的 alignString
fixed该列是否锁定Boolean
  • 对于表中的单元格,一般有几种类型:
  1. 直接显示字段的值
  2. 对字段的值进行转换,再显示转换后的文本
  3. 使用其他组件来渲染内容

以上几种情况的处理思路如下:

  1. 直接通过row[col.prop]来取值
  2. 写一个转换函数 format,由于可能需要用到其他属性,所以传入的是表格该行的值:row;返回转换后的结果
  3. 为了足够灵活,我们借鉴了 Vuerender函数的写法,使用 JSX 的方式来编写动态组件;render 函数传入 h 函数和当前列对象 column,以及当前行对象 row;通过在 template 中嵌入 JSX 的方式来渲染

column 对象要添加的属性,整理成表格如下:

属性说明类型
format对原始值进行转换,返回转换后的结果。(row)=>String
render渲染函数(h,column,row)=>JSX

如何在 template 中嵌入 JSX 呢?

我们编写一个函数式组件 Vnodes:

{
      functional: true,
      render: (h, ctx) => ctx.props.vnodes,
    }

在需要嵌入 JSXtemplate 中使用 Vnodes 组件

<Vnodes :vnodes="col.render($createElement, col, row)" />
  • 单元格处理完了,那表格右侧的操作列如何封装呢?

很简单,就像 columns 那样传入一个 operations 数组即可。

每个操作需要有一个唯一 key,我们用 id 来表示

每个操作的名字我们用 label 表示

一些危险性的操作,比如“删除”,需要用警示色来表示,那就传入一个 isDangerous 属性,组件内部来决定显示的颜色

按钮数量较多时,需要有一个临界值来控制最多显示多少个按钮,其余的按钮折叠,我们用 maxOperationNum 来表示

点击操作按钮后的回调,我们通过传入一个函数 actionMenuClick 来实现

operation 对象的属性如下:

属性说明类型
id操作的 keyString
label操作的名称String
isDangerous是否危险操作Boolean

maxOperationNumactionMenuClick 放到 table 最外层:

属性说明类型默认值
maxOperationNum操作按钮不折叠时最多显示多少个按钮Number2
actionMenuClick按钮点击事件的处理函数(menu, row)=>void,


menu 为 operation 对象,row 为表格数据行对象

表格数据更新的业务逻辑,往往是调接口异步获取数据,传给 data。如何封装异步更新表格数据的函数呢?

我们定义一个函数 fetchData,这个函数返回一个 PromisePromise resolve 后的对象需要符合这个格式:

IData

{
  data: Object,
  page: {
    total: Number
  }
}

把这个函数传给动态表格组件,组件内部在 mounted 生命周期函数中调用这个函数,接收到数据后,再把 data 塞给 table 的 data 属性,把 page.total 塞给分页组件

如果不需要异步加载数据,就不传入 fetchData 函数即可(fetchData 优先级高于 data)

如果需要手动刷新表格,就让动态表格组件暴露一个函数 refreshData,在业务代码中通过 ref 来调用 refreshData 这函数

综上,动态表格所有的参数如下:

参数说明类型默认值
columns表格的列的元数据Array
data表格数据Array
operations操作列Array
maxOperationNum操作按钮不折叠时最多显示多少个按钮Number2
actionMenuClick按钮点击事件的处理函数(menu:IOperation, row)=>void
fetchData异步更新表格数据()=>Promise

methods:

方法名说明参数
refreshData手动刷新表格

IColumn 类型定义

{
prop: String,
label: String,
align:String,
fixed:String,
format:(row)=>String,
render:(h,column,row)=>JSX
}

IOperation 类型定义

{
id:String,
label:String,
isDangerous:Boolean,
}

至此,动态表格组件已经设计好了,可以开发了

实现

下面基于 Vue2 版的 Element 组件,来开发动态表格组件(基于 Vue3 版的 Element Plus 或其他组件库的实现思路类似)

表格动态列的实现如下:

<el-table-column
  v-for="col of columns"
  :key="col.prop"
  :prop="col.prop"
  :label="col.label"
  :align="col.align"
>
  <template #header="{ column }">{{ column.label }}</template>
  <template #default="{ row }">
    <template v-if="typeof col.render === 'function'">
      <Vnodes :vnodes="col.render($createElement, col, row)" />
    </template>
    <template v-else-if="typeof col.format == 'function'"
      >{{ col.format(row) }}</template
    >
    <template v-else>{{ row[col.prop] }}</template>
  </template>
</el-table-column>

这里包含了前面说的 3 种情况:使用 render 函数、使用 format 函数,和直接显示

表格操作列的实现如下:

<el-table-column label="操作">
  <template #default="{ row }">
    <el-button
      v-for="op of operations"
      :key="op.id"
      type="text"
      :class="{ dangerous: op.isDangerous }"
      @click="(e) => clickFn(e, row)"
    >
      {{ op.label }}
    </el-button>
  </template>
</el-table-column>
export default {
  props: {
    operations: {
      type: Array,
      default: () => [],
    },
    actionMenuClick: Function,
  },
  methods: {
    clickFn({ e: menu, row }) {
      if (typeof this.actionMenuClick !== "function") {
        return;
      }
      this.actionMenuClick(menu, row);
    },
  },
};

为了简单,这里没有进一步实现 maxOperationNum

表格分页待实现

表格异步加载数据的实现如下:

export default {
  props: {
    data: {
      type: Array,
      default: () => [],
    },
    fetchData: Function,
  },
  data() {
    return {
      total: 0,
      currentData: [],
      loading: false,
    };
  },
  watch: {
    data(val) {
      if (Array.isArray(val)) {
        this.currentData = val;
      }
    },
  },
  mounted() {
    this.initFn();
  },
  methods: {
    async initFn() {
      if (typeof this.fetchData === "function") {
        this.loading = true;
        try {
          const { list, pagination } = await this.fetchData();
          this.currentData = list.data;
          this.total = pagination.total;
        } catch (err) {
          console.error(err);
        } finally {
          this.loading = false;
        }
      } else {
        this.currentData = this.data;
        this.total = this.data.length;
      }
    },
  },
};

所有代码如下:

<template>
  <div>
    <el-table :data="currentData" v-loading="loading" stripe highlight-current-row style="width: 100%">
      <el-table-column type="index" width="50"></el-table-column>
      <el-table-column v-for="col of columns" :key="col.prop" :prop="col.prop" :label="col.label" :align="col.align">
        <template #header="{ column }">{{ column.label }}</template>
        <template #default="{ row }">
          <template v-if="typeof col.render === 'function'">
            <Vnodes :vnodes="col.render($createElement, col, row)" />
          </template>
          <template v-else-if="typeof col.format == 'function'">{{ col.format(row) }}</template>
          <template v-else>{{ row[col.prop] }}</template>
        </template>
      </el-table-column>
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button
            v-for="op of operations"
            :key="op.id"
            type="text"
            :class="{ dangerous: op.isDangerous }"
            @click="(e) => clickFn(e, row)"
          >
            {{ op.label }}
          </el-button>
        </template>
      </el-table-column>
    </el-table>
    <el-pagination
      :total="total"
      :page-size="pagination.pageSize"
      :current-page="pagination.currentPage"
      :layout="pagination.layout"
      @size-change="sizeChange"
      @current-change="pageChange"
      @prev-click="pageChange"
      @next-click="pageChange"
    ></el-pagination>
  </div>
</template>
export default {
  props: {
    data: {
      type: Array,
      default: () => [],
    },
    columns: {
      type: Array,
      default: () => [],
    },
    operations: {
      type: Array,
      default: () => [],
    },

    pagination: {
      type: Object,
      default: () => ({}),
    },
    actionMenuClick: Function,
    fetchData: Function,
  },
  components: {
    Vnodes: {
      functional: true,
      render: (h, ctx) => ctx.props.vnodes,
    },
  },
  data() {
    return {
      total: 0,
      currentData: [],
      loading: false,
    };
  },
  watch: {
    data(val) {
      if (Array.isArray(val)) {
        this.currentData = val;
      }
    },
  },
  mounted() {
    this.initFn();
  },
  methods: {
    async initFn() {
      if (typeof this.fetchData === 'function') {
        this.loading = true;
        try {
          const { list, pagination } = await this.fetchData();
          this.currentData = list.data;
          this.total = pagination.total;
        } catch (err) {
          console.error(err);
        } finally {
          this.loading = false;
        }
      } else {
        this.currentData = this.data;
        this.total = this.data.length;
      }
    },
    clickFn({ e: menu, row }) {
      if (typeof this.actionMenuClick !== 'function') {
        return;
      }
      this.actionMenuClick(menu, row);
    },
    sizeChange(size) {
      this.$message.info(`每页条数:${size}`);
    },
    pageChange(currentPage) {
      this.$message.info(`当前页:${currentPage}`);
    },
  },
};

测试

写了以下代码来构造动态表格

<dynamic-table
  :data="data"
  :columns="columns"
  :operations="operations"
  :fetch-data="fetchData"
  :pagination="pagination"
  :action-menu-click="actionMenuClick"
></dynamic-table>
import DynamicTable from "./DynamicTable.vue";
export default {
  components: {
    DynamicTable,
  },
  data() {
    return {
      data: [
        {
          date: "2016-05-02",
          name: "王小虎",
          province: "上海",
          city: "普陀区",
          address: "上海市普陀区金沙江路 1518 弄",
          zip: 200333,
          tag: "家",
        },
        {
          date: "2016-05-04",
          name: "王小虎",
          province: "上海",
          city: "普陀区",
          address: "上海市普陀区金沙江路 1517 弄",
          zip: 200333,
          tag: "公司",
        },
      ],
      columns: [
        {
          prop: "name",
          label: "姓名",
          render() {
            return <div>666</div>;
          },
        },
        {
          prop: "date",
          label: "日期",
        },
        {
          prop: "city",
          label: "城市",
          format(data) {
            return `${data.province}-${data.city}`;
          },
        },
      ],
      operations: [
        {
          id: "edit",
          label: "编辑",
        },
        {
          id: "delete",
          label: "删除",
          isDangerous: true,
        },
      ],
      pagination: {
        layout: "total, sizes, prev, pager, next, jumper",
      },
    };
  },
  methods: {
    fetchData() {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve({
            list: {
              data: this.data,
              total: this.data.length,
            },
            pagination: {
              currentPage: 0,
              pageSize: 20,
              total: 30,
            },
          });
        }, 3000);
      });
    },
    actionMenuClick({ menu, row }) {
      const map = {
        edit() {
          this.$message.info(`编辑`);
        },
        delete() {
          this.$message.info(`删除`);
        },
      };
      map[menu.id](row);
    },
  },
};

运行效果

20240616_162835_image.png

下一步优化的方向

未完待续