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

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

假设你在开发一个拥有 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 回调中操作完整数据数组即可,无需关心虚拟滚动的复杂性。

相关推荐
AlienZHOU5 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc8 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师10 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen10 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒11 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow11 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81811 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007811 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明11 小时前
第 6 章 组件进阶
前端·vue.js
江华森11 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端