提升前端开发效率:Element Plus 封装 CTable 和 CSearch 表格搜索栏组件

1,753 阅读3分钟

前言

在实际项目中,由于业务复杂性,组件不可封装得过于死板,因为个性化修改需求一直存在。但我们希望尽可能减少基础代码的编写,通过配置数据的方式使用组件,并且保留组件的灵活性。

NaiveUI的数据表格也是通过数组配置项配置。并且支持每列使用自定义渲染函数,我们下面封装的不仅支持渲染函数,还要支持注册列插槽
毕竟代码一多渲染函数的写法写起来比模板慢很多。

本文的表格将仿照 NaiveUI 的数据表格进行封装,并封装 usePage 方法和 搜索栏 组件。

目标基础代码的重复编写枯燥无聊且容易出错,提高编码效率,减少工作量,使开发过程更加愉快。

提示:最复杂的表单组件 CForm 的封装将在下一篇文章中讨论(使用数据配置+内置常用组件+保留插槽以满足定制化需求)
# 提升前端开发效率:Element Plus 封装CForm表单组件

封装实现后效果

完整在线项目代码(sendbox):codesandbox.io/p/devbox/el…

image.png

接下来封装我们自己的CTable

  • 支持使用数据配置表格
  • 支持表格列、表格列头使用渲染函数render
  • 支持列使用插槽(优于渲染函数。设计逻辑是因为:当要使用到模板编写代码时,估计render的写法会繁琐)
  • 支持传入分页器配置对象使用分页功能
  • 表格保留原有功能
  • 表格列保留原有功能

Ctable表格代码实现

使用defineComponent的方式编写而不用SFC的方式是因为这样编写更灵活。

import { ref, h, defineComponent } from "vue";
import { ElTable, ElTableColumn, ElPagination } from "element-plus";
import "./styles/Ctable.scss";
import { get as _get } from "lodash";
const tableProps = {
  columns: {
    type: Array,
    default: () => [],
  },
  data: {
    type: Array,
    default: () => [],
  },
  pagination: {
    type: Object,
    default: () => {},
  },
};
export default defineComponent({
  props: tableProps,
  setup(props: any, { attrs, slots, expose }: any) {
    const $slots = slots;
    const tableRef = ref<InstanceType<typeof ElTable>>();

    expose({
      tableRef,
    });

    return () =>
      h("div", { class: "c_table" }, [
        h(
          ElTable,
          {
            ref: tableRef,
            data: props.data,
            border: true,
            style: { width: "100%" },
            headerRowClassName: "table_demo",
            headerCellStyle: { background: "rgba(247, 248, 250, 1)" },
            key: Math.random().toString(),
            rowKey: (row: any) => row.id,
            // 透传支持使用原有功能
            ...attrs,
          },
          {
            default: () => {
              return props.columns.map((item: any, index: number) => {
                let itemProps = { ...item };
                // 只扩展了 render和renderTitle属性
                delete itemProps.render;
                delete itemProps.renderTitle;
                let slots: any = {};
                // 表格列注册了插槽并传递了插槽(插槽优先于渲染函数)
                if (item.slotName && $slots[item.slotName]) {
                  slots.default = (scope: any) => {
                    return $slots[item.slotName](scope.row);
                  };
                } else if (!["selection", "index"].includes(item.type)) {
                  slots.default = (scope: any) => {
                    return item.render && Object.keys(scope.row).length
                      ? item.render(scope.row)
                      : h("span", _get(scope.row, item.prop));
                  };
                }
                if (item.renderTitle) {
                  slots.header = (scope: any) => item.renderTitle(scope);
                }
                if (item.type == "index") {
                  console.log(Object.keys(slots));
                }
                return h(
                  ElTableColumn,
                  {
                    // 设置默认值
                    prop: item.prop,
                    label: item.label,
                    align: "center",
                    key: index,
                    //透传,同v-bind一样。 支持使用列原有功能
                    ...itemProps,
                  },
                  slots,
                );
              });
            },
            empty: () =>
              h("div", { class: "noData" }, [h("div", [h("div", "暂无数据")])]),
          },
        ),
        h("div", { class: "pagination" }, [
          h(ElPagination, { ...props.pagination }),
        ]),
      ]);
  },
});

usePage分页器代码实现

  1. 把分页组件需要的配置封装,通过usePage函数调用返回一个分页器配置 以及 传递给接口的分页参数
import { reactive, watch } from 'vue'

// 分页
export const usePage = (actionSearch: any) => {
  const pageState = reactive({
    page: 1,
    page_size: 10,
  })
  const pagination = reactive({
    background: true,
    total: 0,
    pageSizes: [10, 20, 30, 40, 50],
    currentPage: pageState.page,
    pageSize: pageState.page_size,
    layout: 'total, prev, pager, next, sizes',
    onCurrentChange: (page: any) => {
      pageState.page = page
      actionSearch()
    },
    onSizeChange: (pageSize: any) => {
      pageState.page = 1
      pageState.page_size = pageSize
      actionSearch()
    },
  })
  watch([() => pageState.page, () => pageState.page_size], ([newPage, newPageSize]) => {
    pagination.currentPage = newPage
    pagination.pageSize = newPageSize
  })
  return { pageState, pagination }
}

CSearch 搜索栏代码实现

  • 利用表单封装搜索栏。使用el-row布局(一行分成24列)
  • 支持表单配置项传入props,给el-col使用,控制当前项所占列数
  • 封装 时间范围选择器输入框下拉框
  • 支持 suffixPresuffix 插槽
<template>
  <el-form :model="formVal">
    <el-row :gutter="10">
      <el-col
        v-bind="getColProps(item)"
        v-for="(item, index) in props.options"
        :key="index"
      >
        <el-form-item :label="item.label">
          <template v-if="item.type === 'daterange'">
            <el-date-picker
              v-model="formVal[item.key]"
              type="daterange"
              range-separator="-"
              start-placeholder="开始时间"
              value-format="YYYY-MM-DD"
              end-placeholder="结束时间"
              :shortcuts="shortcutsOptions"
              :clearable="item.clearable == undefined ? true : item.clearable"
              @change="
                (val: any) => handleDateChange(val, item.dict[0], item.dict[1])
              "
            />
          </template>
          <template v-if="item.type === 'select'">
            <el-select
              v-model="formVal[item.key]"
              :placeholder="item.tip ? item.tip : `请选择${item.label}`"
              :clearable="item.clearable == undefined ? true : item.clearable"
            >
              <el-option
                v-for="option in item.dict.options"
                :key="option.value"
                :label="option.label"
                :value="option.value"
              />
            </el-select>
          </template>
          <template v-if="item.type === 'input'">
            <el-input
              v-model="formVal[item.key]"
              :placeholder="item.tip ? item.tip : `请输入${item.label}`"
              :clearable="item.clearable == undefined ? true : item.clearable"
            />
          </template>
        </el-form-item>
      </el-col>
      <!-- 操作按钮区 -->
      <el-form-item label="" class="operation">
        <slot name="suffixPre"></slot>
        <el-button type="primary" @click="handleSearch" class="btn_search">
          <el-icon style="margin-right: 4px">
            <Search />
          </el-icon>
          查询
        </el-button>
        <el-button @click="handleReset" class="btn">
          <el-icon><RefreshRight /></el-icon>
          重置
        </el-button>
        <slot name="suffix"></slot>
      </el-form-item>
    </el-row>
  </el-form>
</template>

<script lang="ts" setup>
import { ref } from "vue";
import { shortcutsOptions } from "@/utils/options";
import { Search, RefreshRight } from "@element-plus/icons-vue";

const emits = defineEmits<{
  (e: "search", form: any, page?: number): void;
}>();

const props = withDefaults(
  defineProps<{
    options: any[];
  }>(),
  {
    options: () => [],
  },
);

// 默认列props
const getColProps = (item: any) => {
  if (item.props) {
    return item.props;
  } else {
    return { xs: 24, sm: 12, md: 8, lg: 6, xl: 6 };
  }
};

// 表单数据
const formVal = ref<any>({});

// 初始化表单
const initFromVal = () => {
  props.options.forEach((item) => {
    formVal.value[item.key] =
      typeof item.value !== "undefined" ? item.value : null;
    // 时间区间
    if (item.type === "daterange") {
      formVal.value[item.dict[0]] = "";
      formVal.value[item.dict[1]] = "";
      return;
    }
  });
};

// 时间区间选择
const handleDateChange = (val: string[] | null, key1: string, key2: string) => {
  formVal.value[key1] = val ? val[0] : "";
  formVal.value[key2] = val ? val[1] : "";
};

// 搜索
const handleSearch = () => {
  emits("search", formVal.value, 1);
};
// 重置
const handleReset = () => {
  initFromVal();
  emits("search", formVal.value, 1);
};

initFromVal();
emits("search", formVal.value);
</script>

<style lang="scss" scoped>
.operation {
  margin-left: 10px;
  flex: 1;
}
</style>

封装后的用法

<template>
  <c-search ref="searchRef"  :options="formSeachOptions" @search="search" />
  <c-table :columns="columns" :data="tableData" :single-line="false" :pagination="pagination">
    <template #action="row">
      <el-button type="primary">操作</el-button>
    </template>
  </c-table>
</template>

<script lang="ts" setup>
import { reactive, ref, h } from "vue";
import CSearch from "@/components/common/CSearch.vue";
import CTable from "@/components/common/CTable";
import { usePage } from "@/utils/page";
import { demoOption, getNameByValue } from "@/utils/options";
import { getList } from "@/utils/mockapi";

// 搜索栏数据
const searchRef = ref()
const formSeachOptions = ref([
  { type: "daterange", key: Symbol(), label: "创建时间", dict: ["start_time", "end_time"]},
  { type: "input", key: "name", label: "名称" },
  { type: "select",key: "status", label: "状态 ", value: null, dict:{options:demoOption.status}}]);

// 表格数据
const tableData = ref<any[]>([]);
const columns = reactive<any>([
  {
    type: "index",
    label: "序号",
    width: 80,
  },
  {
    prop: "name",
    label: "名称",
  },
  {
    prop: "pay_amount",
    label: "金额",
    render: (row: any) => {
      return h("div", (row.pay_amount / 100).toFixed(2));
    },
    renderTitle: () => {
      return h("div", "金额(自定义列头)");
    },
  },
  {
    prop: "status",
    label: "状态",
    render: (row: any) => {
      return h("div", getNameByValue(row.status, demoOption.status));
    },
  },
  {
    label: "操作",
    width: 200,
    fixed: "right",
    slotName: "action",
  },
]);

// 请求mock接口获取数据
const search = (query: any = searchRef.value?.getData() || {}, page?: number) => {
  page && (pageState.page = 1);
  getList({ ...query, ...pageState }).then((res: any) => {
    tableData.value = res.data;
    pagination.total = res.total;
  });
};

// 分页器
const { pageState, pagination } = usePage(search);
</script>

<style lang="scss" scoped></style>