不到300行代码实现包含多级表头、自定义单元格渲染、顺滑滚动以及虚拟列表等功能的超级表格

什么是超级表格?

个人认为,超级表格应该涵盖一般表格使用所涉及的相关功能,根据以往项目开发经验,总结出以下功能:

  • 支持多级表头
  • 支持使用h函数等方式实现自定义单元格渲染
  • 支持纵向丝滑滚动
  • 支持接口数据量过大情况下的虚拟列表渲染方式

超级表格相关功能实现代码解读

支持多级表头

数据结构比较复杂的时候,可使用多级表头来展现数据的层次关系。

以上多级表头element plus Table示例的代码如下:

Vue 复制代码
<template> 
    <el-table :data="tableData" style="width: 100%"> 
        <el-table-column prop="date" label="Date" width="150" /> 
        <el-table-column label="Delivery Info"> 
            <el-table-column prop="name" label="Name" width="120" /> 
            <el-table-column label="Address Info"> 
                <el-table-column prop="state" label="State" width="120" /> 
                <el-table-column prop="city" label="City" width="120" /> 
                <el-table-column prop="address" label="Address" /> 
                <el-table-column prop="zip" label="Zip" width="120" /> 
            </el-table-column> 
         </el-table-column> 
     </el-table> 
</template>

一行一行的写el-table-column,多少有点枯燥,可通过调整传入的tableHeader数据结构以及递归调用表头组件的方式来实现多级表头。

tableHeader数据结构:

Vue 复制代码
<script setup>
const tableHeader = [
  {
    prop: "index",
    label: "#",
    align: "center",
    width: "120"
  },
  {
    prop: "date",
    label: "Date",
    width: "160",
    align: "center",
    cellRender: row => {
      return h(
        ElTag,
        {
          type: "primary",
          closable: true
        },
        () => row.date // https://blog.csdn.net/qq_42403461/article/details/142248823
      );
    }
  },
  {
    label: "Delivery Info",
    children: [
      {
        prop: "name",
        label: "Name",
        width: "120",
        align: "center"
      },
      {
        label: "Address Info",
        children: [
          {
            prop: "state",
            label: "State",
            width: "120"
          },
          {
            prop: "city",
            label: "City",
            width: "120"
          },
          {
            prop: "address",
            label: "Address",
            align: "center"
          },
          {
            prop: "zip",
            label: "Zip",
            width: "120",
            cellRender: row => {
              return h(
                ElButton,
                {
                  type: "success",
                  closable: true
                },
                () => row.zip // https://blog.csdn.net/qq_42403461/article/details/142248823
              );
            }
          }
        ]
      }
    ]
  }
];
</script>

通过children字段来存储子表头数据,如果子表头还存在子子表头,依然采用此方式来嵌套。

表头组件table-column.vue:

Vue 复制代码
<template>
  <el-table-column v-bind="props.columnHeader">
    <template v-for="(item, index) in columnHeader.children" :key="index">
      <TableColumn v-if="item.children && item.children.length" :column-header="item" />
      <el-table-column v-else v-bind="item">
        <template v-if="item.cellRender" #default="{ row }">
          <component :is="item.cellRender(row)" />
        </template>
      </el-table-column>
    </template>
  </el-table-column>
</template>

<script setup>
const props = defineProps({
  columnHeader: {
    type: Object,
    required: true
  }
});
</script>

可以看出采用递归调用TableColumn组件的形式实现深层次的多级表头。

支持使用h函数等方式实现自定义单元格渲染

表格单元格不仅需要展示数据,还需要根据业务需求展示不同的状态。比如使用element plus的组件Tag 标签、Button 标签等结合数据进行自定义渲染。可以通过Vue的h函数实现单元格的自定义渲染。

什么是h函数?

Vue中的 h 函数是 Vue 用于创建虚拟DOM(Virtual DOM)节点的核心函数。在 Vue3.0 版本中,这个函数作为渲染函数的基础工具,用于替代模板解析的过程,允许开发者以编程方式描述组件结构和动态生成虚拟节点。

通过h函数的方式构建虚拟DOM树,Vue可以在内部高效地比较新旧虚拟DOM的不同,并最小化地更新实际DOM,从而提高页面渲染性能。

js 复制代码
function h(type, props, children)
  • type:必需,表示要创建的元素类型或组件类型。它可以是一个字符串(HTML标签名),一个组件选项对象、异步组件函数或者一个函数式组件。
  • props:可选的对象,包含了传递给元素或组件的所有属性(attributes)和 props。例如:可以包含类名、样式、事件监听器等。
  • children: 可选,代表子节点,它可以是字符串(文本内容)、数组(包含多个子节点,每个子节点可以是字符串或其他由 h 创建的虚拟节点)或者其他合法的虚拟DOM节点。

实现流程

1、在tableHeader数据结构中使用cellRender字段来接收h函数
注意: 需要给h函数的第三个参数(即内容这个参数加一个匿名函数()=>),例如 () => row.date,从而解决Vue3组件报Non-function value encountered for default slot. Prefer function slots for better performance。
参考:Vue3组件报Non-function value encountered for default slot. Prefer function slots for better performance

2、在wang-table.vue以及table-column.vue文件中,通过component标签来接收h函数创建的虚拟节点。component 标签是Vue框架自定义的标签,它的用途是可以通过动态绑定is 属性来动态渲染组件

之所以使用component标签,是因为封装过程中发现直接通过

Vue 复制代码
 <template v-if="column.cellRender" #default="{ row }">
    {{ column.cellRender(row) }}
 </template>

这种方式展示的单元格为空,无法正常展示。

支持纵向丝滑滚动

wang-table.vue会监听通过props传入的autoScroll,如果autoScroll为true,则开启滚动。

Vue 复制代码
//表格滚动
const tableRef = ref<HtmlElType>(null);
let timer = null; // 定时器
let time = Date.now(); // 定义 time 变量
const createScroll = () => {
  if (Date.now() - time >= 50) {
    time = Date.now();
    // 拿到 table
    const table = tableRef.value?.layout?.table?.refs; // 拿到可以滚动的元素
    if (!table || !table.bodyWrapper) {
      console.error("Table or bodyWrapper not found");
      return;
    }
    const tableWrapper = table.bodyWrapper.firstElementChild.firstElementChild;
    if (!tableWrapper) {
      console.error("Table wrapper not found");
      return;
    }
    tableWrapper.scrollTop = tableWrapper.scrollTop + 1;
    // 判断是否滚动到底部,如果到底部了置为0(可视高度+距离顶部=整个高度)
    if (tableWrapper.clientHeight + tableWrapper.scrollTop >= tableWrapper.scrollHeight) {
      tableWrapper.scrollTop = 0;
    }
  }
  timer = window.requestAnimationFrame(createScroll);
};

// 提供一个方法来停止滚动
const stopScroll = () => {
  if (timer !== null) {
    window.cancelAnimationFrame(timer);
    timer = null;
  }
};
watch(
  () => props.autoScroll,
  () => {
    if (props.autoScroll) {
      createScroll();
    } else {
      stopScroll();
    }
  },
  {
    immediate: true
  }
);

滚动比较丝滑不卡顿的原因是:使用window.requestAnimationFrame代替setTimeout、setInterval等定时器,在浏览器下次重绘之前调用指定的回调函数更新动画,从而使滚动顺滑流畅。

支持接口数据量过大情况下的虚拟列表渲染方式

如果接口返回的数据过量大且不支持分页,可使用虚拟列表的方式进行性能优化,从而避免大数据渲染导致的页面卡顿问题。关于虚拟列表的原理以及实现过程,可参考:使用hooks实现虚拟列表并通过节流、增加缓冲区进行性能优化

Vue 复制代码
<script lang="ts" setup>
import { ref, watch } from "vue";
import useOptimizeVirtualList from "@/hooks/useOptimizeVirtualList";

interface IvirtualList {
  itemHeight?: number; // 列表项的大致高度
  bufferRatio?: number; // 缓冲比例
  throttleTime?: number; // 节流时间
}
// 虚拟列表
// 需要使用 reactive 语法,否则无法响应式更新 使用ref定义的变量来接收actualRenderData 会导致响应式更新无效
const curRenderData = reactive({
  data: null
});
watch([() => props.tableData, () => props.virtualList], () => {
  if (props.virtualList) {
    const { actualRenderData } = useOptimizeVirtualList({
      data: ref(props.tableData), // 列表项数据
      scrollContainer: ".el-table .el-scrollbar__wrap", // 滚动容器
      actualHeightContainer: ".el-table .el-scrollbar__view", // 渲染实际高度的容器
      translateContainer: ".el-table .el-table__body", // 需要偏移的目标元素,
      itemContainer: ".el-table__row", // 列表项
      itemHeight: props.virtualList?.itemHeight || 40, // 列表项的大致高度
      bufferRatio: props.virtualList?.bufferRatio || 1, // 缓冲比例
      throttleTime: props.virtualList?.throttleTime || 50 // 节流时间
    });
    curRenderData.data = actualRenderData;
  }
});

监听通过props传入的virtualList,通过hooks/useOptimizeVirtualList得到需要渲染的列表数据并监听列表滚动,更新渲染的列表数据。

需要注意的是,要用reactive语法来接收需要渲染的列表数据, 否则列表滚动时无法响应式更新数据。
使用ref定义的变量来接收actualRenderData,会导致响应式更新无效,且只能给ref定义的变量赋常量,赋响应式数据无效。(刚开始使用ref定义的变量来接收actualRenderData,滚动表格时,数据不更新,折腾了好久才找到原因)

Vue 复制代码
// 推荐
const curRenderData = reactive({
  data: null
});
watch([() => props.tableData, () => props.virtualList], () => {
  if (props.virtualList) {
    const { actualRenderData } = useOptimizeVirtualList({
     ...
    });
    curRenderData.data = actualRenderData;
  }
});

// 不推荐
const curRenderData = ref([]);
watch([() => props.tableData, () => props.virtualList], () => {
  if (props.virtualList) {
    const { actualRenderData } = useOptimizeVirtualList({
      ...
    });
    curRenderData.value = actualRenderData; // 只能给ref定义的变量赋常量 赋响应式数据无效
    curRenderData.value = actualRenderData.value; // 这样赋值 滚动表格 curRenderData不能响应式更新表格数据  actualRenderData是响应式且能根据表格滚动更新数据  curRenderData的值为第一次计算actualRenderData对应的值 后面不会再变化
  }
});

完整代码

文件目录结构

lua 复制代码
 |-- components

     |-- wang-table  
     
        |-- table-column.vue                // 多级表头组件                          
        
        |-- wang-table.vue                   // 表格组件                                
        
  |-- views

     |-- wangTable  
     
        |-- baseTable.vue                   // 使用表格组件的文件 
        

代码展示

Vue 复制代码
// table-column.vue
<template>
  <el-table-column v-bind="props.columnHeader">
    <template v-for="(item, index) in columnHeader.children" :key="index">
      <TableColumn v-if="item.children && item.children.length" :column-header="item" />
      <el-table-column v-else v-bind="item">
        <template v-if="item.cellRender" #default="{ row }">
          <component :is="item.cellRender(row)" />
        </template>
      </el-table-column>
    </template>
  </el-table-column>
</template>

<script setup>
const props = defineProps({
  columnHeader: {
    type: Object,
    required: true
  }
});
</script>

// wang-table.vue
<template>
  <el-table
    ref="tableRef"
    :class="['custome-table', { 'virtual-list-table': props.virtualList }]"
    v-bind="$attrs"
    :data="props.virtualList ? curRenderData.data : tableData"
    :header-cell-style="headstyle"
    border
    @mousemove="handleMousemove"
    @mouseleave="handleMouseleave"
  >
    <el-table-column
      v-if="props.showOrderNumber"
      :label="props.orderNumberLabel"
      type="index"
      align="center"
      :width="80 * ratio"
    ></el-table-column>
    <template v-for="column in props.tableHeader" :key="column">
      <table-column v-if="column.children && column.children.length > 0" :column-header="column"></table-column>
      <el-table-column v-else v-bind="column">
        <template v-if="column.cellRender" #default="{ row }">
          <component :is="column.cellRender(row)" />
        </template>
      </el-table-column>
    </template>
    <slot></slot>
  </el-table>

  <div v-if="props.page" class="common-table-page">
    <el-pagination
      :page-size="page.pagesize"
      background
      layout="prev, pager, next,sizes,total, jumper"
      :total="page.total"
      :current-page="currentPage"
      @current-change="changePageNo"
      @size-change="changePagesize"
    />
  </div>
</template>

<script lang="ts" setup>
import { ref, watch } from "vue";
import TableColumn from "./table-column.vue";
import useOptimizeVirtualList from "@/hooks/useOptimizeVirtualList";

type HtmlElType = HTMLElement | null;
type Mapper<T> = { [P in keyof T as string]?: string | object };
interface Ipage {
  total: number;
  pageNo: number;
  pagesize: number;
}
interface IvirtualList {
  itemHeight?: number; // 列表项的大致高度
  bufferRatio?: number; // 缓冲比例
  throttleTime?: number; // 节流时间
}

interface Props {
  tableData: Array<any>;
  tableHeader: Mapper<any>;
  page?: Ipage;
  showOrderNumber?: boolean; // 是否显示序号
  orderNumberLabel?: string; // 序号列的列名
  autoScroll?: boolean; // 是否滚动
  virtualList?: IvirtualList | boolean;
}

const props = withDefaults(defineProps<Props>(), {
  showOrderNumber: true,
  orderNumberLabel: "序号",
  autoScroll: false
});
const emit = defineEmits<{
  (e: "changePageNo", pageNo: number): void;
  (e: "changePagesize", pagesize: number): void;
}>();

const ratio = ref(window.innerWidth / 1920);
const currentPage = ref(1);
const changePageNo = pageNo => {
  currentPage.value = pageNo;
  emit("changePageNo", pageNo);
};
const changePagesize = pagesize => {
  //条数变化 页码恢复为1
  currentPage.value = 1;
  emit("changePagesize", pagesize);
};

//表头样式
const headstyle = () => {
  return {
    "text-align": "center"
  };
};

//表格滚动
const tableRef = ref<HtmlElType>(null);
let timer = null; // 定时器
let time = Date.now(); // 定义 time 变量
const createScroll = () => {
  if (Date.now() - time >= 50) {
    time = Date.now();
    // 拿到 table
    const table = tableRef.value?.layout?.table?.refs; // 拿到可以滚动的元素
    if (!table || !table.bodyWrapper) {
      console.error("Table or bodyWrapper not found");
      return;
    }
    const tableWrapper = table.bodyWrapper.firstElementChild.firstElementChild;
    if (!tableWrapper) {
      console.error("Table wrapper not found");
      return;
    }
    tableWrapper.scrollTop = tableWrapper.scrollTop + 1;
    // 判断是否滚动到底部,如果到底部了置为0(可视高度+距离顶部=整个高度)
    if (tableWrapper.clientHeight + tableWrapper.scrollTop >= tableWrapper.scrollHeight) {
      tableWrapper.scrollTop = 0;
    }
  }
  timer = window.requestAnimationFrame(createScroll);
};

// 提供一个方法来停止滚动
const stopScroll = () => {
  if (timer !== null) {
    window.cancelAnimationFrame(timer);
    timer = null;
  }
};
watch(
  () => props.autoScroll,
  () => {
    if (props.autoScroll) {
      createScroll();
    } else {
      stopScroll();
    }
  },
  {
    immediate: true
  }
);
/**
 * 处理鼠标移动事件的函数
 *
 * 本函数的目的是在鼠标移动时根据props.autoScroll的值决定是否停止自动滚动
 * 这对于在自动滚动状态下进行用户交互时提高用户体验非常关键
 */
const handleMousemove = () => {
  if (props.autoScroll) {
    stopScroll();
  }
};
/**
 * 处理鼠标离开事件的函数
 * 当鼠标离开时,根据props.autoScroll的值决定是否创建滚动效果
 */
const handleMouseleave = () => {
  if (props.autoScroll) {
    createScroll();
  }
};

// 虚拟列表
// 需要使用 reactive 语法,否则无法响应式更新 使用ref定义的变量来接收actualRenderData 会导致响应式更新无效
const curRenderData = reactive({
  data: null
});
watch([() => props.tableData, () => props.virtualList], () => {
  if (props.virtualList) {
    const { actualRenderData } = useOptimizeVirtualList({
      data: ref(props.tableData), // 列表项数据
      scrollContainer: ".el-table .el-scrollbar__wrap", // 滚动容器
      actualHeightContainer: ".el-table .el-scrollbar__view", // 渲染实际高度的容器
      translateContainer: ".el-table .el-table__body", // 需要偏移的目标元素,
      itemContainer: ".el-table__row", // 列表项
      itemHeight: props.virtualList?.itemHeight || 40, // 列表项的大致高度
      bufferRatio: props.virtualList?.bufferRatio || 1, // 缓冲比例
      throttleTime: props.virtualList?.throttleTime || 50 // 节流时间
    });
    curRenderData.data = actualRenderData;
  }
});
</script>

<style lang="scss" scoped>
.el-table :deep(.cell) {
  white-space: pre-line !important;
}
.common-table-page {
  display: flex;
  justify-content: flex-end;
  margin-top: 10px;
}

.virtual-list-table {
  :deep(.el-scrollbar__wrap) {
    height: 100%;
    overflow: auto;
    position: relative;
  }
  :deep(.el-table__body) {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
  }
}
</style>

// baseTable.vue
<template>
  <WangTable
    :table-header="tableHeader"
    :table-data="tableData"
    :show-order-number="false"
    :virtual-list="virtualList"
    height="500"
  />
</template>

<script setup>
const tableHeader = [
  {
    prop: "index",
    label: "#",
    align: "center",
    width: "120"
  },
  {
    prop: "date",
    label: "Date",
    width: "160",
    align: "center",
    cellRender: row => {
      return h(
        ElTag,
        {
          type: "primary",
          closable: true
        },
        () => row.date // https://blog.csdn.net/qq_42403461/article/details/142248823
      );
    }
  },
  {
    label: "Delivery Info",
    children: [
      {
        prop: "name",
        label: "Name",
        width: "120",
        align: "center"
      },
      {
        label: "Address Info",
        children: [
          {
            prop: "state",
            label: "State",
            width: "120"
          },
          {
            prop: "city",
            label: "City",
            width: "120"
          },
          {
            prop: "address",
            label: "Address",
            align: "center"
          },
          {
            prop: "zip",
            label: "Zip",
            width: "120",
            cellRender: row => {
              return h(
                ElButton,
                {
                  type: "success",
                  closable: true
                },
                () => row.zip // https://blog.csdn.net/qq_42403461/article/details/142248823
              );
            }
          }
        ]
      }
    ]
  }
];
const tableData = ref([]);
const virtualList = ref({});
setTimeout(() => {
  tableData.value = new Array(10000).fill({}).map((_, index) => ({
    index: index + 1,
    date: "2016-05-03",
    name: "Tom",
    state: "California",
    city: "Los Angeles",
    address: "No. 189, Grove St, Los Angeles",
    zip: "CA 90036"
  }));
  virtualList.value = {
    itemHeight: 50
  };
}, 1000);
</script>

总结

主要介绍了不到300行代码实现超级表格的原理以及注意事项。当然,目前代码仅能满足大部分使用场景,对于一些复杂的使用场景未进行相关开发,后续将根据项目实战经验进行优化与补充。

相关推荐
m0_748245521 小时前
深入探索前端调试神器vConsole
前端
533_1 小时前
[element-ui] e-image 和e-table一起使用显示问题
前端·vue.js·elementui
叶不休2 小时前
DOM事件的传播机制
开发语言·前端·javascript·css·chrome·html·ecmascript
超爱吃士力架2 小时前
Linux指令大全
linux·前端·设计模式
Rudon滨海渔村2 小时前
React-antd组件库 - 让Menu子菜单项水平排列 - 下拉菜单排序 - 自定义子菜单展示方式
前端·react.js·前端框架·react
前端熊猫2 小时前
React的状态管理库-Redux
前端·javascript·react.js
某公司摸鱼前端2 小时前
react 使用 PersistGate 白屏解决方案
前端·javascript·react.js·前端框架·reactjs
奥德坤2 小时前
基于 Vue 和 CSS 实现的垂直滚动效果
前端·css·vue.js
Damon小智3 小时前
node_modules文件夹删除失败解决办法
前端·node.js·vue·node_modules·删除失败
_.Switch3 小时前
FastAPI 应用生产环境部署与性能优化
开发语言·前端·数据库·python·性能优化·fastapi