Vue3 二次封装 Element Plus 通用配置化表格全量封装

30 阅读5分钟

引言

在后台管理系统开发中,表格是最常用的 UI 组件之一。Element Plus 提供了功能强大的 el-table 组件,但在实际项目中,我们往往需要根据业务需求进行二次封装,以提高开发效率和代码复用性。

本文将详细介绍如何基于 Element Plus 封装一个功能完善、易于扩展的表格组件。

功能特性

  • 支持自定义列配置
  • 内置分页功能
  • 支持加载状态
  • 支持空状态定制
  • 支持全局序号列
  • 支持插槽自定义

实现细节

Props

参数说明类型默认值可选值
data表格数据Array[]-
columns列配置Array[]-
loading加载状态Booleanfalsetrue/false
pagination分页配置Object/Boolean{}-
paginationOptions分页组件配置Object{}-
emptyHeight空数据表格高度String"100%"-
emptyOptions空状态组件配置Object{}-
fit列宽是否自适应Booleantruetrue/false
showHeader是否显示表头Booleantruetrue/false
stripe是否显示斑马纹Boolean-true/false
border是否显示边框Boolean-true/false
size表格尺寸String-medium/small/mini
height表格高度String/Number--
其他配置支持 Element UI el-table 的所有配置-

Events

事件名称说明回调参数
pagination:size-change每页条数变化时触发size
pagination:current-change当前页码变化时触发current
其他事件支持 Element UI el-table 的所有事件同 Element UI

Column 配置项

参数说明类型默认值可选值
prop列字段名String--
label列标题String--
width列宽String/Number--
minWidth最小列宽String/Number--
fixed是否固定列String/Boolean-left/right/true/false
type列类型String-globalIndex/index/selection/expand
hidden是否隐藏列Booleanfalsetrue/false
其他配置支持 Element UI el-table-column 的所有配置-

Slots

插槽名称说明
tableEmpty空状态插槽
tableEmptyDescription空状态描述插槽
tableEmptyImage空状态图片插槽
tableEmptyDefault空状态默认插槽
tableAppend表格底部追加内容插槽
tablePaginationDefault分页组件默认插槽
[prop]-header自定义列头插槽,[prop] 为列的 prop 值
[prop]自定义列内容插槽,[prop] 为列的 prop 值

组件源码

LdTableColumn

<script setup lang="ts">
  defineOptions({
    name: 'LdTableColumn',
  });
  defineProps({
    column: {
      type: Object,
      default: () => ({}),
    },
  });
</script>
<template>
  <el-table-column v-bind="column">
    <!-- 递归渲染子列 -->
    <template v-if="column.children && column.children.length > 0">
      <ld-table-column
        v-for="childCol in column.children"
        :key="childCol.prop || childCol.label"
        :column="childCol"
      />
    </template>
    <!-- 表头插槽 -->
    <template
      v-if="
        (!column.children || column.children.length === 0) && column.useHeaderSlot && column.prop
      "
      #header="{ column: elColumn, $index }"
    >
      <slot :name="`${column.prop}-header`" :column="elColumn" :index="$index">
        {{ column.label }}
      </slot>
    </template>
    <!-- 内容插槽 -->
    <template
      v-if="(!column.children || column.children.length === 0) && column.useSlot && column.prop"
      #default="{ row, column: elColumn, $index }"
    >
      <slot :name="column.prop" :row="row" :column="elColumn" :index="$index">
        {{
          column.formatter
            ? column.formatter(row, elColumn, row[column.prop], $index)
            : row[column.prop]
        }}
      </slot>
    </template>
  </el-table-column>
</template>

LdTable

<script setup lang="ts">
  import { useResizeObserver } from '@/composables';
  import type { LdTableEmits, LdTablePaginationProps, LdTableProps, LdTableSlots } from './LdTable';

  defineOptions({
    name: 'LdTable',
  });

  const elTableRef = useTemplateRef('elTableRef');
  const paginationRef = useTemplateRef('paginationRef');
  const paginationHeight = ref(0);
  // 分页器与表格之间的间距常量
  const PAGINATION_SPACING = 6;
  useResizeObserver(paginationRef, (entries) => {
    const entry = entries[0];
    if (entry) {
      requestAnimationFrame(() => {
        paginationHeight.value = entry.contentRect.height;
      });
    }
  });

  const props = withDefaults(defineProps<LdTableProps>(), {
    columns: () => [],
    fit: true,
    showHeader: true,
    stripe: undefined,
    border: undefined,
    size: undefined,
    emptyHeight: '100%',
    emptyText: '暂无数据',
  });
  // const instance = getCurrentInstance();
  const attrs = useAttrs();
  const emits = defineEmits<LdTableEmits>();
  const slots = defineSlots<LdTableSlots>();

  // 合并分页组件配置
  const mergedPaginationOptions = computed(() => {
    const defaultOptions: Partial<LdTablePaginationProps> = {
      pageSizes: [10, 20, 30, 50, 100],
      align: 'center',
      background: true,
      layout: 'total, prev, pager, next, sizes, jumper',
      hideOnSinglePage: false,
      size: 'default',
      pagerCount: 7,
    };

    return { ...defaultOptions, ...props.paginationOptions } as LdTablePaginationProps;
  });
  // 空数据判断
  const isEmpty = computed(() => {
    return props.data && props.data.length === 0;
  });
  // 表格高度逻辑
  const tableHeight = computed(() => {
    // 空数据且非加载状态时固定高度
    if (isEmpty.value && !props.loading) return props.emptyHeight;
    // 使用传入的高度
    if (props.height) return props.height;
    // 默认占满容器高度
    return '100%';
  });
  // 容器高度计算
  const containerHeight = computed(() => {
    const matchPaginationHeight = showPagination.value ? paginationHeight.value : 0;
    const offset = matchPaginationHeight + PAGINATION_SPACING;
    return {
      height: offset === 0 ? '100%' : `calc(100% - ${offset}px)`,
    };
  });

  const mergedTableProps = computed(() => {
    return {
      ...attrs,
      ...props,
      height: tableHeight.value,
    };
  });

  const showPagination = computed(() => {
    return props.pagination && !isEmpty.value;
  });

  // 合并空状态组件配置
  const mergedEmptyOptions = computed(() => {
    const defaultOptions = {
      description: props.emptyText,
      imageSize: 120,
    };
    return { ...defaultOptions, ...props.emptyOptions };
  });

  const tableColumns = computed(() => {
    return props.columns
      .map((column) => ({
        hidden: column.hidden ?? false,
        ...column,
      }))
      .filter((column) => !column.hidden);
  });

  const getGlobalIndex = (index: number) => {
    if (!props.pagination) return index + 1;
    const { currentPage = 1, pageSize = 10 } = props.pagination;

    return isNaN((currentPage - 1) * pageSize + index + 1)
      ? index + 1
      : (currentPage - 1) * pageSize + index + 1;
  };
  const handleSizeChange = (size: number) => {
    emits('pagination:size-change', size);
  };
  const handleCurrentChange = (current: number) => {
    emits('pagination:current-change', current);
    scrollToTop(); // 页码改变后滚动到表格顶部
  };
  const handlePaginationChange = (pageSize: number, currentPage: number) => {
    emits('pagination:change', pageSize, currentPage);
  };
  const handlePrevClick = (currentPage: number) => {
    emits('pagination:prev-click', currentPage);
  };
  const handleNextClick = (currentPage: number) => {
    emits('pagination:next-click', currentPage);
  };
  // 滚动表格内容到顶部
  const scrollToTop = () => {
    nextTick(() => {
      if (elTableRef.value) {
        elTableRef.value.setScrollTop(0);
      }
    });
  };

  defineExpose({
    elTableRef,
    paginationRef,
  });
</script>

<template>
  <div class="ld-table" :class="{ 'ld-table--empty': isEmpty }" :style="containerHeight">
    <el-table ref="elTableRef" v-loading="!!loading" v-bind="mergedTableProps">
      <template v-for="col in tableColumns">
        <!-- 渲染全局序号列 -->
        <el-table-column
          v-if="col.type === 'globalIndex'"
          v-bind="{ ...col }"
          :key="'globalIndex-' + (col.prop || col.type)"
          :fixed="col.fixed || 'left'"
          :align="col.align || 'center'"
          :header-align="col.headerAlign || 'center'"
        >
          <template #header="{ column, $index }">
            <slot :name="`${column.prop}-header`" :column="column" :index="$index">
              <span>{{ col.label || '序号' }}</span>
            </slot>
          </template>
          <template #default="{ row, column, $index }">
            <slot :name="`${column.prop}`" :row="row" :column="column" :index="$index">
              <span>{{ getGlobalIndex($index) }}</span>
            </slot>
          </template>
        </el-table-column>

        <!-- 渲染列 -->
        <ld-table-column v-else :key="col.prop || col.label" :column="col">
          <template v-for="(_, slotName) in slots" #[slotName]="slotProps">
            <slot :name="slotName" v-bind="slotProps" />
          </template>
        </ld-table-column>
      </template>
      <template v-if="slots['default']" #default>
        <slot></slot>
      </template>
      <template #empty>
        <slot name="tableEmpty">
          <div v-if="loading"></div>
          <el-empty v-else v-bind="mergedEmptyOptions">
            <template v-if="slots['tableEmptyDescription']" #description>
              <slot name="tableEmptyDescription"></slot>
            </template>
            <template v-if="slots['tableEmptyImage']" #image>
              <slot name="tableEmptyImage"></slot>
            </template>
            <template v-if="slots['tableEmptyDefault']" #default>
              <slot name="tableEmptyDefault"></slot>
            </template>
          </el-empty>
        </slot>
      </template>
      <template #append>
        <slot name="tableAppend"></slot>
      </template>
    </el-table>

    <div
      v-if="showPagination"
      ref="paginationRef"
      class="ld-table__pagination ld-table__pagination--custom"
      :class="'ld-table__pagination--' + mergedPaginationOptions.align"
    >
      <el-pagination
        v-bind="mergedPaginationOptions"
        :total="pagination?.total"
        :disabled="loading"
        :page-size="pagination?.pageSize"
        :current-page="pagination?.currentPage"
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
        @change="handlePaginationChange"
        @prev-click="handlePrevClick"
        @next-click="handleNextClick"
      >
        <template v-if="slots['tablePaginationDefault']" #default>
          <slot name="tablePaginationDefault"></slot>
        </template>
      </el-pagination>
    </div>
  </div>
</template>

<style lang="scss" scoped>
  .ld-table {
    width: 100%;
    position: relative;
    > * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    .el-table {
      width: 100%;
    }

    .ld-table__pagination {
      margin-top: 13px;
      &.ld-table__pagination--custom {
        display: flex;
        align-items: center;
        flex-wrap: wrap;
        gap: 10px;
      }
      &.ld-table__pagination--left {
        justify-content: flex-start;
      }

      &.ld-table__pagination--center {
        justify-content: center;
      }

      &.ld-table__pagination--right {
        justify-content: flex-end;
      }
    }
  }
</style>

类型注解

import type { EmptyProps, PaginationProps, TableColumnCtx, TableProps } from 'element-plus';
import type { VNode } from 'vue';

export interface LdTableEmits {
  (e: 'pagination:current-change', current: number): void;
  (e: 'pagination:size-change', size: number): void;
  (e: 'pagination:change', pageSize: number, currentPage: number): void;
  (e: 'pagination:prev-click', currentPage: number): void;
  (e: 'pagination:next-click', currentPage: number): void;
}

export interface LdTableSlots {
  default: (scope: unknown) => VNode[];
  tablePaginationDefault: (scope: unknown) => VNode[];
  tableEmpty: (scope: unknown) => VNode[];
  tableEmptyDefault: (scope: unknown) => VNode[];
  tableEmptyDescription: (scope: unknown) => VNode[];
  tableEmptyImage: (scope: unknown) => VNode[];
  tableAppend: (scope: unknown) => VNode[];
  [key: string]: (scope: unknown) => VNode[];
}

export interface LdTablePaginationConfig {
  // 总页数
  total: number;
  // 当前页码
  currentPage: number;
  // 每页显示条数
  pageSize: number;
}

// 表格列配置接口
export interface ColumnOption<T = unknown> {
  // 列类型
  type?: 'selection' | 'expand' | 'index' | 'globalIndex';
  // 列属性名
  prop?: string;
  // 列标题
  label?: string;
  // 列宽度
  width?: string | number;
  // 最小列宽度
  minWidth?: string | number;
  // 固定列
  fixed?: boolean | 'left' | 'right';
  // 是否可排序
  sortable?: boolean;
  // 过滤器选项
  filters?: unknown[];
  // 过滤方法
  filterMethod?: (value: unknown, row: T) => boolean;
  // 过滤器位置
  filterPlacement?: string;
  // 是否禁用
  disabled?: boolean;
  // 是否显示列
  visible?: boolean;
  // 是否选中显示
  checked?: boolean;
  // 自定义渲染函数
  formatter?: (row: T) => unknown;
  // 插槽相关配置
  // 是否使用插槽渲染内容
  useSlot?: boolean;
  // 插槽名称(默认为 prop 值)
  slotName?: string;
  // 是否使用表头插槽
  useHeaderSlot?: boolean;
  // 表头插槽名称(默认为 `${prop}-header`)
  headerSlotName?: string;
  // 其他属性
  [key: string]: unknown;
}

export type TableSize = 'large' | 'default' | 'small';

export interface LdTableColumnProps extends Partial<TableColumnCtx<Record<string, unknown>>> {
  prop?: string;
  label?: string;
  type?: string;
  hidden?: boolean;
  fixed?: 'left' | 'right' | boolean;
  align?: 'left' | 'center' | 'right';
  headerAlign?: 'left' | 'center' | 'right';
}

export interface LdTableConfig {
  columns: LdTableColumnProps[];
  loading?: boolean;
  pagination?: LdTablePaginationConfig;
  paginationOptions?: PaginationProps;
  emptyHeight?: string | number;
  emptyOptions?: EmptyProps;
  fit?: boolean;
  showHeader?: boolean;
  stripe?: boolean;
  border?: boolean;
  size?: TableSize;
  height?: string | number;
  emptyText?: string;
}

export type LdTableProps = LdTableConfig & Partial<TableProps<Record<string, unknown>>>;
export type LdTablePaginationProps = PaginationProps & {
  align?: 'left' | 'center' | 'right';
};

工具 hooks

import {
  computed,
  toValue,
  watch,
  getCurrentScope,
  onScopeDispose,
  type MaybeRefOrGetter,
} from 'vue';

function unrefElement(
  el: MaybeRef<HTMLElement | SVGElement | ComponentPublicInstance | undefined | null>,
) {
  const _el = toValue(el);
  return (_el as ComponentPublicInstance)?.$el ?? _el;
}

export const useResizeObserver = (
  target:
    | MaybeRefOrGetter<HTMLElement | SVGElement | ComponentPublicInstance | null | undefined>
    | MaybeRefOrGetter<HTMLElement | SVGElement | ComponentPublicInstance | null | undefined>[],
  callback: ResizeObserverCallback,
  options: ResizeObserverOptions = {},
) => {
  let observer: ResizeObserver | undefined;
  const isSupported = computed(() => window && 'ResizeObserver' in window);

  const cleanup = () => {
    if (observer) {
      observer.disconnect();
      observer = undefined;
    }
  };

  const targets = computed(() => {
    const _targets = toValue(target);
    return Array.isArray(_targets)
      ? _targets.map((el) => unrefElement(el as ComponentPublicInstance))
      : [unrefElement(_targets)];
  });

  const stopWatch = watch(
    targets,
    (els) => {
      cleanup();
      if (isSupported.value && window) {
        observer = new ResizeObserver(callback);
        for (const _el of els) {
          if (_el) observer.observe(_el, options);
        }
      }
    },
    { immediate: true, flush: 'post' },
  );

  const stop = () => {
    cleanup();
    stopWatch();
  };

  if (getCurrentScope()) {
    onScopeDispose(stop);
  }

  return {
    isSupported,
    stop,
  };
};

FAQ

  1. 当使用 pagination 功能时,需要传入正确的 currentsizetotal
  2. 当使用 globalIndex 类型的列时,会自动计算全局序号,不受分页影响
  3. 当表格数据为空且非加载状态时,会显示空状态组件
  4. TODO:render 函数来定义列渲染

感谢阅读,敬请斧正!