基于 VxeTable 的高级表格选择组件

基于 VxeTable 的高级表格选择组件

概述

VxeTableSelect 是一个基于 VxeTable 的高级表格选择组件,支持单元格级别的选择、框选、多选等功能。该组件提供了类似 Excel 的表格交互体验,包括鼠标拖拽选择、键盘快捷键操作等。

组件架构

1. 核心依赖和类型定义

typescript 复制代码
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
import { useVModel, onClickOutside } from "@vueuse/core";
import type { VxeTableInstance } from "vxe-table";

解读: 组件使用了 Vue 3 的 Composition API,结合 VueUse 工具库来处理响应式数据和外部点击检测。VxeTable 作为底层表格组件提供基础的表格功能。

2. 接口定义

typescript 复制代码
interface TableRow {
  id: number | string;
  [key: string]: any;
}

interface CellPosition {
  rowIndex: number;
  colIndex: number;
}

interface SelectionData {
  rowIndex: number;
  colIndex: number;
  rowId: number | string;
  value: any;
  rowData: any;
}

interface SelectionBox {
  visible: boolean;
  startX: number;
  startY: number;
  endX: number;
  endY: number;
}

解读: 这些接口定义了组件的核心数据结构。TableRow 定义表格行数据,CellPosition 表示单元格位置,SelectionData 包含选中单元格的完整信息,SelectionBox 用于框选功能的坐标管理。

关键功能解析

1. 响应式状态管理

typescript 复制代码
// 组件引用
const tableRef = ref<VxeTableInstance>();
const tableWrapper = ref<HTMLElement>();

// 选中状态管理
const selectedCells = ref<Set<string>>(new Set());

// 使用 useVModel 处理 v-model
const modelValue = useVModel(props, "modelValue", emit);

// 鼠标交互状态
const isMouseDown = ref(false);
const isModifierPressed = ref(false);
const selectionStart = ref<CellPosition | null>(null);
const selectionEnd = ref<CellPosition | null>(null);

// 选择框状态
const selectionBox = reactive<SelectionBox>({
  visible: false,
  startX: 0,
  startY: 0,
  endX: 0,
  endY: 0,
});

解读: 组件使用了多个响应式变量来管理不同的状态:

  • selectedCells 使用 Set 数据结构存储选中的单元格,提高查找和操作效率
  • useVModel 实现了双向数据绑定,支持 v-model 语法
  • selectionBox 使用 reactive 包装,用于实时更新选择框的位置和大小

2. 性能优化策略

typescript 复制代码
// 性能优化:节流函数
const throttle = <T extends (...args: any[]) => any>(func: T, delay: number): T => {
  let lastCall = 0;
  return ((...args: any[]) => {
    const now = Date.now();
    if (now - lastCall >= delay) {
      lastCall = now;
      return func(...args);
    }
  }) as T;
};

// 缓存DOM元素以避免重复查询
let cachedTableBodyElement: HTMLElement | null = null;
let cachedFirstRow: HTMLElement | null = null;
let cachedCells: NodeListOf<Element> | null = null;
let cacheTimestamp = 0;
const CACHE_DURATION = 1000; // 缓存1秒

解读: 组件采用了多种性能优化策略:

  • 节流函数:限制鼠标移动事件的触发频率,避免过度渲染
  • DOM 缓存:缓存频繁访问的 DOM 元素,减少重复查询的开销
  • 时间戳控制:通过时间戳控制缓存的有效期,平衡性能和数据准确性

3. 单元格位置计算

typescript 复制代码
const calculateCellPosition = (clientX: number, clientY: number) => {
  const cached = getCachedTableElements();
  if (!cached) return null;

  const { tableBodyElement, firstRow, cells } = cached;
  const bodyRect = tableBodyElement.getBoundingClientRect();

  // 计算相对于表格内容的坐标(考虑滚动)
  const contentX = clientX - bodyRect.left + tableBodyElement.scrollLeft;
  const contentY = clientY - bodyRect.top + tableBodyElement.scrollTop;

  let colIndex = -1;
  let rowIndex = -1;

  // 计算列索引
  if (cells) {
    let currentX = 0;
    for (let i = 0; i < cells.length; i++) {
      const cellWidth = (cells[i] as HTMLElement).offsetWidth;
      if (contentX >= currentX && contentX < currentX + cellWidth) {
        colIndex = i;
        break;
      }
      currentX += cellWidth;
    }
  }

  // 计算行索引
  if (firstRow) {
    const cellHeight = firstRow.offsetHeight;
    rowIndex = Math.floor(contentY / cellHeight);
  }

  return { rowIndex, colIndex, contentX, contentY };
};

解读: 这是组件的核心算法之一,用于将鼠标坐标转换为表格单元格位置:

  • 坐标转换:将屏幕坐标转换为相对于表格内容的坐标
  • 滚动补偿:考虑表格的滚动偏移量,确保计算准确性
  • 边界处理:确保计算出的索引在有效范围内

4. 鼠标事件处理

typescript 复制代码
const onMouseDown = (event: MouseEvent) => {
  // 只处理左键和在修饰键模式下的右键
  if (event.button !== 0 && !(event.button === 2 && isModifierPressed.value)) {
    return;
  }

  // 点击表格时设置焦点状态
  isTableFocusedState.value = true;

  if (event.target && (event.target as HTMLElement).closest(".cell-content")) {
    const cellElement = (event.target as HTMLElement).closest(".cell-content");
    if (!cellElement) return;

    const rowIndex = parseInt(cellElement.getAttribute("data-row-index") || "0");
    const colIndex = parseInt(cellElement.getAttribute("data-col-index") || "0");
    const cellKey = getCellKey(rowIndex, colIndex);

    // 修饰键+右键:切换选择状态
    if (props.enableMultiSelect && isModifierPressed.value && event.button === 2) {
      if (selectedCells.value.has(cellKey)) {
        selectedCells.value.delete(cellKey);
      } else {
        selectedCells.value.add(cellKey);
      }
      updateSelection();
      event.preventDefault();
      event.stopPropagation();
      return;
    }

    // 正常的左键框选逻辑
    if (event.button === 0) {
      isMouseDown.value = true;
      selectionStart.value = { rowIndex, colIndex };
      selectionEnd.value = { rowIndex, colIndex };

      // 如果没有按修饰键或者未开启多选,清空之前的选择
      if (!props.enableMultiSelect || !isModifierPressed.value) {
        selectedCells.value.clear();
      }

      // 开始框选
      const wrapperRect = tableWrapper.value?.getBoundingClientRect();
      if (wrapperRect) {
        selectionBox.startX = event.clientX - wrapperRect.left;
        selectionBox.startY = event.clientY - wrapperRect.top;
        selectionBox.endX = selectionBox.startX;
        selectionBox.endY = selectionBox.startY;
        selectionBox.visible = true;
      }

      event.preventDefault();
    }
  }
};

解读: 鼠标按下事件处理了多种交互模式:

  • 按键区分:区分左键和右键,支持不同的操作模式
  • 修饰键支持:支持 Ctrl/Cmd 键进行多选操作
  • 焦点管理:点击时设置表格焦点状态
  • 数据属性读取:通过 data 属性获取单元格的行列索引

5. 选择区域可视化

typescript 复制代码
// 计算已选中单元格的覆盖层
const selectionOverlays = computed<SelectionOverlay[]>(() => {
  if (selectedCells.value.size === 0) return [];

  // 计算所有选中单元格的边界
  let minRow = Infinity;
  let maxRow = -Infinity;
  let minCol = Infinity;
  let maxCol = -Infinity;

  for (const cellKey of selectedCells.value) {
    const [rowIndex, colIndex] = cellKey.split("-").map(Number);
    minRow = Math.min(minRow, rowIndex);
    maxRow = Math.max(maxRow, rowIndex);
    minCol = Math.min(minCol, colIndex);
    maxCol = Math.max(maxCol, colIndex);
  }

  // 计算选择区域的边界位置
  const topLeftRect = getCellRect(minRow, minCol);
  const bottomRightRect = getCellRect(maxRow, maxCol);

  if (!topLeftRect || !bottomRightRect) return [];

  const left = topLeftRect.left;
  const top = topLeftRect.top;
  const width = bottomRightRect.left + bottomRightRect.width - topLeftRect.left;
  const height = bottomRightRect.top + bottomRightRect.height - topLeftRect.top;

  return [
    {
      key: "selection-area",
      style: {
        position: "absolute",
        left: `${left}px`,
        top: `${top}px`,
        width: `${width}px`,
        height: `${height}px`,
        transform: "",
      },
    },
  ];
});

解读: 这个计算属性负责生成选择区域的可视化覆盖层:

  • 边界计算:遍历所有选中单元格,计算最小和最大的行列索引
  • 位置计算:根据边界单元格的位置计算整个选择区域的位置和尺寸
  • 响应式更新:作为计算属性,会在选中状态变化时自动更新

6. 覆盖层DOM管理

typescript 复制代码
// 创建并插入覆盖层容器到表格主体中
const createOverlayContainer = () => {
  const cached = getCachedTableElements();
  if (!cached?.tableBodyElement || overlayContainer) return;

  overlayContainer = document.createElement("div");
  overlayContainer.className = "selection-overlay-container";
  overlayContainer.style.cssText = `
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
    z-index: 10;
    background: transparent;
  `;
  cached.tableBodyElement.style.position = "relative";
  cached.tableBodyElement.appendChild(overlayContainer);
};

// 更新覆盖层DOM
const updateOverlayDOM = () => {
  if (!overlayContainer) return;

  // 清空现有覆盖层
  overlayContainer.innerHTML = "";

  // 添加已选中单元格覆盖层
  for (const overlay of selectionOverlays.value) {
    const overlayDiv = document.createElement("div");
    overlayDiv.className = "selection-overlay selected-area";
    overlayDiv.style.cssText = `
      position: absolute;
      left: ${overlay.style.left};
      top: ${overlay.style.top};
      width: ${overlay.style.width};
      height: ${overlay.style.height};
      background-color: rgba(24, 144, 255, 0.1);
      border: 1px solid #1890ff;
      pointer-events: none;
    `;
    overlayContainer.appendChild(overlayDiv);
  }

  // 添加正在选择区域覆盖层
  if (selectingOverlay.value) {
    const overlayDiv = document.createElement("div");
    overlayDiv.className = "selection-overlay selecting";
    overlayDiv.style.cssText = `
      position: absolute;
      left: ${selectingOverlay.value.style.left}px;
      top: ${selectingOverlay.value.style.top}px;
      width: ${selectingOverlay.value.style.width}px;
      height: ${selectingOverlay.value.style.height}px;
      background-color: rgba(82, 196, 26, 0.1);
      border: 1px solid #52c41a;
      pointer-events: none;
    `;
    overlayContainer.appendChild(overlayDiv);
  }
};

解读: 覆盖层管理是组件的重要特性:

  • 动态创建:在表格主体中动态创建覆盖层容器
  • 样式控制:通过 CSS 样式控制覆盖层的外观和层级
  • 事件穿透 :设置 pointer-events: none 确保覆盖层不影响表格的正常交互

关键技术点

1. 事件监听优化

typescript 复制代码
// 防止默认的 passive 事件监听
(function () {
  if (typeof EventTarget !== "undefined") {
    const func = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function (type, fn, capture) {
      (this as any).func = func;
      if (typeof capture !== "boolean") {
        capture = capture || {};
        capture.passive = false;
      }
      (this as any).func(type, fn, capture);
    };
  }
})();

解读: 这段代码重写了 addEventListener 方法,确保事件监听器不是被动的,这样可以调用 preventDefault() 来阻止默认行为。

2. 键盘修饰键检测

typescript 复制代码
// 检查修饰键是否按下(支持 Ctrl 和 Command)
const isModifierKeyPressed = (event: KeyboardEvent | MouseEvent) => {
  return event.ctrlKey || event.metaKey;
};

解读: 跨平台支持,在 Windows/Linux 上检测 Ctrl 键,在 macOS 上检测 Command 键。

3. 自动滚动功能

typescript 复制代码
// 自动滚动检测
const cached = getCachedTableElements();
if (cached?.tableBodyElement) {
  const tableBodyElement = cached.tableBodyElement;
  const bodyRect = tableBodyElement.getBoundingClientRect();
  const scrollMargin = 50;
  let scrollX = 0;
  let scrollY = 0;

  // 横向滚动
  if (event.clientX < bodyRect.left + scrollMargin) {
    scrollX = -10;
  } else if (event.clientX > bodyRect.right - scrollMargin) {
    scrollX = 10;
  }

  // 纵向滚动
  if (event.clientY < bodyRect.top + scrollMargin) {
    scrollY = -10;
  } else if (event.clientY > bodyRect.bottom - scrollMargin) {
    scrollY = 10;
  }

  if (scrollX !== 0 || scrollY !== 0) {
    tableBodyElement.scrollBy(scrollX, scrollY);
  }
}

解读: 当鼠标接近表格边缘时自动滚动,提供流畅的选择体验。通过检测鼠标位置与表格边界的距离来触发滚动。

4. 焦点管理

typescript 复制代码
// 使用 onClickOutside 来管理表格焦点状态
onClickOutside(tableWrapper, () => {
  isTableFocusedState.value = false;
});

解读: 使用 VueUse 的 onClickOutside 来检测外部点击,自动管理表格的焦点状态。

使用示例

vue 复制代码
<template>
  <VxeTableSelect
    v-model="selectedData"
    :table-data="tableData"
    :columns="columns"
    :height="500"
    :show-selection-box="true"
    :enable-multi-select="true"
    @selection-change="onSelectionChange"
  />
</template>

<script setup>
import { ref } from 'vue';

const selectedData = ref([]);
const tableData = ref([
  { id: 1, name: '张三', age: 25, city: '北京' },
  { id: 2, name: '李四', age: 30, city: '上海' },
  // ...
]);

const columns = ref([
  { field: 'name', title: '姓名', width: 120 },
  { field: 'age', title: '年龄', width: 80 },
  { field: 'city', title: '城市', width: 100 },
]);

const onSelectionChange = (data) => {
  console.log('选择变化:', data);
};
</script>

总结

VxeTableSelect 组件是一个功能丰富的表格选择组件,主要特点包括:

  1. 高性能:通过节流、缓存等技术优化性能
  2. 交互友好:支持鼠标拖拽、键盘快捷键等多种交互方式
  3. 可视化反馈:提供选择框和覆盖层的可视化反馈
  4. 跨平台兼容:支持不同操作系统的修饰键
  5. 灵活配置:支持单选、多选等多种选择模式

该组件适用于需要复杂表格交互的场景,如数据分析、报表系统等。通过学习其实现原理,可以深入理解 Vue 3 的响应式系统、DOM 操作优化、事件处理等核心概念。

相关推荐
摸着石头过河的石头3 小时前
JavaScript 防抖与节流:提升应用性能的两大利器
前端·javascript
酸菜土狗3 小时前
让 ECharts 图表跟随容器自动放大缩小
前端
_大学牲3 小时前
FuncAvatar: 你的头像氛围感神器 🤥🤥🤥
前端·javascript·程序员
葡萄城技术团队3 小时前
SpreadJS 性能飙升秘籍:底层优化技术深度拆解
前端
brzhang3 小时前
我且问你,如果有人用 AI 抄你的产品,爱卿又当如何应对?
前端·后端·架构
533_4 小时前
[element-ui] el-tree 组件鼠标双击事件
前端·javascript·vue.js
KIKIiiiiiiii4 小时前
微信个人号开发中如何高效实现API二次开发
java·前端·python·微信
日月之行_4 小时前
Vite+:企业级前端构建的新选择
前端
山顶听风4 小时前
Flask应用改用Waitress运行
前端·笔记·python·flask