通用虚拟滚动表格行拖拽排序:从思路到完整实现

通用虚拟滚动表格行拖拽排序:从思路到完整实现

假设你在开发一个拥有 10 万行数据的表格。为了性能,你采用了虚拟滚动,屏幕上只渲染可见区域的十几行。现在用户想把第 9527 行拖到第 3 行前面。

问题来了:第 3 行可能根本不在 DOM 里,鼠标下面没有任何一个"目标行"可以落点。更麻烦的是,如果表格还开启了筛选,行号本身都在动态变化。我们该如何知道用户到底想插到哪里?

这篇文章先讲清楚核心思路,最后给出完整可用的代码。我们不依赖全局索引,只依赖每行的唯一标识 Idd,通过固定行高和当前渲染窗口,在鼠标移动时精准计算出插入位置。


1. 心智模型:三层数据与 Idd

虚拟滚动表格中,有三层数据视图:

bash 复制代码
完整数据 allData:      [A, B, C, D, E, F, G, H, I, J]   (可能是筛选后的)
                              ↓ 虚拟滚动取窗口
窗口数据 showGridData:  [C, D, E, F, G, H]                (真正渲染的 DOM 行)
                              ↓
DOM 行:                 <tr C> <tr D> <tr E> <tr F> <tr G> <tr H>
  • 完整数据是业务方持有的全部数据,可能被筛选、排序影响。
  • 窗口数据 是虚拟滚动组件根据滚动位置计算出的、当前需要渲染的行数组。它与 DOM 中的 <tr> 严格一一对应且顺序一致。
  • Idd 是每行数据的唯一标识,不随位置、筛选或滚动变化。

整个拖拽过程只用 Idd 来表示"谁被拖""拖到哪里"。坐标计算则基于 showGridData 这个窗口快照。

showGridData 通常不仅包含可视区域的行,还会多出上下几行作为缓冲区(overscan)。缓冲区的作用会在核心思路中说明。


2. 一次拖拽的完整旅程

scss 复制代码
pointerdown(按下手柄)
   ↓
记录 fromIdd 和 fromNextIdd
创建幽灵行
   ↓
pointermove(移动)
   ↓
更新幽灵行位置
计算目标 Idd
更新指示线
必要时触发自动滚动
   ↓
pointerup(松手)
   ↓
判断目标是否与原位不同
调用 onSort(fromIdd, toIdd)
清理临时状态

核心挑战有两个:

  1. 身份稳定性:只用 Idd 表示拖拽源和目标,不依赖易变的索引。
  2. 坐标到 Idd 的映射:如何把鼠标的屏幕坐标转换成"应该插入到哪一行之前"的 Idd。

下面先讲清楚第二个挑战的解法,这是整个方案的核心。


3. 核心思路:从鼠标坐标到目标 Idd

3.1 坐标系统一

指示线的 top 是相对于滚动容器内容区 的偏移,会随内容一起滚动。所以我们必须把鼠标的屏幕坐标也转换成内容坐标,两者才能比较。

转换公式:

ini 复制代码
mouseTopInContent = (clientY - containerRect.top) + container.scrollTop
  • clientY - containerRect.top:鼠标在容器视口内的 Y 偏移。
  • + container.scrollTop:加上已滚动的距离,得到鼠标相对于内容顶部的 Y 坐标。

3.2 锚点选择

有了鼠标的内容坐标,还需要知道每一行在内容里的位置。我们利用第一个渲染行的 offsetTop

ini 复制代码
firstRowTopInContent = firstRowEl.offsetTop

这个值就是第一个渲染行在滚动内容中的顶部偏移。它不随滚动变化,天然是一个稳定的锚点。第一个渲染行可能是缓冲区行(不可见),但这不影响,只要它在 DOM 里就能提供正确的参考。

前提假设:滚动容器设置了 position: relative,且没有明显的边框和内边距。多数虚拟滚动组件的容器是干净的 div,满足这个条件。

3.3 偏移行数计算

两个值都在内容坐标系里,相减再除以行高,就得到鼠标偏移了多少行:

ini 复制代码
exact = (mouseTopInContent - firstRowTopInContent) / rowHeight

插入语义统一为"插入到目标行之前":

exact round(exact) 含义
0.2 0 插到第 0 行之前
0.5 1 插到第 1 行之前
0.7 1 插到第 1 行之前
1.4 1 插到第 1 行之前
-0.3 0 插到第 0 行之前(clamp 后)

Math.round(exact) 正好实现了"上半部分插前面,下半部分插后面"的规则。然后限制在窗口索引范围内:

ini 复制代码
insertIndex = clamp(Math.round(exact), 0, totalRows)

insertIndex = totalRows 表示插入到所有渲染行的末尾之后。

3.4 索引转 Idd

窗口索引只是临时的,要转换成稳定的 Idd:

ini 复制代码
targetIdd = insertIndex < totalRows
  ? showGridData[insertIndex].Idd
  : null

null 表示"插入到完整数据末尾"。

3.5 缓冲区的价值

totalRowsshowGridData 的长度,而 showGridData 包含可视区域加上下缓冲区。这个缓冲区对拖拽排序至关重要:

假设可视区域最后一行是 E,但 showGridData 里还有 F、G、H(缓冲区)。当用户把行拖到可视底部时,我们依然能拿到 F 的 Idd,精确告诉业务方"插入到 F 前面"。

如果没有缓冲区,totalRows 就等于可视行数,鼠标拖到可视底部时 insertIndex 直接等于 totalRows,目标变成 null(数据末尾),精度大大降低。所以缓冲区不仅提升滚动体验,还让拖拽排序在边缘处更精准。


4. 指示线定位:复用同一个计算结果

插入点在滚动内容中的 Y 坐标就是:

ini 复制代码
indicatorTopInContent = firstRowTopInContent + insertIndex * rowHeight

insertIndex = totalRows 时,指示线正好落在窗口内容底部。

指示线的 top 直接使用内容坐标,无需额外转换。它和行元素一样在滚动容器内部,会随内容一起滚动,正好符合"插入到内容某个位置"的语义。

为了性能,如果指示线落在可视区域之外(上下各留 2px 容差),直接隐藏即可。


5. 按下与松手:身份判断

5.1 按下时记录什么

按下手柄时,需要记录:

  • fromIdd:被拖拽行的 Idd。
  • fromNextIdd:原位置下一行的 Idd,用于判断"是否真的移动了"。
  • toIdd:初始化为 fromNextIdd,即原位。

为什么记录 fromNextIdd?因为"原位"的定义是"被拖拽行仍然位于它原来的下一行之前"。如果松手时 toIdd 仍然等于 fromNextIdd(或等于 fromIdd 自身),说明没有发生有效移动。

5.2 松手时怎么判断

假设窗口数据 [A, B, C],拖拽 B:

  • 原位是"B 在 C 之前",即 fromNextIdd = 'C'
  • 如果用户拖到 B 的上半部分,toIdd = 'B'。此时 toIdd !== fromNextIdd,但实际上位置没变。

所以需要同时排除两种情况:

yaml 复制代码
toIdd !== fromIdd && toIdd !== fromNextIdd

只有目标既不是自己、也不是原下一行时,才算真正移动了,此时调用 onSort(fromIdd, toIdd)


6. 幽灵行与自动滚动

6.1 幽灵行

幽灵行是被拖拽行的克隆,position: fixed 跟随鼠标移动。几个关键点:

  • pointer-events: none:让幽灵行不挡住鼠标事件。
  • 添加到 document.body:避免被容器的 overflow 裁剪。
  • transform: translateY(deltaY) 移动:不触发布局重排,性能更好。
  • 初始位置与源行完全重叠,之后只做 Y 方向偏移。

6.2 自动滚动

当鼠标靠近容器上边缘或下边缘时,启动自动滚动。每帧做三件事:

  1. 判断鼠标是否还在边缘区域,不在就停止。
  2. 判断是否还能继续滚动(canScrollUp / canScrollDown),不能就停止。
  3. 修改 scrollTop,然后重新计算目标 Idd

最后一步很关键:滚动后 showGridData 会更新,第一个渲染行元素可能被替换,所以每帧都必须重新获取锚点,不能缓存。


7. 完整代码

php 复制代码
import {onBeforeUnmount, ref, toValue} from 'vue'
import {clamp} from '@/tools/Tool.js'
​
/**
 * 虚拟滚动表格行拖拽排序 composable
 *
 * @param {Object}                    options
 * @param {number}                    options.rowHeight         每行高度(px),必须与表格实际行高一致
 * @param {import('vue').Ref<[]>}     options.showGridData      虚拟滚动实际显示的所有数据
 * @param {Function}                  options.onSort            拖拽结束回调 (fromGlobalIdx, toGlobalIdx)
 * @param {import('vue').ComputedRef<HTMLElement | null>} options.scrollContainer
 *                                                              滚动容器的计算属性
 * @param {number}                    [options.edgeZone=20]     触发自动滚动的边缘区域高度(px)
 * @param {number}                    [options.scrollSpeed=8]   自动滚动速度(px/frame)
 * @param {string}                    [options.rowSelector='tbody tr'] 行元素选择器
 * @returns {Object} 包含 onPointerDown、getDragRowClass
 */
export function useVirtualDragSort(options) {
    const {
        rowHeight,
        showGridData,
        onSort,
        scrollContainer,
        edgeZone = 20,
        scrollSpeed = 8,
        rowSelector = 'tbody tr'
    } = options
​
    // ==================== 内部状态 ====================
​
    /** 拖拽状态:普通对象,不暴露给模板,避免响应式开销 */
    let dragState = null
​
    /** 插入指示线 DOM 元素(惰性创建) */
    let indicatorEl = null
​
    /** 自动滚动 requestAnimationFrame 句柄 */
    let autoScrollFrame = null
​
    /** 滚动容器 DOM 引用(惰性获取后缓存) */
    let containerEl = null
​
    /** 滚动容器高度缓存,由 ResizeObserver 更新 */
    let containerHeight = 0
​
    /** ResizeObserver 实例,监听容器高度变化 */
    let resizeObserver = null
​
    /**
     * 当前正在拖拽的行 Idd(响应式)
     * 用于在模板中动态绑定行 class,解决虚拟滚动 DOM 回收后行内样式丢失的问题
     */
    const draggingIdd = ref(null)
​
​
    // ==================== 容器与监听 ====================
​
    /**
     * 获取滚动容器(惰性初始化:第一次拖拽时才获取并缓存)
     * @returns {HTMLElement | null}
     */
    function getContainer() {
        if (!containerEl) {
            const el = toValue(scrollContainer)
            if (el) {
                containerEl = el
                containerHeight = el.clientHeight
                setupResizeObserver(el)
            }
        }
        return containerEl
    }
​
    /**
     * 设置 ResizeObserver 监听容器高度变化
     * @param {HTMLElement} el 滚动容器
     */
    function setupResizeObserver(el) {
        cleanupResizeObserver()
​
        resizeObserver = new ResizeObserver(() => {
            // 高度变化时只更新缓存值,不重复读取
            if (containerEl) {
                containerHeight = containerEl.clientHeight
                // 容器尺寸变化可能影响 scrollWidth,刷新指示线宽度
                updateIndicatorWidth()
            }
        })
        resizeObserver.observe(el)
    }
​
    /**
     * 清理 ResizeObserver 实例
     */
    function cleanupResizeObserver() {
        if (resizeObserver) {
            resizeObserver.disconnect()
            resizeObserver = null
        }
    }
​
​
    // ==================== DOM 工具 ====================
​
    /**
     * 创建幽灵行(fixed 定位,跟随鼠标移动)
     * @param {HTMLElement} sourceEl 被拖拽的原始行元素
     * @returns {HTMLElement} 幽灵行元素
     */
    function createGhost(sourceEl) {
        const ghost = sourceEl.cloneNode(true)
        const rect = sourceEl.getBoundingClientRect()
​
        // 基本样式:固定定位、尺寸与原始行一致
        ghost.style.position = 'fixed'
        ghost.style.top = `${rect.top}px`
        ghost.style.left = `${rect.left}px`
        ghost.style.width = `${rect.width}px`
        ghost.style.height = `${rect.height}px`
        ghost.style.zIndex = '9999'
        ghost.style.pointerEvents = 'none'   // 让鼠标事件穿透
        ghost.style.opacity = '0.85'
        ghost.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'
        ghost.style.margin = '0'
        ghost.style.border = '1px solid #409eff'
        ghost.style.boxSizing = 'border-box'
​
        // 如果源行有拖拽高亮 class,克隆时会带上,幽灵行不需要这个 class
        ghost.classList.remove('is-dragging-source')
​
        document.body.appendChild(ghost)
        return ghost
    }
​
    /** 更新指示线宽度,使其等于滚动容器的完整内容宽度(scrollWidth) */
    function updateIndicatorWidth() {
        if (!indicatorEl || !containerEl) return
        indicatorEl.style.width = `${containerEl.scrollWidth}px`
    }
​
    /**
     * 创建插入指示线(绝对定位在滚动容器内)
     * @param {HTMLElement} container 滚动容器
     * @returns {HTMLElement} 指示线元素
     */
    function createIndicator(container) {
        // 确保容器可以成为绝对定位的参考点
        if (!container.style.position || container.style.position === 'static') {
            container.style.position = 'relative'
        }
​
        const el = document.createElement('div')
        el.style.position = 'absolute'
        el.style.height = '2px'
        el.style.backgroundColor = '#409eff'
        el.style.zIndex = '1000'
        el.style.pointerEvents = 'none'
        el.style.display = 'none'
​
        container.appendChild(el)
        return el
    }
​
    /**
     * 获取滚动容器内第一个渲染行元素(实时查询,虚拟滚动下 DOM 动态变化)
     * @param {HTMLElement} container 滚动容器
     * @returns {Element | null}
     */
    function getFirstRowEl(container) {
        return container.querySelector(rowSelector)
    }
​
​
    // ==================== 指示线定位 ====================
​
    /**
     * 更新插入指示线位置
     * 指示线始终位于目标行的顶部(即插入到该行之前)
     * 当 targetIndex 为 null 时,表示插入到末尾,指示线位于内容底部(总行数 * 行高)
     * @param {number|null} targetIndex          插入点索引(基于当前渲染窗口),null 表示末尾
     * @param {number}      firstRowTopInContent 第一个渲染行在滚动内容中的顶部 Y 坐标
     * @param {HTMLElement} container            滚动容器
     */
    function updateIndicator(
        targetIndex,
        firstRowTopInContent,
        container
    ) {
        if (!container || !indicatorEl) return
​
        // 计算指示线在滚动内容中的 Y 坐标
        // 若 targetIndex 为 null,使用当前窗口总行数作为索引(即内容底部)
        const totalRows = toValue(showGridData).length
        const effectiveIndex = targetIndex === null ? totalRows : targetIndex
        const indicatorTopInContent = firstRowTopInContent + effectiveIndex * rowHeight
​
        // 视口裁剪,避免滚动条附近闪烁
        const scrollTop = container.scrollTop
        const viewportTop = scrollTop
        const viewportBottom = scrollTop + containerHeight
        if (indicatorTopInContent < viewportTop - 2 || indicatorTopInContent > viewportBottom + 2) {
            indicatorEl.style.display = 'none'
            return
        }
​
        indicatorEl.style.top = `${indicatorTopInContent}px`
        indicatorEl.style.display = 'block'
    }
​
    // ==================== 核心计算 ====================
​
    /**
     * 根据鼠标 Y 坐标更新目标插入行和指示线
     * 目标行 Idd 表示"插入到该行之前",若为 null 表示插入到末尾
     * @param {number} clientY 鼠标的 clientY 坐标
     */
    function updateTargetFromPoint(clientY) {
        if (!dragState) return
​
        const container = getContainer()
        if (!container) return
​
        const firstRowEl = getFirstRowEl(container)
        if (!firstRowEl) return
​
        const containerRect = container.getBoundingClientRect()
        const firstRowRect = firstRowEl.getBoundingClientRect()
​
        // 第一个渲染行在滚动内容中的顶部 Y 坐标
        const firstRowTopInContent =
            firstRowRect.top - containerRect.top - container.clientTop + container.scrollTop
​
        // 鼠标在滚动内容中的 Y 坐标
        const mouseTopInContent =
            clientY - containerRect.top - container.clientTop + container.scrollTop
​
        // 鼠标相对第一个渲染行的偏移量(以行高为单位)
        const exact = (mouseTopInContent - firstRowTopInContent) / rowHeight
​
        // 当前窗口总行数(showGridData 的长度)
        const totalRows = toValue(showGridData).length
​
        // 插入点索引:四舍五入到最近的整数行位置,范围 [0, totalRows]
        const insertIndex = clamp(Math.round(exact), 0, totalRows)
​
        // 目标行 Idd:插入点在末尾时为 null,否则取对应行数据
        dragState.toIdd = insertIndex < totalRows
            ? toValue(showGridData)[insertIndex].Idd
            : null
​
        // 传递 targetIndex(null 表示末尾)给 updateIndicator
        const targetIndex = insertIndex < totalRows ? insertIndex : null
        updateIndicator(targetIndex, firstRowTopInContent, container)
    }
​
    // ==================== 自动滚动 ====================
​
    /**
     * 获取自动滚动的边缘阈值
     * @param {HTMLElement} container 滚动容器
     * @returns {{top: number, bottom: number}} 上、下阈值(clientY 坐标)
     */
    function getScrollThresholds(container) {
        const rect = container.getBoundingClientRect()
        return {
            top: rect.top + edgeZone,
            bottom: rect.top + containerHeight - edgeZone,
        }
    }
​
    /**
     * 判断鼠标是否处于自动滚动边缘区域
     * 使用模块级 dragState.mouseY 作为当前鼠标 Y 坐标
     * @returns {boolean}
     */
    function isInAutoScrollZone() {
        if (!dragState) return false
​
        const container = getContainer()
        if (!container) return false
​
        const {top, bottom} = getScrollThresholds(container)
        return dragState.mouseY < top || dragState.mouseY > bottom
    }
​
    /**
     * 判断容器是否还能向上滚动
     * @param {HTMLElement} container 滚动容器
     * @returns {boolean} 如果可以向上滚动返回 true,否则 false
     */
    function canScrollUp(container) {
        return container.scrollTop > 0
    }
​
    /**
     * 判断容器是否还能向下滚动
     * @param {HTMLElement} container 滚动容器
     * @returns {boolean} 如果可以向下滚动返回 true,否则 false
     */
    function canScrollDown(container) {
        const maxScrollTop = container.scrollHeight - container.clientHeight
        return container.scrollTop < maxScrollTop
    }
​
    /**
     * 开启自动滚动(当鼠标位于容器边缘区域时持续滚动)
     */
    function startAutoScroll() {
        if (autoScrollFrame) return
​
        const step = () => {
            const container = getContainer()
            if (!container) return stopAutoScroll()
​
            // 鼠标不在边缘区域,停止自动滚动
            if (!isInAutoScrollZone()) return stopAutoScroll()
​
            // 根据鼠标位置决定滚动方向
            const {top} = getScrollThresholds(container)
            if (dragState.mouseY < top) {
                // 向上滚动
                if (!canScrollUp(container)) return stopAutoScroll()
                container.scrollTop -= scrollSpeed
            } else {
                // 向下滚动
                if (!canScrollDown(container)) return stopAutoScroll()
                container.scrollTop += scrollSpeed
            }
​
            // 滚动后鼠标位置不变,但内容位置变化了,需要重新计算插入位置
            updateTargetFromPoint(dragState.mouseY)
​
            // 继续下一帧
            autoScrollFrame = requestAnimationFrame(step)
        }
​
        autoScrollFrame = requestAnimationFrame(step)
    }
​
    /**
     * 停止自动滚动
     */
    function stopAutoScroll() {
        if (autoScrollFrame) {
            cancelAnimationFrame(autoScrollFrame)
            autoScrollFrame = null
        }
    }
​
​
    // ==================== 事件处理 ====================
​
    /**
     * 全局 pointermove 事件处理
     * @param {PointerEvent} e
     */
    function onPointerMove(e) {
        if (!dragState) return
​
        // 更新鼠标坐标
        dragState.mouseY = e.clientY
​
        // 更新幽灵行位置(相对于起始位置偏移)
        const deltaY = e.clientY - dragState.startY
        dragState.ghostEl.style.transform = `translateY(${deltaY}px)`
​
        // 更新插入位置和指示线
        updateTargetFromPoint(e.clientY)
​
        // 根据当前鼠标位置判断是否需要自动滚动
        if (isInAutoScrollZone()) startAutoScroll()
        else stopAutoScroll()
    }
​
    /**
     * 全局 pointerup 事件处理:结束拖拽并触发排序
     */
    function onPointerUp() {
        if (!dragState) return
​
        // 移除全局事件监听
        document.removeEventListener('pointermove', onPointerMove)
        document.removeEventListener('pointerup', onPointerUp)
​
        // 停止自动滚动
        stopAutoScroll()
​
        // 移除幽灵行
        dragState.ghostEl?.remove()
​
        // 隐藏指示线
        if (indicatorEl) indicatorEl.style.display = 'none'
​
        // 清空拖拽行 ID,模板会自动移除源行高亮 class
        draggingIdd.value = null
​
        // 触发排序(仅当目标位置与起始位置不同)
        if (
            dragState.toIdd !== dragState.fromIdd &&
            dragState.toIdd !== dragState.fromNextIdd
        ) onSort(dragState.fromIdd, dragState.toIdd)
​
        // 重置拖拽状态
        dragState = null
    }
​
    /**
     * 拖拽手柄 pointerdown 事件:启动拖拽
     * @param {PointerEvent} e
     * @param {Object} row 当前行数据(需包含主键 Idd)
     */
    function onPointerDown(e, row) {
        // 仅响应鼠标左键
        if (e.button !== undefined && e.button !== 0) return
        e.preventDefault()
​
        // 获取被拖拽的行元素(兼容 el-table 内部结构)
        const rowEl = e.target.closest(rowSelector)
        if (!rowEl) return
​
        // 获取滚动容器(惰性初始化)
        const container = getContainer()
        if (!container) return
​
        // 创建幽灵行
        const ghostEl = createGhost(rowEl)
​
        // 设置拖拽行 ID,用于模板动态绑定高亮 class
        draggingIdd.value = row.Idd
​
        // 创建指示线(如果还没有)
        if (!indicatorEl) {
            indicatorEl = createIndicator(container)
            // 初始设置宽度
            updateIndicatorWidth()
        }
​
        // 拖拽元素在 showGridData 中的索引
        const totalRows = toValue(showGridData)
        const fromIndex = totalRows?.findIndex(r => r?.Idd === row?.Idd)
        // 获取拖拽行的下一行 Idd, 若为最后一行则为 null, 表示插入到末尾
        const fromNextIdd = totalRows?.[fromIndex + 1]?.Idd ?? null
​
        // 初始化拖拽状态
        dragState = {
            fromIdd: row.Idd,
            fromNextIdd,
            toIdd: fromNextIdd,
            startY: e.clientY,
            mouseY: e.clientY,
            ghostEl,
        }
​
        // 注册全局事件
        document.addEventListener('pointermove', onPointerMove)
        document.addEventListener('pointerup', onPointerUp)
    }
​
​
    // ==================== 行 class 计算 ====================
​
    /**
     * 根据当前拖拽状态计算行 class(用于与其它行 class 逻辑合并)
     * @param {Object} params
     * @param {Object} params.row - 行对象
     * @returns {string} 返回 class 字符串,如 'is-dragging-source' 或 ''
     */
    function getDragRowClass({row}) {
        return row.Idd === draggingIdd.value ? 'is-dragging-source' : ''
    }
​
​
    // ==================== 清理 ====================
​
    /**
     * 清理所有资源(组件卸载时调用)
     */
    function destroy() {
        // 移除全局事件监听
        document.removeEventListener('pointermove', onPointerMove)
        document.removeEventListener('pointerup', onPointerUp)
​
        // 停止自动滚动
        stopAutoScroll()
​
        // 清理拖拽状态
        if (dragState) {
            dragState.ghostEl?.remove()
            dragState = null
        }
​
        // 移除指示线
        if (indicatorEl) {
            indicatorEl.remove()
            indicatorEl = null
        }
​
        // 清空拖拽行 ID
        draggingIdd.value = null
​
        // 清理 ResizeObserver 和容器引用
        cleanupResizeObserver()
        containerEl = null
        containerHeight = 0
    }
​
    // 组件卸载时自动清理
    onBeforeUnmount(destroy)
​
    return {
        onPointerDown,
        getDragRowClass,
    }
}
​
export default useVirtualDragSort

8. 使用示例

kotlin 复制代码
import { useVirtualDragSort } from './useVirtualDragSort'
​
const { onPointerDown, getDragRowClass } = useVirtualDragSort({
  rowHeight: 25,
  showGridData,       // Ref,虚拟滚动的窗口数据(含缓冲区)
  scrollContainer,    // ComputedRef<HTMLElement | null>
  onSort: (fromIdd, toIdd) => {
    const data = allData.value
    const fromIndex = data.findIndex(r => r.Idd === fromIdd)
    if (fromIndex === -1) return
​
    const [moved] = data.splice(fromIndex, 1)
​
    if (toIdd === null) {
      data.push(moved)
    } else {
      const toIndex = data.findIndex(r => r.Idd === toIdd)
      if (toIndex === -1) {
        data.splice(fromIndex, 0, moved) // 回退
        return
      }
      data.splice(toIndex, 0, moved)
    }
  },
})

模板:

xml 复制代码
<tr
  v-for="row in showGridData"
  :key="row.Idd"
  :class="getDragRowClass({ row })"
  @pointerdown="onPointerDown($event, row)"
>
  <td>{{ row.name }}</td>
  <!-- 其他列 -->
</tr>

CSS:

css 复制代码
.is-dragging-source {
  opacity: 0.5;
}

9. 边界情况与限制

  • 拖拽行是最后一行fromNextIdd = null,初始 toIdd = null。拖到数据末尾不触发排序。
  • 鼠标拖到内容之外clamp 保证 insertIndex 不越界。
  • 虚拟滚动窗口变化 :每次 pointermove 都重新获取第一个渲染行和窗口数据,所以滚动后依然准确。
  • 行高必须固定 :算法依赖 rowHeight,如果实际行高不一致会导致偏差。
  • 不支持树形结构:本方案面向扁平列表。
  • 坐标计算假设 :滚动容器需设置 position: relative,且没有明显的边框和内边距。若存在这些,offsetTopclientY 的转换会有微小误差,实际项目中可根据需要补偿。

10. 总结

本方案的核心可以浓缩为四句话:

  1. 以稳定 Idd 为锚点:拖拽全程只用 Idd 表示身份,彻底摆脱索引依赖。
  2. 用 offsetTop 简化内容坐标计算 :第一个渲染行的 offsetTop 直接给出内容偏移,与鼠标转换后的内容坐标求差,除以行高四舍五入即得窗口插入索引。
  3. 指示线直接复用同一计算结果firstRowTopInContent + insertIndex * rowHeight 就是指示线的 top,无需额外查询目标行 DOM。
  4. 借助缓冲区精确插入showGridData 天然提供了"窗口末尾下一项"的 Idd,使得拖拽到可视区域边缘时仍能精确定位。

这套方案不仅解决了虚拟滚动下的坐标映射难题,还考虑了交互体验(幽灵行、指示线、自动滚动)和性能(rAF、transform、ResizeObserver)。业务方只需在 onSort 回调中操作完整数据数组即可,无需关心虚拟滚动的复杂性。

相关推荐
hh95015 分钟前
Agent Plan × DeepSeek Harness:角色 Prompt 驱动的 Agent 分工优化与协作质量实验
java·前端·人工智能·prompt·adg·agent plan·adg成都社区
xiaominlaopodaren16 分钟前
three.js最小地图运行时(九):TileKey 线框与 cursor 坐标探针
javascript·gis·three.js
CarIise17 分钟前
JavaScript进阶与轮播图实现 课堂笔记
开发语言·javascript·笔记
专业抄代码选手18 分钟前
05|把递归渲染拆成 Fiber:让一棵大树可以暂停
前端·javascript·react.js
像我这样帅的人丶你还21 分钟前
🚀苹果的液态玻璃咋做?🚀
前端·webgl·three.js
mayaairi22 分钟前
JS DOM节点操作完全指南:增删改查与性能优化
开发语言·javascript·性能优化
天才熊猫君24 分钟前
Vue 3 插槽机制深度解析:两条线与三层树
前端·javascript
爱丶不疚27 分钟前
BrowserWindow:你的Electron 应用可以不用手写红绿灯
前端·electron
李溪白29 分钟前
事件循环(Event Loop):JavaScript 里的“时间管理大师”与“插队狂魔”
javascript