背景
vue3 项目中使用的是 element-plus 的 table 组件,页面中常用的功能,大概包含 排序,多选 以及 列的自定义展示,所以很有封装的必要,否则每次都要写很长一串。
由于 table-column 是靠插槽自定义的列内容,总要嵌套一些 v-if 的逻辑,写法并不简洁。
传统写法如下:
<template #default="{ row }">
<template v-if="column.prop === 'email'">
{{ `我的邮件:${row.email}` }}
</template>
......
</template>
想法
习惯了 antd 中的表格,每次配置一个 columns,支持 render 函数式渲染,这样就将数据和 html 分离开了,不需要在模板中进行展示判断,只要在对应的数据位置,写自己的 render 函数就可以了。
Vue 中能不能像这样写 jsx 呢?
实现
- 确认安装了
@vitejs/plugin-vue-jsx插件,并在vite.config.ts中进行了使用。 - 实现
BaseTable.tsx组件。 - 新增:也支持具名插槽传递。
// 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 模板,不会有框架底层的编译转换。
实现过程中遇到的问题:
- 在 .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;
}
}
}
- 在封装过程中,由于写法的关系,
elTable不再支持v-loading的指令,所以需要使用ElLoading.service的方式调用。 - 由于分页器一般没啥特殊逻辑,所以也将代码写在了一起,并没有抽离出去。
- 使用
ElPagination分页组件时,文档是支持onChange的,但配置后没生效,所以实现时分别监听了page和size。暂时不确定问题原因,在新增透传属性或事件时需要留个心,看是否生效。
使用
<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 上的方法