通用虚拟滚动表格行拖拽排序:从思路到完整实现
假设你在开发一个拥有 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)
清理临时状态
核心挑战有两个:
- 身份稳定性:只用 Idd 表示拖拽源和目标,不依赖易变的索引。
- 坐标到 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 缓冲区的价值
totalRows 是 showGridData 的长度,而 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 自动滚动
当鼠标靠近容器上边缘或下边缘时,启动自动滚动。每帧做三件事:
- 判断鼠标是否还在边缘区域,不在就停止。
- 判断是否还能继续滚动(
canScrollUp/canScrollDown),不能就停止。 - 修改
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,且没有明显的边框和内边距。若存在这些,offsetTop和clientY的转换会有微小误差,实际项目中可根据需要补偿。
10. 总结
本方案的核心可以浓缩为四句话:
- 以稳定 Idd 为锚点:拖拽全程只用 Idd 表示身份,彻底摆脱索引依赖。
- 用 offsetTop 简化内容坐标计算 :第一个渲染行的
offsetTop直接给出内容偏移,与鼠标转换后的内容坐标求差,除以行高四舍五入即得窗口插入索引。 - 指示线直接复用同一计算结果 :
firstRowTopInContent + insertIndex * rowHeight就是指示线的top,无需额外查询目标行 DOM。 - 借助缓冲区精确插入 :
showGridData天然提供了"窗口末尾下一项"的 Idd,使得拖拽到可视区域边缘时仍能精确定位。
这套方案不仅解决了虚拟滚动下的坐标映射难题,还考虑了交互体验(幽灵行、指示线、自动滚动)和性能(rAF、transform、ResizeObserver)。业务方只需在 onSort 回调中操作完整数据数组即可,无需关心虚拟滚动的复杂性。