Vue封装el-table来说说composition api的好处

192 阅读5分钟

由于之前公司有个特殊,中台的代码有大部分是后端java来完成,这样就是考验组件是否更合理,el-table已经是优秀的组件,但是不免产品和UI有自己想法修改稍微调整,需要完成的功能

  • 第一列靠左固定,最后一列(操作)靠右固定,有默认固定宽度
  • table的单选,需要修改成el-radio
  • 和后端接口请求在一起,支持分页
  • 当表格数据大时候需要前端自己分页
  • 支持拖拽排序(7-22补充)

第一列靠左固定,最后一列(操作)靠右固定

这个相比最简单,如果不封装组件<el-table-column prop="date" label="Date" fixed width="123" />就是设置fixed属性和width属性, 如果封装的话拦截slots.default对于子节点拦截处理,具体代码如下

实现代码

function useChildren(slots: any) {
  const items = slots && slots.default ? slots.default() : [];
  return items.map((tableColumn: VNode, tableColumnIndex: number) => {
    let fixed: boolean | string = false;
    tableColumnIndex === 0 && (fixed = "left");
    tableColumnIndex === items.length - 1 && (fixed = "right");

    return cloneVNode(tableColumn, {
      fixed,
      minWidth: tableColumn?.props?.minWidth || 128,
    });
  });
}

实现效果

image.png

table的单选,需要修改成el-radio

先看看自带的效果

image.png

说2点自己的看法

  1. 看不出来是单选
  2. 选择后的事件和多选不一致

怎么去解决这个问题呢?在表格最左边添加栏目内容是el-radio, 内部记录radioId,当点击响应切换

我们先定义类型

export type Key = number | string;

export interface Data {
  id: Key;
  [key: string]: any;
}
export interface Scope {
  row: Data;
}

export interface RowSelection {
  type?: "checkbox" | "radio";
  selectedRowKeys?: Key[];
  onChange?: (selectedRowKeys: Key[], selectedRows: Data[]) => void;
}

实现代码

function useRadio(rowSelection: RowSelection, dataSource: Ref<Data[]>) {
  const radioId = ref(rowSelection.selectedRowKeys?.[0]);
  const onChange = () => {
    if (rowSelection.onChange) {
      const checkedIds = ref(radioId.value ? [radioId.value] : []);
      rowSelection.onChange(
        toRaw(checkedIds.value),
        dataSource.value.filter((x) => checkedIds.value.includes(x.id))
      );
    }
  };
  const handleRadioChange = (id: Key) => {
    radioId.value = id;
    onChange();
  };
  const node = rowSelection.type === "radio" && (
    <el-table-column width="50" prop="id" fixed>
      {(scope: Scope) => (
        <el-radio
          modelValue={radioId.value}
          label={scope.row.id}
          onChange={() => handleRadioChange(scope.row.id)}
        >
          {" "}
        </el-radio>
      )}
    </el-table-column>
  );
  return {
    node,
    radioId,
    handleRadioChange,
  };
}

实现效果

2023-07-17 06.37.09.gif

table的多选,都是同一属性RowSelection

刚才反馈的单选和多选操作属性太多,api太多,既然封装了,我们就把2个整合成一个

查看源码

function useCheckbox(rowSelection: RowSelection, dataSource: Ref<Data[]>) {
  const checkedIds = ref<Key[]>(rowSelection.selectedRowKeys || []);
  const isAllChecked = computed(() => {
    return dataSource.value.every((row) => checkedIds.value.includes(row.id));
  });
  const hasChecked = computed(() => {
    return dataSource.value.some((row) => checkedIds.value.includes(row.id));
  });
  const onChange = () => {
    if (rowSelection.onChange) {
      rowSelection.onChange(
        toRaw(checkedIds.value),
        dataSource.value.filter((x) => checkedIds.value.includes(x.id))
      );
    }
  };
  const handleCheckboxChange = (id: Key) => {
    const index = checkedIds.value.indexOf(id);
    if (index >= 0) {
      checkedIds.value.splice(index, 1);
    } else {
      checkedIds.value.push(id);
    }
    onChange();
  };
  const handleAllCheckboxChange = () => {
    const ids = dataSource.value.map((x) => x.id);

    if (isAllChecked.value) {
      checkedIds.value = checkedIds.value.filter((id) => !ids.includes(id));
    } else {
      // 当前页,不在checkedIds,去选中
      dataSource.value
        .filter((row) => !checkedIds.value.includes(row.id))
        .forEach((row) => {
          checkedIds.value.push(row.id);
        });
    }
    onChange();
  };
  const node = rowSelection.type === "checkbox" && (
    <el-table-column width="50" prop="id" fixed>
      {{
        header: () => (
          <el-checkbox
            model-value={isAllChecked.value}
            indeterminate={isAllChecked.value ? false : hasChecked.value}
            onChange={handleAllCheckboxChange}
          />
        ),
        default: (scope: Scope) => (
          <el-checkbox
            model-value={checkedIds.value.includes(scope.row.id)}
            onChange={() => handleCheckboxChange(scope.row.id)}
          />
        ),
      }}
    </el-table-column>
  );
  return {
    node,
    checkedIds,
    isAllChecked,
    hasChecked,
    handleCheckboxChange,
    handleAllCheckboxChange,
  };
}

查看效果

2023-07-17 06.41.04.gif

和后端接口请求在一起,支持分页

大部分我们的表格都是和分页结合在一起,el-pagination的属性基本上已经满足我们日常需求,我们之需要把2者整合在一起,同样的为了简单原则,我们先定义

export interface Pagination {
  total?: number; // 后端分页,只需要传递这个
  pageSize?: number; // 每页展示数量,默认10
  virtual?: boolean; // 前端分页,设置成true
  onSizeChange?: (pageSize: number) => void;
  onCurrentChange?: (currentPage: number) => void;
}

具体代码如下

function usePagination(pagination: Pagination, data: Data[]) {
  const basePageSize = pagination.pageSize || 10;
  const pageSizes = [
    basePageSize,
    basePageSize * 2,
    basePageSize * 3,
    basePageSize * 5,
  ];
  const currentPage = ref(1);
  const pageSize = ref(basePageSize);
  const handleSizeChange = (ps: number) => {
    currentPage.value = 1;
    pageSize.value = ps;
    pagination?.onSizeChange && pagination?.onSizeChange(ps);
  };
  const handleCurrentChange = (cp: number) => {
    currentPage.value = cp;
    pagination?.onCurrentChange && pagination?.onCurrentChange(cp);
  };
  const showPagination =
    pagination.virtual || (pagination?.total as number) > 0;
  const node = showPagination && (
    <el-pagination
      pageSize={basePageSize}
      pageSizes={pageSizes}
      total={pagination.virtual ? data.length : pagination?.total}
      background
      layout="total, sizes, prev, pager, next, jumper"
      onSizeChange={(val: number) => handleSizeChange(val)}
      onCurrentChange={(val: number) => handleCurrentChange(val)}
      style="display: flex;justify-content: flex-end;margin-top: 12px;"
    />
  );
  const dataSource = computed(() => {
    if (!pagination.virtual) {
      return data;
    }

    const val = (currentPage.value - 1) * pageSize.value;

    return data.slice(val, val + pageSize.value);
  });

  return {
    node,
    dataSource,
    pageSizes,
    handleSizeChange,
  };
}

查看效果

2023-07-17 06.52.24.gif

整体代码

import {
  defineComponent,
  cloneVNode,
  ref,
  toRaw,
  computed,
  PropType,
  VNode,
  Ref,
} from "vue";
import "./index.css";

export type Key = number | string;

export interface Data {
  id: Key;
  [key: string]: any;
}
export interface Scope {
  row: Data;
}

export interface RowSelection {
  type?: "checkbox" | "radio";
  selectedRowKeys?: Key[];
  onChange?: (selectedRowKeys: Key[], selectedRows: Data[]) => void;
}

export interface Pagination {
  total?: number;
  pageSize?: number;
  virtual?: boolean;
  onSizeChange?: (pageSize: number) => void;
  onCurrentChange?: (currentPage: number) => void;
}

function useChildren(slots: any) {
  const items = slots && slots.default ? slots.default() : [];
  return items.map((tableColumn: VNode, tableColumnIndex: number) => {
    let fixed: boolean | string = false;
    tableColumnIndex === 0 && (fixed = "left");
    tableColumnIndex === items.length - 1 && (fixed = "right");

    return cloneVNode(tableColumn, {
      fixed,
      minWidth: tableColumn?.props?.minWidth || 128,
    });
  });
}

function useCheckbox(rowSelection: RowSelection, dataSource: Ref<Data[]>) {
  const checkedIds = ref<Key[]>(rowSelection.selectedRowKeys || []);
  const isAllChecked = computed(() => {
    return dataSource.value.every((row) => checkedIds.value.includes(row.id));
  });
  const hasChecked = computed(() => {
    return dataSource.value.some((row) => checkedIds.value.includes(row.id));
  });
  const onChange = () => {
    if (rowSelection.onChange) {
      rowSelection.onChange(
        toRaw(checkedIds.value),
        dataSource.value.filter((x) => checkedIds.value.includes(x.id))
      );
    }
  };
  const handleCheckboxChange = (id: Key) => {
    const index = checkedIds.value.indexOf(id);
    if (index >= 0) {
      checkedIds.value.splice(index, 1);
    } else {
      checkedIds.value.push(id);
    }
    onChange();
  };
  const handleAllCheckboxChange = () => {
    const ids = dataSource.value.map((x) => x.id);

    if (isAllChecked.value) {
      checkedIds.value = checkedIds.value.filter((id) => !ids.includes(id));
    } else {
      // 当前页,不在checkedIds,去选中
      dataSource.value
        .filter((row) => !checkedIds.value.includes(row.id))
        .forEach((row) => {
          checkedIds.value.push(row.id);
        });
    }
    onChange();
  };
  const node = rowSelection.type === "checkbox" && (
    <el-table-column width="50" prop="id" fixed>
      {{
        header: () => (
          <el-checkbox
            model-value={isAllChecked.value}
            indeterminate={isAllChecked.value ? false : hasChecked.value}
            onChange={handleAllCheckboxChange}
          />
        ),
        default: (scope: Scope) => (
          <el-checkbox
            model-value={checkedIds.value.includes(scope.row.id)}
            onChange={() => handleCheckboxChange(scope.row.id)}
          />
        ),
      }}
    </el-table-column>
  );
  return {
    node,
    checkedIds,
    isAllChecked,
    hasChecked,
    handleCheckboxChange,
    handleAllCheckboxChange,
  };
}

function useRadio(rowSelection: RowSelection, dataSource: Ref<Data[]>) {
  const radioId = ref(rowSelection.selectedRowKeys?.[0]);
  const onChange = () => {
    if (rowSelection.onChange) {
      const checkedIds = ref(radioId.value ? [radioId.value] : []);
      rowSelection.onChange(
        toRaw(checkedIds.value),
        dataSource.value.filter((x) => checkedIds.value.includes(x.id))
      );
    }
  };
  const handleRadioChange = (id: Key) => {
    radioId.value = id;
    onChange();
  };
  const node = rowSelection.type === "radio" && (
    <el-table-column width="50" prop="id" fixed>
      {(scope: Scope) => (
        <el-radio
          modelValue={radioId.value}
          label={scope.row.id}
          onChange={() => handleRadioChange(scope.row.id)}
        >
          {" "}
        </el-radio>
      )}
    </el-table-column>
  );
  return {
    node,
    radioId,
    handleRadioChange,
  };
}

function usePagination(pagination: Pagination, data: Data[]) {
  const basePageSize = pagination.pageSize || 10;
  const pageSizes = [
    basePageSize,
    basePageSize * 2,
    basePageSize * 3,
    basePageSize * 5,
  ];
  const currentPage = ref(1);
  const pageSize = ref(basePageSize);
  const handleSizeChange = (ps: number) => {
    currentPage.value = 1;
    pageSize.value = ps;
    pagination?.onSizeChange && pagination?.onSizeChange(ps);
  };
  const handleCurrentChange = (cp: number) => {
    currentPage.value = cp;
    pagination?.onCurrentChange && pagination?.onCurrentChange(cp);
  };
  const showPagination =
    pagination.virtual || (pagination?.total as number) > 0;
  const node = showPagination && (
    <el-pagination
      pageSize={basePageSize}
      pageSizes={pageSizes}
      total={pagination.virtual ? data.length : pagination?.total}
      background
      layout="total, sizes, prev, pager, next, jumper"
      onSizeChange={(val: number) => handleSizeChange(val)}
      onCurrentChange={(val: number) => handleCurrentChange(val)}
      style="display: flex;justify-content: flex-end;margin-top: 12px;"
    />
  );
  const dataSource = computed(() => {
    if (!pagination.virtual) {
      return data;
    }

    const val = (currentPage.value - 1) * pageSize.value;

    return data.slice(val, val + pageSize.value);
  });

  return {
    node,
    dataSource,
    pageSizes,
    handleSizeChange,
  };
}

export default defineComponent({
  props: {
    data: {
      type: Array as PropType<Data[]>,
      default: () => [],
    },
    rowSelection: {
      type: Object as PropType<RowSelection>,
      default: () => ({
        selectedRowKeys: [],
      }),
    },
    pagination: {
      type: Object as PropType<Pagination>,
      default: () => ({
        pageSize: 10,
        virtual: false,
      }),
    },
  },
  setup(props, { slots }) {
    const children = useChildren(slots);
    const { node: paginationNode, dataSource } = usePagination(
      props.pagination,
      props.data
    );
    const { node: checkboxNode } = useCheckbox(props.rowSelection, dataSource);
    const { node: radioNode } = useRadio(props.rowSelection, dataSource);
    return () => (
      <>
        <el-table data={dataSource.value}>
          {checkboxNode}
          {radioNode}
          {children}
        </el-table>
        {paginationNode}
      </>
    );
  },
});

调用代码

<script lang="ts" setup>
import ElTable2 from "./components/el-table2";
import type { Key } from "./components/el-table2";

const tableData = [
  {
    id: 1,
    date: "2016-05-03",
    name: "Tom",
    state: "California",
    city: "Los Angeles",
    address: "No. 189, Grove St, Los Angeles",
    zip: "CA 90036",
    tag: "Home",
    status: 0,
  },
  {
    id: 2,
    date: "2016-05-02",
    name: "Tom",
    state: "California",
    city: "Los Angeles",
    address: "No. 189, Grove St, Los Angeles",
    zip: "CA 90036",
    tag: "Office",
    status: 1,
  },
  {
    id: 3,
    date: "2016-05-04",
    name: "Tom",
    state: "California",
    city: "Los Angeles",
    address: "No. 189, Grove St, Los Angeles",
    zip: "CA 90036",
    tag: "Home",
    status: 2,
  },
  {
    id: 4,
    date: "2016-05-01",
    name: "Tom",
    state: "California",
    city: "Los Angeles",
    address: "No. 189, Grove St, Los Angeles",
    zip: "CA 90036",
    tag: "Office",
    status: 0,
  },
];
const handleClick = () => {
  console.log("click");
};
const handleRowSelect = (selectedRowKeys: Key[]) => {
  console.log("selectedRowKeys", selectedRowKeys);
};
const handlePaginationSizeChange = (val: number) => {
  console.log("pageSize", val);
};
const handlePaginationCurrentChange = (val: number) => {
  console.log("currentPage", val);
};
</script>

<template>
  <h2>普通表格(左边、右边固定、固定最小宽度)</h2>
  <el-table2 :data="tableData">
    <el-table-column prop="date" label="Date" />
    <el-table-column prop="name" label="Name" />
    <el-table-column prop="name" label="Name" />
    <el-table-column prop="state" label="State" />
    <el-table-column prop="city" label="City" />
    <el-table-column prop="address" label="Address" width="600" />
    <el-table-column prop="zip" label="Zip" />
    <el-table-column label="Operations">
      <template #default="{ row }">
        <el-button link type="primary" size="small" @click="handleClick">
          编辑
        </el-button>
        <el-button v-if="row.status === 2" link type="primary" size="small">
          删除
        </el-button>
      </template>
    </el-table-column>
  </el-table2>
  <h2>多选</h2>
  <el-table2
    :data="tableData"
    :row-selection="{
      type: 'checkbox',
      selectedRowKeys: [1, 2],
      onChange: handleRowSelect,
    }"
  >
    <el-table-column prop="date" label="Date" />
    <el-table-column prop="name" label="Name" />
    <el-table-column prop="name" label="Name" />
    <el-table-column prop="state" label="State" />
    <el-table-column prop="city" label="City" />
    <el-table-column prop="address" label="Address" width="600" />
    <el-table-column prop="zip" label="Zip" />
    <el-table-column label="Operations">
      <template #default="{ row }">
        <el-button link type="primary" size="small" @click="handleClick">
          编辑
        </el-button>
        <el-button v-if="row.status === 2" link type="primary" size="small">
          删除
        </el-button>
      </template>
    </el-table-column>
  </el-table2>
  <h2>单选</h2>
  <el-table2
    :data="tableData"
    :row-selection="{
      type: 'radio',
      selectedRowKeys: [],
      onChange: handleRowSelect,
    }"
  >
    <el-table-column prop="date" label="Date" />
    <el-table-column prop="name" label="Name" />
    <el-table-column prop="name" label="Name" />
    <el-table-column prop="state" label="State" />
    <el-table-column prop="city" label="City" />
    <el-table-column prop="address" label="Address" width="600" />
    <el-table-column prop="zip" label="Zip" />
    <el-table-column label="Operations">
      <template #default="{ row }">
        <el-button link type="primary" size="small" @click="handleClick">
          编辑
        </el-button>
        <el-button v-if="row.status === 2" link type="primary" size="small">
          删除
        </el-button>
      </template>
    </el-table-column>
  </el-table2>
  <h2>带分页</h2>
  <el-table2
    :data="tableData"
    :pagination="{
      total: 100,
      onSizeChange: handlePaginationSizeChange,
      onCurrentChange: handlePaginationCurrentChange,
    }"
  >
    <el-table-column prop="date" label="Date" />
    <el-table-column prop="name" label="Name" />
    <el-table-column prop="name" label="Name" />
    <el-table-column prop="state" label="State" />
    <el-table-column prop="city" label="City" />
    <el-table-column prop="address" label="Address" width="600" />
    <el-table-column prop="zip" label="Zip" />
    <el-table-column label="Operations">
      <template #default="{ row }">
        <el-button link type="primary" size="small" @click="handleClick">
          编辑
        </el-button>
        <el-button v-if="row.status === 2" link type="primary" size="small">
          删除
        </el-button>
      </template>
    </el-table-column>
  </el-table2>
  <h2>前端分页</h2>
  <el-table2
    :data="tableData"
    :pagination="{
      virtual: true,
      pageSize: 2,
      onSizeChange: handlePaginationSizeChange,
      onCurrentChange: handlePaginationCurrentChange,
    }"
  >
    <el-table-column prop="date" label="Date" />
    <el-table-column prop="name" label="Name" />
    <el-table-column prop="name" label="Name" />
    <el-table-column prop="state" label="State" />
    <el-table-column prop="city" label="City" />
    <el-table-column prop="address" label="Address" width="600" />
    <el-table-column prop="zip" label="Zip" />
    <el-table-column label="Operations">
      <template #default="{ row }">
        <el-button link type="primary" size="small" @click="handleClick">
          编辑
        </el-button>
        <el-button v-if="row.status === 2" link type="primary" size="small">
          删除
        </el-button>
      </template>
    </el-table-column>
  </el-table2>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  color: #2c3e50;
}
</style>

拖拽排序

日常表格作为中台列表最重要的组件,也难免少不了拖拽排序功能,这里我们使用sortablejs组件

查看代码

export interface Drag {
  oldIndex: number;
  newIndex: number;
  dataSource: Data[];
}

export interface Sortable {
  drag?: boolean;
  onDrag?: (drag: Drag) => void;
}

function useSortable(
  sortable: Sortable,
  elTable: Ref,
  dataSource: Ref<Data[]>
) {
  const drag = sortable.drag;
  const onDrag = sortable.onDrag;

  drag &&
    onMounted(() => {
      const tbody = elTable?.value?.querySelector(
        ".el-table__body-wrapper tbody"
      );
      const sortable =
        tbody &&
        Sortablejs.create(tbody, {
          handle: ".sortable-handle", // Restricts sort start click/touch to the specified element
          ghostClass: "sortable-ghost", // Class name for the drop placeholder,
          setData: function (dataTransfer: any) {
            // to avoid Firefox bug
            // Detail see : https://github.com/RubaXa/Sortable/issues/1012
            dataTransfer.setData("Text", "");
          },
          onEnd: (evt: any) => {
            const targetRow = dataSource.value.splice(evt.oldIndex, 1)[0];
            dataSource.value.splice(evt.newIndex, 0, targetRow);

            onDrag &&
              onDrag({
                oldIndex: evt.oldIndex,
                newIndex: evt.newIndex,
                dataSource: dataSource.value,
              });

            // for show the changes, you can delete in you code
            // const tempIndex = this.newList.splice(evt.oldIndex, 1)[0];
            // this.newList.splice(evt.newIndex, 0, tempIndex);
          },
        });
      onUnmounted(() => {
        sortable.destroy();
      });
    });

  const node = drag && (
    <el-table-column width="30" fixed>
      <el-icon class="sortable-handle">
        <Menu />
      </el-icon>
    </el-table-column>
  );

  return { node };
}

查看效果

2023-07-22 06.31.50.gif

洋洋洒洒写了快2W字了,之前也封装过挺多table组件,要么通过传递大json,

  • 各种属性传递,后来代码越来越大,导致维护成本越高
  • 对于新手来说,本来记录el-table属性都挺多,还需要了解json的属性配置

但是通过组合式api封装不一样,可以把单独逻辑剥离成一个hooks,内部实现对应的逻辑,每个逻辑相互不依赖,也可以正常使用响应式数据、生命周期