如何基于el-table封装一个方便好用的table组件

本篇博客属于随手一写,建议主要看模板和类型定义那块的实现思路。另外代码中会有部分不规范的地方,只优化了一部分,比如在template里写了些逻辑代码,类型有些用了any等等,最近总感觉自己欠了很多技术债没还,但是周末又不想去还(/doge)。
后面会出一期合辑,分享一下从零开始搭建组件库的过程,包含从组件写法到打包到发布npm私服的全过程。中间会穿插一些重点比如组件国际化兼容、打包、如何按需引入等等等等,这次是认真的,一定要更出来!

之前听到很多人讲在用 element-ui 的 table 组件时,还是自己循环 el-table-column 去实现的!每个页面都这么写想想就头大,另外万一产品要求你项目中所有的表格都要扩展一个功能,比如说按shift,可以实现数据行多选?那岂不是当场傻眼!

我们完全可以基于el-table去封装一个组件,基于配置自动生成表格。

配置都提供哪些属性呢?element-ui 每个组件都提供了一份 types文件,每个组件支持的属性参数,我们都可以二次封装后暴露给使用者。没错就是用到了 ts 的继承和 Partial 关键字,好奇的可以直接去看类型文件那部分。

所以以此作为基础,我们完全不用担心自己二次封装会导致某些属性或者事件覆盖不到位,需要什么功能的时候,再往这个组件里加对应的属性或者 emit 相应的事件就好了。

对于这里属性覆盖不到位而且还需要通过prop一个一个把属性配置传进来的情况,感觉评论区老哥的提示,我们可以采用透传的方式,参考 掘金的这篇文章 如何实现组件属性透传?

简单的讲下实现思路: 上图是el-table官网提供的用法示例,首先看到这么多重复的 el-table-column 标签,我下意识的反应就是用循环替换重复的代码,对于标签上的属性不同怎么解决呢?

我们可以提供一个对象数组形如 [{ prop: 'date', label: '日期', minWidth: '180', }],在对象内配置prop、 label 、width属性。然后循环这个对象数组即可。同理 el-table-column 提供的所有可配置属性我们都可以配置在对象里。

对于el-table的配置项我们也可以在data里提供一个对象,放这些配置项。

那么问题来了,el-table和el-table-column有这么多属性呢,我正常在标签上写的时候还有个代码提示,现在转到配置项里了,我还要去扒文档多麻烦啊!这时候就要用到ts的类型继承和Partial关键字了。

上面就说过了饿了么ui给每个组件都提供了一份类型声明文件

我们完全可以自定义一个类型,去继承饿了么提供的类型文件,从而依靠ts提供代码提示, 但当你兴致冲冲的去用extends 继承了 ElTable,用的时候会发现报错,因为在饿了么提供的类型声明里,所有属性都是必填的,而我们在提供配置信息的时候,显然不可能把每个属性都配置出来,一般只会配置我们需要的。

此时Partial就闪亮登场了,Partial的意思就是部分的,使用方式可以去ts官方文档去看,当你使用 Partial<ElTable>后,就产生了一个新的类型,这个新的类型包含了所有ElTable的属性,但所有属性就变成了可选的,所以上述的问题就完美解决了。我们直接去继承 Partial<ElTable>就好了。还有一些骚操作可以去看下面代码中类型定义部分。

支持的功能

目前支持的功能有:

  • 支持原生el-table的所有功能(本文中展示的代码,或许会有漏的属性或事件,因为我只加了本项目用到的那些)

  • 高度可扩展(可在el-table的基础上扩展更多自定义功能)

  • 根据传入的配置文件自动生成table并渲染数据

  • 所有的配置项支持代码和类型提示

  • 支持传入实际的业务数据类型,在配置列字段时会根据传入的ts类型做校验,校验列标识配置是否错误(基于泛型实现)

  • 会根据配置的列标识为每个单元格和表头生成插槽

  • 支持传入配置在行尾生成操作按钮

  • 支持高度自适应 、 行单选

  • 支持按shift实现任意行多选

  • 等等一些其他的小功能

本文中的代码使用了vue-class-component + ts的写法,没用过的同学看起来可能不太习惯,但主要是模板那块儿和类型定义那块的实现思路。

简单用法

看个效果就好了

html 复制代码
  <os-table
      :tableOption="tableOption"
      :tableColumnOption="columnOptions"
      :rowOperationOption="rowOperationOptions"
    ></os-table>
typescript 复制代码
import { Component, Vue } from 'vue-property-decorator';
import { OsTableColumn, OsTableOption, RowOperation } from 'osui/types/os-table';
import { OperationOption } from 'osui/types/os-table-operation';
import { OsQueryItemOption } from 'osui/types/os-table-query';
@Component({})
export default class Table extends Vue {
  // 仅为实例,所以没有为{ name: string; age: number; gender: 1 | 0; height: number }专门创建一个类型
  public tableOption: OsTableOption<{ name: string; age: number; gender: 1 | 0; height: number }> = {
    loading: false,
    data: [
      {
        name: '张三',
        age: 18,
        gender: 1,
        height: 175,
      },
      {
        name: '李四',
        age: 28,
        gender: 1,
        height: 175,
      },
    ],
    fit: true,
  };

  /**
   * 默认的表格列配置
   * label传入的是国际化的key,没用过i18n的就把tableExample.age理解成实际传入的是字符串 "年龄" 就好了
   */
  public columnOptions: Array<OsTableColumn<{ name: string; age: number; gender: 1 | 0; height: number }>> = [
    {
      prop: 'name',
      label: 'tableExample.name',
      minWidth: '120px',
      showOverflowTooltip: true,
    },
    {
      prop: 'age',
      label: 'tableExample.age',
      minWidth: '120px',
    },
    {
      prop: 'gender',
      label: 'tableExample.gender',
      minWidth: '120px',
    },
    {
      prop: 'height',
      label: 'tableExample.height',
      minWidth: '120px',
    },
  ];

  /**
   * table行的操作配置
   */
  public rowOperationOptions: RowOperation<{ name: string; age: number; gender: 1 | 0; height: number }> = {
    fixed: 'right',
    width: '280px',
    operations: [
      {
        operationType: 'delete',
        type: 'text',
        label: 'tableExample.delete',
        icon: 'el-icon-delete',
        permissionCode: 'delete',
        handleClick: (item): void => {
          console.log(item);
        },
      },
    ],
  };
}

下面这个例子包含了单元格和表头自定义,行多选、列随意拖拉、合计列等等功能

模板文件

html 复制代码
<template>
  <div class="os-table">
    <el-table
      ref="osTable"
      :key="tableKey"
      v-loading="tableOption.loading"
      :element-loading-text="t(getLoadingText)"
      :element-loading-spinner="getLoadingSpinner"
      :element-loading-background="getLoadingBackground"
      :data="tableOption.data || []"
      :fit="tableOption.fit"
      :height="tableOption.height"
      :max-height="tableOption.closeAdaptiveHeight ? undefined : maxHeight"
      :border="border"
      :row-key="tableOption.rowKey ? tableOption.rowKey(tableColumnOption) : 'id'"
      :default-sort="tableOption.defaultSort"
      :highlight-current-row="tableOption.highlightCurrentRow"
      :header-cell-class-name="tableOption.headerCellClassName || 'default-header-style'"
      :cell-class-name="tableOption.cellClassName"
      :size="tableOption.size || 'mini'"
      :class="border ? 'no-border' : ''"
      :empty-text="tableOption.emptyText"
      :show-summary="tableOption.showSummary"
      :summary-method="tableOption.summaryMethod ? tableOption.summaryMethod : getSummaries"
      @selection-change="handleSelectionChange"
      @select="shiftMultiple"
      @cell-click="handleCellClick"
      @sort-change="handleSortChange"
    >
      <template slot="empty">
        <el-empty :image-size="200" :description="emptyText"></el-empty>
      </template>
      <el-table-column v-if="tableOption.radioSelection" :label="t('OSUI.table.select')" width="65">
        <template v-slot="scope">
          <el-radio v-model="radioSelected" :label="scope.row"><i></i></el-radio>
        </template>
      </el-table-column>
      <template v-for="item in tableColumnOption">
        <el-table-column
          :key="item.prop"
          :type="item.type"
          :reserve-selection="item.reserveSelection"
          :selectable="item.selectable"
          :column-key="item.prop"
          :prop="item.prop"
          :label="t(item.label)"
          :width="item.width"
          :min-width="item.minWidth"
          :fixed="item.fixed"
          :sortable="item.sortable"
          :formatter="item.formatter"
          :showOverflowTooltip="item.showOverflowTooltip"
          :align="item.align || 'left'"
          :header-align="item.headerAlign || 'left'"
          :class-name="item.className"
        >
          <template v-if="renderSlot(item)" v-slot="scope">
            <slot :name="item.prop" v-bind="scope">
              {{ emptyFormatter(scope.row[item.prop]) }}
            </slot>
          </template>
          <template v-if="renderSlot(item)" v-slot:header>
            <slot :name="`${item.prop}-header`" v-bind="item">
              {{ t(item.label) }}
            </slot>
          </template>
        </el-table-column>
      </template>
      <el-table-column
        v-if="rowOperationOption"
        :fixed="rowOperationOption.fixed || 'right'"
        :label="rowOperationOption.label || t('OSUI.table.operation')"
        :width="rowOperationOption.width || 180"
      >
        <template v-slot="scope">
          <template v-for="item in rowOperationOption.operations">
            <el-button
              v-if="!item.dynamicHidden || !item.dynamicHidden(scope.row)"
              :key="item.operationType"
              v-permissionOperation="item.permissionCode"
              :type="item.type"
              :size="item.size || 'mini'"
              :icon="item.icon"
              @click="item.handleClick(scope.row, item)"
              >{{ item.label ? t(item.label) : '' }}</el-button
            >
          </template>
          <slot name="operation" v-bind="scope"> </slot>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

类型文件

typescript 复制代码
import { ElButton } from 'element-ui/types/button';
import { LoadingServiceOptions } from 'element-ui/types/loading';
import { ElTableColumn, TableColumnFixedType } from 'element-ui/types/table-column';
import { ElTable, SortOrder } from 'element-ui/types/table';
export interface SummaryPropsOptions<T> {
  /**
   * 要进行合计的属性
   */
  prop: keyof T;
  /**
   * 结果保留几位小数
   */
  fixPlace?: number;
  /**
   * 单位
   */
  unit?: string;
}

export type OsTableColumn<T> = Partial<ElTableColumn> & {
  /**
   * 要绑定的data内字段名
   */
  prop: keyof T;
  label: string;
  /**
   * 数据为空时,默认展示的内容
   */
  emptyValue?: string;
  /**
   * 该列是否隐藏
   */
  hide?: boolean;
};

/**
 * 操作配置
 */
export interface Operation<T> extends Partial<ElButton> {
  /**
   * 操作类型,用于标识按钮的唯一性
   */
  operationType: 'add' | 'edit' | 'delete' | 'detail' | string;
  /**
   * 按钮显示的文本
   * 请保证按钮文本和图标至少有一项
   */
  label?: string;
  /**
   * 按钮的图标
   */
  icon?: string;
  /**
   * 是否动态隐藏该操作按钮,不传则默认显示按钮
   * 返回true则隐藏
   */
  dynamicHidden?: (rowData: T) => boolean;
  handleClick: (roleData: T, operation: Operation<T>) => void;
  /**
   * 权限标识
   */
  permissionCode?: string;
}
/**
 * 行操作
 */
export interface RowOperation<T extends object> extends Partial<ElTableColumn> {
  /**
   * 同column的fixed,不传默认为 'right'
   */
  fixed?: boolean | TableColumnFixedType;
  /**
   * 同column的label,不传默认为 '操作'
   */
  label?: string;
  /**
   * 操作配置
   */
  operations: Array<Operation<T>>;
}

/**
 * 表格配置
 */
export interface OsTableOption<T extends object> extends Partial<Omit<ElTable, 'defaultSort'>> {
  data: Array<T>;
  /**
   * 加载数据时滚动
   */
  loading?: boolean;
  loadingOptions?: LoadingServiceOptions;
  /**
   * 开启表格单选
   */
  radioSelection?: boolean;
  /**
   * 表格距底部距离,为其他元素预留位置
   */
  bottomOffset?: number;
  size?: 'medium' | 'small' | 'mini';
  className?: string;
  /**
   * 是否关闭自适应高度
   */
  closeAdaptiveHeight?: boolean;
  sumPropsOptions?: Array<SummaryPropsOptions<T>>;
  /**
   * 默认排序
   * 覆盖el-table默认的defaultSort类型定义
   */
  defaultSort?: { prop: keyof T; order: SortOrder };
}

ts文件

typescript 复制代码
import { Component, Prop, Watch } from 'vue-property-decorator';
import { ElTableColumn, TableColumn } from 'element-ui/types/table-column';
import { DefaultSortOptions, ElTable } from 'element-ui/types/table';
import { debounce } from 'lodash-es';
import { mixins } from 'vue-class-component';
import { I18nMixin } from 'src/mixins/local';
import { OsTableColumn, OsTableOption, RowOperation } from 'osui/types/os-table';
import { permissionOperation } from 'src/directives/permission-operation';
@Component({
  name: 'os-table',
  directives: { permissionOperation },
})
export default class OsTable extends mixins(I18nMixin) {
  @Prop({
    required: true,
    type: Object,
  })
  public tableOption!: OsTableOption<object>;

  @Prop({
    required: true,
    type: Array,
  })
  public tableColumnOption!: Array<OsTableColumn<any>>;

  @Prop({
    type: Object,
    required: false,
  })
  public rowOperationOption!: RowOperation<any>;

  public radioSelected: object | null = null;

  private tableKey = true;

  private maxHeight = 200;

  private clientHeight = 0;

  private selectedRows: Array<object> = [];

  /**
   * 实现shift多选相关的参数
   */
  private shiftMultipleOpts = {
    /**
     * 当前shift按键是否被按下
     */
    keyDown: false,
    /**
     * 记录选中的起始行索引
     */
    startIndex: -1,
    allowEmit: true,
  };

  public get border(): boolean {
    return this.tableOption.border ?? true;
  }

  public get emptyText(): string | undefined {
    return this.tableOption.emptyText ? this.t(this.tableOption.emptyText as string).toString() : undefined;
  }

  public emptyFormatter(value: any): any {
    if (typeof value === 'number') {
      return value;
    }
    if (!value || value.length === 0) {
      return '--';
    }
    return value;
  }

  public mounted(): void {
    this.initKeyDownListener();
    if (this.tableOption.closeAdaptiveHeight) {
      return;
    }
    this.getAutoMaxHeight();
    window.onresize = debounce((): void => {
      this.clientHeight = document.documentElement.clientHeight;
    }, 100);
  }

  public destroyed(): void {
    window.onresize = null;
  }

  public handleSelectionChange(values: Array<object>): void {
    if (this.shiftMultipleOpts.allowEmit) {
      this.$emit('selection-change', values);
    }
  }

  public handleSortChange(sortOptions: DefaultSortOptions): void {
    this.$emit('sort-change', sortOptions);
  }

  public clearSelection(): void {
    (this.$refs.osTable as ElTable).clearSelection();
  }

  public doLayout(): void {
    (this.$refs.osTable as ElTable).doLayout();
  }

  public reload(): void {
    this.tableKey = !this.tableKey;
  }

  public get tableRef(): ElTable {
    return this.$refs.osTable as ElTable;
  }

  /**
   * 设置单选情况下的的行选中状态
   */
  public toggleRadioSelection(rowData: object): void {
    this.radioSelected = rowData;
  }

  public clearRadioSelection(): void {
    this.radioSelected = null;
  }

  public openLoading(): void {
    this.setLoading(true);
  }

  public closeLoading(): void {
    this.setLoading(false);
  }

  public handleCellClick(row: Object, column: ElTableColumn, cell: Object): void {
    this.$emit('cell-click', row, column, cell);
  }

  /**
   * 设置loading属性
   * @param value 状态
   */
  private setLoading(value: boolean): void {
    this.tableOption.loading = value;
  }

  @Watch('radioSelected')
  private handleRadioSelection(value: object): void {
    this.$emit('radio-selection-change', value);
  }

  private get getLoadingText(): string {
    return this.tableOption.loadingOptions?.text ?? 'OSUI.table.loadingText';
  }

  private get getLoadingSpinner(): string {
    return this.tableOption.loadingOptions?.spinner ?? 'el-icon-loading';
  }

  private get getLoadingBackground(): string {
    return this.tableOption.loadingOptions?.background ?? '';
  }

  private renderSlot(item: OsTableColumn<any>): boolean {
    return item.type !== 'selection' && item.type !== 'index' && !item.formatter;
  }

  private getAutoMaxHeight(): void {
    const el = (this.$refs.osTable as ElTable).$el;
    let bottomOffset = 20;
    if (this.tableOption.bottomOffset !== 0) {
      bottomOffset = this.tableOption.bottomOffset || 78;
    }
    this.$nextTick(() => {
      // 实测,第一次渲染页面时,获取到的视口位置的高度会有31px的误差(大了31px),不知道为什么,下一次执行周期,会恢复正常
      const topHeight = el.getBoundingClientRect().top;
      this.maxHeight = window.innerHeight - topHeight - bottomOffset;
    });
  }

  @Watch('clientHeight')
  private computedMaxHeight(): void {
    this.getAutoMaxHeight();
  }

  /**
   * 计算指定列的合计
   * @param param
   * @returns
   */
  private getSummaries(param: any): Array<string> {
    const { columns, data }: { columns: Array<TableColumn>; data: Array<any> } = param;
    if (data.length === 0) {
      return [];
    }
    const sums: Array<string> = [];
    const needSumProps: Array<string> = this.tableOption.sumPropsOptions?.map((x) => x.prop) || [];

    columns.forEach((column, index) => {
      if (index === 0) {
        sums[index] = this.t('OSUI.table.summaryText') as string;
        return;
      }
      if (!needSumProps.includes(column.property as any)) {
        sums[index] = '';
        return;
      }

      const values = data.map((item) => Number(item[column.property]));

      if (!values.every((value: any) => isNaN(value))) {
        const sumValue = values.reduce((prev, curr) => {
          const value = Number(curr);
          if (!isNaN(value)) {
            return prev + curr;
          } else {
            return prev;
          }
        }, 0);

        let sumRes = '';
        const { fixPlace, unit } = this.tableOption.sumPropsOptions!.find((x) => x.prop === column.property)!;
        sumRes = fixPlace ? sumValue?.toFixed(fixPlace) : sumValue.toString();
        sumRes = unit ? `${sumRes} ${unit}` : sumRes;

        sums[index] = sumRes;
      } else {
        sums[index] = 'N/A';
      }
    });
    return sums;
  }

  /**
   * 监听键盘按下、弹起事件
   */
  private initKeyDownListener(): void {
    window.addEventListener('keydown', (e) => {
      if (e.shiftKey) {
        this.shiftMultipleOpts.keyDown = true;
      }
    });
    window.addEventListener('keyup', () => {
      this.shiftMultipleOpts.keyDown = false;
    });
  }

  private shiftMultiple(selection: Array<object>, row: object): void {
    // table当前数据
    const data = (this.$refs.osTable as ElTable).data;
    // 最后选中行的索引
    const endIdx = (this.$refs.osTable as ElTable).data.findIndex((x) => x === row);
    if (this.shiftMultipleOpts.keyDown && selection.includes(data[this.shiftMultipleOpts.startIndex])) {
      this.shiftMultipleOpts.allowEmit = false;
      // 用户可能反向选取,所以要取绝对值
      const sum = Math.abs(this.shiftMultipleOpts.startIndex - endIdx) + 1;
      // 获取起点和终点较小的值
      const min = Math.min(this.shiftMultipleOpts.startIndex, endIdx);
      let i = 0;
      this.$nextTick(() => {
        while (i < sum) {
          const index = min + i;
          // 设置最后一个选中时,允许emit selection-change 事件,防止每次设置选中项都emit一次
          if (i === sum - 2) {
            this.shiftMultipleOpts.allowEmit = true;
          }
          if (!this.selectedRows.includes(data[index])) {
            (this.$refs.osTable as ElTable).toggleRowSelection(data[index], true);
          }
          i++;
        }
      });
    } else {
      // 记录最后一次仅点击的行索引
      this.shiftMultipleOpts.startIndex = endIdx;
    }
  }
}

scss文件

css 复制代码
::v-deep .default-header-style {
    background-color: #fafafa;
    color: rgba($color: #000000, $alpha: 0.85);
}

// 解决表格出现横向滚动条时,暂无数据不再居中的问题
::v-deep .el-table__empty-block {
    width: 100% !important;
}
相关推荐
ziyue757523 分钟前
vue修改element-ui的默认的class
前端·vue.js·ui
树叶会结冰44 分钟前
HTML语义化:当网页会说话
前端·html
冰万森1 小时前
解决 React 项目初始化(npx create-react-app)速度慢的 7 个实用方案
前端·react.js·前端框架
牧羊人_myr1 小时前
Ajax 技术详解
前端
浩男孩1 小时前
🍀封装个 Button 组件,使用 vitest 来测试一下
前端
蓝银草同学1 小时前
阿里 Iconfont 项目丢失?手把手教你将已引用的 SVG 图标下载到本地
前端·icon
李广坤1 小时前
状态模式(State Pattern)
设计模式
布列瑟农的星空1 小时前
重学React —— React事件机制 vs 浏览器事件机制
前端
一小池勺2 小时前
CommonJS
前端·面试