Vue3 + TSX 封装 el-table:还原 Antd 风格的 Columns 配置

594 阅读1分钟

背景

vue3 项目中使用的是 element-plustable 组件,页面中常用的功能,大概包含 排序多选 以及 列的自定义展示,所以很有封装的必要,否则每次都要写很长一串。

由于 table-column 是靠插槽自定义的列内容,总要嵌套一些 v-if 的逻辑,写法并不简洁。

传统写法如下:

<template #default="{ row }">
  <template v-if="column.prop === 'email'">
    {{ `我的邮件:${row.email}` }}
  </template>
  ......
</template>

想法

习惯了 antd 中的表格,每次配置一个 columns,支持 render 函数式渲染,这样就将数据和 html 分离开了,不需要在模板中进行展示判断,只要在对应的数据位置,写自己的 render 函数就可以了。

Vue 中能不能像这样写 jsx 呢?

实现

  1. 确认安装了 @vitejs/plugin-vue-jsx 插件,并在 vite.config.ts 中进行了使用。
  2. 实现 BaseTable.tsx 组件。
  3. 新增:也支持具名插槽传递。
// type.ts
type LabelType = string | number | (() => any);
// 定义基础列接口
interface BaseColumn {
  type?: "default" | "selection" | "index"; // 列类型
  width?: string | number;
  align?: "left" | "center" | "right";
  fixed?: boolean | "left" | "right";
  showOverflowTooltip?: boolean;
}
// 选择列接口
interface SelectionColumn extends BaseColumn {
  type: "selection";
  // 选择列不需要 key 和 label
}

// 索引列接口
interface IndexColumn extends BaseColumn {
  type: "index";
  label?: LabelType;
  // 索引列不需要 key
}

// 默认列接口
export interface DefaultColumn extends BaseColumn {
  key: string; // 默认列必须有 key
  label: LabelType; // 默认列必须有 label
  render?: (row: any, column?: Column, index?: number) => any;
  slotName?: string; // 自定义插槽名称,当 render 不存在时使用
  headerSlotName?: string; // 自定义表头插槽名称,优先级高于 label 函数
  sortable?: boolean | "custom";
}

// 组合所有列类型
export type Column = SelectionColumn | IndexColumn | DefaultColumn;
// BaseTable.tsx
import { defineComponent, PropType, Fragment, onUnmounted, ref, watch } from "vue";
import { ElTable, ElTableColumn, ElPagination, ElLoading } from "element-plus";
import { Column, DefaultColumn } from "./type"

export default defineComponent({
  name: "BaseTable",
  inheritAttrs: false,
  props: {
    // --------- table 配置 ---------
    // 数据
    data: { type: Array as PropType<any[]>, required: true },
    // 列定义
    columns: { type: Array as PropType<Column[]>, required: true },
    // loading 状态
    loading: { type: Boolean, default: false },
    // 表格配置
    // stripe: 是否斑马纹
    stripe: { type: Boolean, default: false },
    // border: 是否带边框
    border: { type: Boolean, default: true },
    // height: 表格高度,支持字符串或数字
    height: {
      type: [String, Number] as PropType<string | number>,
      default: undefined,
    },
    // --------- 下面为分页配置 ---------
    // showPagination: 是否显示分页器
    showPagination: {
      type: Boolean,
      default: true,
    },
    // total: 数据总条数
    total: {
      type: Number,
      default: 0,
    },
    // 分页器布局
    pageLayout: {
      type: String,
      default: "total, sizes, prev, pager, next, jumper"
    },
    // 分页器每页条数选项
    pageSizeOptions: {
      type: Array as PropType<number[]>,
      default: () => [10, 20, 30, 50],
    },
    // 每页条数
    pageSize: {
      type: Number,
      default: 10,
    },
    // 当前页码
    currentPage: {
      type: Number,
      default: 1,
    }
  },
  emits: ["sort-change", "selection-change", "page-change", "update:currentPage", "update:pageSize"],
  setup(props, { emit, attrs, slots, expose }) {
    const tableRef = ref<any>(null);

    // 转发 ElTable 的方法
    const tableMethods = {
      clearSelection: (...args: any[]) => tableRef.value?.clearSelection(...args),
      getSelectionRows: () => tableRef.value?.getSelectionRows(),
      toggleRowSelection: (...args: any[]) =>
        tableRef.value?.toggleRowSelection(...args),
      toggleAllSelection: () => tableRef.value?.toggleAllSelection(),
      toggleRowExpansion: (...args: any[]) =>
        tableRef.value?.toggleRowExpansion(...args),
      setCurrentRow: (...args: any[]) => tableRef.value?.setCurrentRow(...args),
      clearSort: () => tableRef.value?.clearSort(),
      clearFilter: (...args: any[]) => tableRef.value?.clearFilter(...args),
      doLayout: () => tableRef.value?.doLayout(),
      sort: (...args: any[]) => tableRef.value?.sort(...args),
      scrollTo: (...args: any[]) => tableRef.value?.scrollTo(...args),
      setScrollTop: (...args: any[]) => tableRef.value?.setScrollTop(...args),
      setScrollLeft: (...args: any[]) => tableRef.value?.setScrollLeft(...args),
    };
    // 暴露表格 ref 实例以及上面方法
    expose({ tableRef, ...tableMethods });
    const loadingInstance = ref<any>(null);
    // 事件转发给父组件
    const onSortChange = (sort: any) => emit("sort-change", sort);
    const onSelectionChange = (rows: any[]) => emit("selection-change", rows);
    const onPageChange = (page: number, size: number) => {
      // 更新当前页和每页条数,触发回调
      emit("update:currentPage", page);
      emit("update:pageSize", size);
      emit("page-change", page, size)
    };

    // 监听 loading
    watch(
      () => props.loading,
      (val) => {
        if (!tableRef.value) return;
        const tableEl = tableRef.value.$el as HTMLElement;
        if (val) {
          loadingInstance.value = ElLoading.service({
            target: tableEl,
            // text: '加载中...',
            // background: 'rgba(255,255,255,0.7)',
          });
        } else {
          loadingInstance.value?.close();
          loadingInstance.value = null;
        }
      },
      { immediate: true }
    );

    onUnmounted(() => {
      loadingInstance.value?.close();
    })

    return () => (
      <Fragment>
        <ElTable
          ref={tableRef}
          data={props.data}
          stripe={props.stripe}
          border={props.border}
          height={props.height}
          style={{width: "100%"}}
          {...attrs}  // 透传属性
          onSortChange={onSortChange}
          onSelectionChange={onSelectionChange}
        >
          {props.columns.map((col, idx) => {
            // 处理 label:支持 string/number 或函数返回值(VNode/string)
            const header = 
              col.type === "selection" 
                ? undefined 
                : typeof col.label === "function" 
                  ? col.label() 
                  : col.label;

            // 获取列的数据字段,只有默认列才有 key
            const dataField = ["default", undefined].includes(col.type) ? (col as DefaultColumn).key : undefined;
            
            // 构建表头插槽对象
            const headerSlots: Record<string, any> = {};

            // 检查是否有表头插槽
            if (
              ["default", undefined].includes(col.type) &&
              (col as DefaultColumn).headerSlotName
            ) {
              const headerSlotName = (col as DefaultColumn).headerSlotName!;
              if (slots[headerSlotName]) {
                headerSlots.header = ({ column, $index }: any) => {
                  return slots[headerSlotName]!({
                    column: col,
                    $index,
                    data: props.data, // 传入表格数据
                  });
                };
              }
            }

            return (
              <ElTableColumn
                key={dataField ?? `col-${idx}`}
                prop={col.type === "selection" || col.type === "index" ? undefined : dataField}
                type={col.type || "default"}
                label={col.type === "selection" ? undefined : (header as any)}
                sortable={["default", undefined].includes(col.type) ? (col as DefaultColumn).sortable : undefined}
                width={col.width}
                align={col.align || "center"}
                fixed={col.fixed as any}
                showOverflowTooltip={col.showOverflowTooltip}
              >
                {{
                  // 表头插槽
                  ...headerSlots,
                  // 默认插槽拿到作用域 { row, column, $index }
                  default: ({ row, column, $index }: any) => {
                    // 1. 优先使用 render 函数
                    if (["default", undefined].includes(col.type) && (col as DefaultColumn).render) {
                      return (col as DefaultColumn).render!(row, column as any, $index);
                    }
                    // 2. 如果有 slotName,则使用对应的插槽
                    if (["default", undefined].includes(col.type) && (col as DefaultColumn).slotName) {
                      const slotName = (col as DefaultColumn).slotName!;
                      if (slots[slotName]) {
                        return slots[slotName]!({ row, column, $index });
                      }
                    }
                    // 3. 默认显示字段值
                    return (["default", undefined].includes(col.type) && dataField)
                      ? row[dataField]
                      : undefined;
                  },
                }}
              </ElTableColumn>
            );
          })}
        </ElTable>

        {/* 分页器 */}
        {props.showPagination && props.total > 0 && (
          <div style="margin: 10px 0 20px; padding: 12px">
            <ElPagination
              layout={props.pageLayout}
              total={props.total}
              pageSize={props.pageSize}
              pageSizes={props.pageSizeOptions}
              currentPage={props.currentPage}
              onCurrentChange={(page: number) => onPageChange(page, props.pageSize)}
              onSizeChange={(size: number) => onPageChange(1, size)}
            />
          </div>
        )}
      </Fragment>
    );
  },
});

解释一下上面代码:

写法上,首先是写的 tsx 文件,语法是要符合 jsx 的,平时使用的 el-table 在 jsx 中要使用大驼峰 ElTable,在 vue 中使用的自定义指令,比如 v-if 这些在 jsx 中是不生效的,因为这里不是 template 模板,不会有框架底层的编译转换。

实现过程中遇到的问题

  1. 在 .vue 文件的 script 中写诸如 render: () => <div></div> 编辑器语法会报错。

修改模板语言

<script setup lang="tsx">  // 改成 tsx

修改 .eslintrc.cjs,使支持 jsx。

需要注意的是,如果加了下面设置,如果项目中有尖括号断言会被解析成 jsx,格式报错,需要修改成 {} as Type 的写法,这个无法避免

相关资料: blog.csdn.net/gitblog_008…

typescript.xiniushu.com/zh/referenc…

parserOptions: {
    ecmaFeatures: {
      jsx: true // 新增
    }
}

想解决上面的问题,就需要在使用 jsx 的文件中,开启 jsx:true,同时文件中类型断言不使用 <>{} 这种形式,改为 as。如何部分开启 jsx,不影响全局呢?将使用 jsx 的组件名从 .vue 改成 .tsx.vue,然后针对这种文件名做处理。改了文件后缀名,别忘了路由引入时也兼容处理一下。

overrides: [
    {
      files: ["*.html"],
      processor: "vue/.vue",
    },
    {
      files: ["*.tsx.vue"], // 匹配所有以 .tsx.vue 结尾的文件
      parserOptions: {
        ecmaFeatures: {
          jsx: true, // 为这些文件开启 JSX 解析
        },
      },
    },
  ],

新增一个 shims-tsx.d.ts 声明,否则会有 JSX element implicitly has type any because no interface JSX.IntrinsicElements exists 的报错。

declare global {
  namespace JSX {
    type Element = VNode;
    interface IntrinsicElements {
      [elem: string]: any;
    }
  }
}
  1. 在封装过程中,由于写法的关系,elTable 不再支持 v-loading 的指令,所以需要使用ElLoading.service 的方式调用。
  2. 由于分页器一般没啥特殊逻辑,所以也将代码写在了一起,并没有抽离出去。
  3. 使用 ElPagination 分页组件时,文档是支持 onChange 的,但配置后没生效,所以实现时分别监听了 pagesize。暂时不确定问题原因,在新增透传属性或事件时需要留个心,看是否生效。

使用

<template>
  <BaseTable
    :columns="columns"
    :data="tableData"
    :loading="loading"
    :total="total"
    v-model:current-page="currentPage"
    v-model:page-size="pageSize"
    @page-change="handlePageChange"
  />
</template>

<script setup lang="tsx">
import { ref } from 'vue';
import { Column } from "@/components/BaseTable/type";

const columns:Column[]  = [
  {
    key: 'name',
    label: '姓名',
    sortable: true,
  },
  {
    key: 'age',
    label: '年龄',
    sortable: true,
  },
  {
    key: 'email',
    label: '邮箱',
    render: (row) => (
      <span style="color: #409eff">{row.email}</span>
    ),
  },
  {
    key: 'action',
    label: '操作',
    render: (row) => (
      <el-button size="small" onClick={() => handleEdit(row)}>
        编辑
      </el-button>
    ),
  },
];

const tableData = ref([]);
const loading = ref(false);
const total = ref(0);
const currentPage = ref(1);
const pageSize = ref(10);

const handlePageChange = (page: number, size: number) => {
  // 获取数据逻辑
  // getTableData();
};
</script>

至此,BaseTabe 组件就可以支持根据 columns 的配置来自定义列展示了,表头和列内容都支持函数形式,也可以根据实际使用情况,透传一些 props 或事件。

可以借鉴下面这篇文章,根据业务需要,对基础表格进行拓展: 后台管理系统容易忽略的表格优化项

补充

由于团队有些成员还是喜欢使用插槽形式,尤其是有些时候需要配合vue的自定义指令,所以上面表格内部也支持使用具名插槽。

const columns: Column[] = [
  {
    key: 'name',
    label: '姓名',
    sortable: true,
  },
  {
    key: 'age',
    label: '年龄',
    sortable: true,
  },
  {
    key: 'email',
    label: '邮箱',
    slotName: 'email', // 使用插槽
  },
  {
    key: 'action',
    label: '操作',
    slotName: 'action', // 使用插槽
  },
];

<BaseTable
    :columns="columns"
    :data="tableData"
    :loading="loading"
    :total="total"
    v-model:current-page="currentPage"
    v-model:page-size="pageSize"
    @page-change="handlePageChange"
  >
    <!-- 使用具名插槽自定义列内容 -->
    <template #email="{ row, column, $index }">
      <span style="color: #409eff">{{ row.email }}</span>
    </template>
    
    <template #action="{ row }">
      <el-button size="small" @click="handleEdit(row)">
        编辑
      </el-button>
    </template>
  </BaseTable>

由于插槽是在父组件编译的,所以子组件拿到的是一个对象,属性就是对应的插槽函数,jsx 作为中间层基本没有影响。

父组件模板(使用 BaseTable 的地方)
    ↓ 在父组件中编译
┌─────────────────────────────────┐
│ slots = {                       │
│   status: ({ row }) => {        │
│     return row.status === 1     │  ← v-if 已编译成 JS 逻辑
│       ? createVNode(ElTag, ...) │
│       : createVNode(ElTag, ...) │
│   }                             │
│ }                               │
└─────────────────────────────────┘
    ↓ 传递给 BaseTable
┌─────────────────────────────────┐
│ BaseTable 内部(JSX)           
│   slots[slotName]({ row })        ← 只是调用函数,获取 VNode
│                                 
│   不关心插槽内部是什么            
└─────────────────────────────────┘

文档更新

2025-12-08:组件内已暴露了表格 ref 上的方法