虚拟滚动在编辑器中必须采用固定行高、position: relative/absolute 定位、节点池复用及 requestanimationframe 节流,确保光标不丢失、回车不错位、粘贴不崩溃。

直接渲染上万条数据,HTML 编辑器必然卡死——不是代码写得不够好,而是浏览器根本处理不了那么多 DOM 节点。虚拟滚动是刚性需求,但编辑器场景比普通列表更敏感:光标定位、焦点管理、内容editable、行内富文本都会放大实现偏差。关键不在“能不能滚”,而在“光标会不会丢”“回车是否错位”“粘贴后是否崩溃”。
为什么 contenteditable + 虚拟滚动特别容易翻车
普通虚拟列表只管渲染和定位,而编辑器要维持 contenteditable 的状态链路:selection、focus、composition、input 事件都依赖真实 DOM 节点存在且稳定。常见错误现象包括:
- 滚动后光标跳到顶部或消失
- 在非首屏行按
Enter,新行插入到可视区顶部而非当前光标位置 - 粘贴多行文本时,只渲染了前几行,后面内容被截断或错位
-
getSelection().getRangeAt(0)返回的commonAncestorContainer指向已卸载的节点,抛出DOMException: The object is in an invalid state.
根本原因:编辑器不能简单地用 innerHTML = '' 清空再重写;也不能靠 transform: translateY 把整块内容“漂移”过去——光标坐标系会失准。必须让每一行 DOM 元素在生命周期内保持可寻址、可聚焦、可 selection 更新。
固定行高 + position: relative + 真实节点复用是唯一稳态路径
动态行高在编辑器里基本不可行:哪怕只测一次 getBoundingClientRect(),滚动中反复触发就会 layout thrashing;而预估高度又无法应对 font-family 切换、zoom 变化、图片加载完成等场景。所以编辑器虚拟滚动必须强制固定行高(如 line-height: 1.5; min-height: 24px;),并采用以下组合:
- 外层容器设
position: relative; overflow-y: auto;,高度固定 - 占位
<div class="spacer"> 设 <code>height: ${totalItems * itemHeight}px,不参与编辑 - 真实内容区用
position: absolute; top: ${startIndex * itemHeight}px; width: 100%;,而非transform - 节点池预先创建 20–30 个
<div contenteditable="true">,每次滚动只更新 <code>textContent和data-index,不销毁不重建 - 每次更新前调用
contentEl.replaceChildren(...nodePool.slice(start, end)),避免事件监听器残留
注意:top 定位比 transform 更可控——它不改变元素在文档流中的几何位置,selection API 计算坐标时不会偏移;Chrome 115–122 中 transform 触发额外合成层的问题也会规避。
scroll 监听必须绑定在容器,且节流到 requestAnimationFrame
编辑器不允许滚动帧率掉到 30fps 以下,否则输入延迟感极强。不要用 setTimeout 或防抖,必须用 requestAnimationFrame 对齐屏幕刷新节奏:
let pendingScroll = false;
container.addEventListener('scroll', () => {
if (!pendingScroll) {
pendingScroll = true;
requestAnimationFrame(() => {
const scrollTop = container.scrollTop;
const startIndex = Math.floor(scrollTop / itemHeight);
updateVisibleRange(startIndex);
pendingScroll = false;
});
}
});
还要注意两个隐藏坑:
- 用户用鼠标滚轮快速滚动时,
scrollTop可能跳变,需用Math.min(endIndex, totalItems - 1)防越界,否则nodePool.slice()会返回空数组,导致内容区空白 - 编辑器常有缩放(
zoom或transform: scale()),缩放后必须重新读取clientHeight和itemHeight,建议监听resizeObserver并重算参数 - Mac 上
overflow-y: auto在内容变化时可能触发滚动条宽度闪动,导致 layout shift,应统一设为overflow-y: overlay(仅 Safari 支持)或用 CSS 自定义 scrollbar 占位
光标同步和键盘导航必须手动接管
虚拟滚动后,原生 selection 不再自动跟随滚动位置。必须监听 keydown(尤其 ArrowUp/ArrowDown/PageUp/PageDown)并做干预:
- 按下方向键时,先判断光标是否在首/尾行;若即将移出可视区,提前触发一次
scrollTo({ top: nextTop }),再让原生 selection 移动 -
Home/End键不能只操作当前行,要计算目标行索引并强制滚动到位后再设置 selection - 每次
input或selectionchange后,检查getSelection().focusNode是否属于当前渲染节点池,如果不是,立即补全对应行并重置 selection
最易被忽略的是:编辑器里“看不见的行”不是“不存在”,而是“尚未挂载”。一旦光标被拖拽到非可视区,或通过键盘跳转到远端行,你必须立刻加载那行并插入 DOM 池——不能等滚动触发才响应。这个逻辑一旦漏掉,用户就会觉得“编辑器不认我点的地方”。











