
先抛结论这类光标跳动十有八九不是鼠标、也不是浏览器卡顿造成的而是表格或列表在滚动过程中发生了一次你不知道的重渲染正在编辑的输入框被销毁重建了。Vue 的 diff 算法虽然会尽力复用节点但只要 key 不稳定、组件边界没切开、数据更新时机没控制住输入中的光标就会被打回原位甚至焦点直接丢失。这个“Vue 项目页面滚动时数据栏位光标跳动问题”我在不止一个项目里踩过。最典型的是交易订单录入、库存盘点这类页面一个十几行甚至几十行的可编辑表格用户一边滚动一边改数据页面顶部和底部常常有轮询刷新、滚动到底部加载更多、行内校验联动之类的逻辑。任何一个环节触发了整表重新渲染正在输入的那个单元格光标就开始乱跳。这个问题的难点在于它不是必现的往往滚动速度快一点、接口延迟久一点才出现排查起来特别费劲。1. 问题现象与业务场景1.1 光标跳动的典型表现我实际见到过的光标跳动大概可以分成三类。第一类是光标位置被重置到输入框开头或末尾。用户在中间某个字旁边输入滚了一下页面输入到一半的内容还在但光标突然跑到了最前面继续输入时文字顺序就全乱了。这种最常见本质上是因为 input 这个原生节点被重新创建或者被赋了新的 value浏览器只能把光标放回一个默认位置。第二类是焦点直接丢失。输入框的高亮边框消失再点击一次才能重新输入。这种往往意味着整个单元格甚至整行都被移出了 DOM或者组件被 v-if 临时卸载了焦点自然也就丢了。第三类更隐蔽看起来是光标跳到别的输入框里去了。你明明在第三行输入滚动结束后焦点却落在了第五行同一个字段上。我一般遇到这种第一反应就是行 key 用的是数组下标而在滚动和刷新过程中前面插入了新行diff 算法把“原本第三行”的新节点当成了新创建的行输入实例也被连带换了。对用户来说就是光标“串行”了。1.2 最容易出问题的业务场景不是所有页面都会遇到这个问题。普通静态列表很少踩雷最容易出问题的是下面几类可编辑表格 轮询刷新。后台每 3 秒推送一条价格或状态表格随时在变。滚动加载 行内输入。滚动一旦触发加载前面插入的数据会改变整张表的行索引。行内联动校验。输入一个字段后其他行或该行其他字段通过 computed 或者 watch 实时变化。表格列包含下拉、日期、数字输入等交互组件且这些组件的数据和行对象直接绑定。这些场景有一个共同点都在“编辑进行中”的时候页面外部环境还在持续更新数据。数据源一变Vue 的响应式系统会通知所有依赖这条数据的组件重新渲染光标跳动就成了必然。2. 根因分析滚动时到底发生了什么2.1 滚动、数据刷新与组件重渲染的连锁关系很多人一开始会怀疑滚动是浏览器原生行为怎么会影响 Vue 组件呢滚动本身确实不直接触发渲染但页面上的滚动监听会。很多表格组件会在滚动事件里做“懒加载”“预加载”“触底刷新”之类的事比如判断 scrollTop 是否接近 scrollHeight然后发起请求。请求返回后数据更新组件重新渲染。这个链路才是光标跳动的真正源头。还有一种情况是业务代码里的定时器。页面在展示数据列表时用 setInterval 定期去拉最新数据滚动只是恰好发生在某次数据回来的时候。用户感知上就是“一滚就跳”实际上跳的原因是那次隐藏的刷新。2.2 key 用不对diff 就会把输入框当成新节点这是最核心的技术点。Vue 更新列表时会拿新旧 vnode 的 key 做对比。如果 key 是数组索引 index那么列表一旦发生任何位置变化比如第一条数据被替换、前面插入了新记录、列表被排序从变化位置开始的所有节点都会被认为是“不同的节点”。diff 算法怎么处理不同节点旧的卸载新的创建。对于 input 这个 DOM 元素来说卸载再创建就等于把正在编辑的输入框彻底换掉。浏览器不会给一个新创建的 input 保留原来的光标位置结果就是你眼看着光标闪了一下然后跳走了。即使列表数据没有任何变化只要整张表因为父组件重新渲染而又跑了一遍 v-for并且 key 用 index 或一个会变化的随机数Vue 也可能无法复用原有元素导致输入框重建。所以稳定 key 是解决这个问题的第一道防线。2.3 v-model 数据链路在异步更新中插一脚除了节点重建v-model 本身也可能埋雷。六、有人在表格里写input v-modelrow.price /row 是行对象price 是后台要更新的字段。后台刷新时如果直接改了 row.price那么 Vue 响应式系统会把新值写入 input 的 DOM value 属性。DOM value 被外部赋值后光标位置通常会跳到末尾或者丢失原来的选区。这就是为什么有些人即使加了 key光标还是跳。更隐蔽的是 v-model 绑定到一个 computed 中间层。滚动时某个状态变量变了computed 重新求值内容变了输入框跟着刷新。即使内容实际没变只要 computed 返回了新引用也可能触发更新。所以排查过程中一定要把“数据更新链路”和“节点 diff”分开看两个因素可能同时在起作用。3. 修复方案与取舍3.1 先建立更新边界别和底层 diff 对抗我的核心思路是不要去设法“冻结 DOM”也不要去想办法让 diff 算法记住光标位置因为浏览器根本不支持这个能力。应该从三个方面入手让 Vue 能稳定复用节点、把输入状态隔离在独立组件里、从源头减少滚动期间的重渲染压力。这三个方向不是三选一而是要组合。只加 key 不拆组件表格内部大范围更新仍然可能导致输入框属性被覆盖只拆组件不去管刷新时机滚动加载时列表数量变化依然会引起重排只暂停刷新不加 key一旦用户手动触发重新排序还是会出现焦点漂移。所以实际项目里我一般是按顺序都做一遍。3.2 用稳定 key 给每一行一个固定身份先说最简单的修改给每个列表项一个稳定且唯一的 key。template tr v-foritem in list :keyitem.id td input v-modelitem.price / /td /tr /template如果你用的 UI 框架是 Element Plus表格组件已经支持行 keyel-table :datalist :row-keyrow row.id这里有一个关键点row-key 必须选业务主键不能是数组索引也不能是可能重复的字段。如果数据里没有唯一 id可以在接口返回后统一补一个_rowKey字段用随机的字符串或自增数都可以只要保证这条记录在生命周期内不变就行。注意加了稳定 key 之后不要再通过“index 找到第几行”这种方式去改数据。因为插入新行后index 代表的业务数据已经漂移。正确做法是找记录 id。3.3 把可编辑单元格拆成独立组件来隔离焦点稳定 key 解决了“节点尽可能复用”但还没解决“父组件逻辑更新导致 input 属性被赋值”的问题。更好的做法是拆出一个独立输入组件由它来管理输入过程中的本地状态。拆组件的作用是缩小更新范围。父表格再怎么重渲染如果这个单元格组件没有被销毁并且组件内部没有把外部值实时响应式地绑定到 input 上焦点就不会被动摇。你可以把它理解成给每个格子装了一个“保护壳”外部值只是初始值用户录入的内容先存在组件自己的局部变量里等失去焦点或者点保存时才回传。这样用户正在录入时无论外面刷新多少次输入框的值都不会被外部数据覆盖光标自然也就稳了。3.4 滚动过程中暂停高频刷新减轻重渲染源编辑态的稳定性也可以靠“降低更新频率”来实现。页面滚动过程中用户本来就很难进行精确录入这时候暂停接口轮询或懒加载数据让滚动结束后再刷新体验反而更好。我一般会在滚动容器上挂一个带防抖的监听滚动停顿 200 毫秒左右再允许刷新。这样可以避免连续滚动时频繁触发接口也能大幅减少无意义的重渲染。let scrollTimer null const scrollSettled ref(true) function onScroll() { if (scrollTimer) clearTimeout(scrollTimer) scrollSettled.value false scrollTimer setTimeout(() { scrollSettled.value true }, 200) } watch(scrollSettled, (val) { if (val) { fetchLatest() } })这个方案不能单独使用但它和 key、组件边界配合起来效果非常好。4. 一个 el-table 场景的完整修复实操4.1 先看原始写法错得最典型的地方我拿之前处理过的一个交易行情录入页面来演示。页面有一张可编辑表格每行有“价格”“数量”“备注”三个输入框顶部有一个每两秒刷新一次的行情定时器滚动时会触发下一页加载。最初的核心代码大概是这样的template div classtable-wrap scroll.passiveonScroll el-table :datalist el-table-column label价格 template #default{ row } input v-modelrow.price / /template /el-table-column el-table-column label数量 template #default{ row } input v-modelrow.qty / /template /el-table-column /el-table /div /template这个写法至少有两个问题表格没有行 keyinput 直接 v-model 绑定行数据。用户滚动时只要列表加载了新数据或行情接口刷新了 price 字段整行的 input 就会被重新处理。现场用户描述就是“我在改第二行的价格手还没离开键盘光标就跑了后面打进去的数字全跑到开头去了。”4.2 第一步给表格加 row-key修复行身份漂移先把行身份钉死让每一行都跟着业务记录走而不是跟着数组下标走。el-table :datalist :row-keyrow row.id 改完后老用户反馈“光标串到其他行”的问题立刻缓解因为表格组件在更新时会尝试复用原来的行组件。但还没彻底好行情刷新 price 字段时输入框的 value 还是会被直接覆盖导致光标仍然抖动。这说明 key 只是基础不是全部。4.3 第二步拆分 CellInput把输入焦点锁在组件内部我新建了一个CellInput.vue组件专门处理单元格内的输入template input refinputRef :valuedraft inputhandleInput blurcommit / /template script setup import { ref, watch } from vue const props defineProps({ modelValue: { type: [String, Number], default: } }) const emit defineEmits([update:modelValue, change]) const inputRef ref(null) const draft ref(props.modelValue) watch(() props.modelValue, (val) { // 当前输入框不处于聚焦状态时才同步外部值 if (document.activeElement ! inputRef.value) { draft.value val } }) function handleInput(e) { draft.value e.target.value emit(update:modelValue, draft.value) } function commit() { if (draft.value ! props.modelValue) { emit(change, draft.value) } } /script父表格里这样用el-table-column label价格 template #default{ row } CellInput v-modelrow.price / /template /el-table-column这个组件内部维护了一个draft本地值。用户聚焦时输入框展示和编辑的都是draft不会被外部的modelValue响应式更新直接打乱。只有输入框失焦时才把最终值提交回行数据。提示这里的关键判断是document.activeElement ! inputRef.value保证外部数据变化不会影响正在编辑的输入框。这个判断虽然依赖 DOM API但在浏览器环境下足够可靠适配移动端时要注意 input 聚焦行为差异。改完这一步之后用户录入过程中光标已经不跳了。但还有一个隐藏问题滚动到底部加载数据时新数据会把当前视图内的行挤下去用户正在看的那一行整行移出视野输入框虽然不会跳动但视觉上像“连人带椅子被挪走”。这个不属于光标问题但这种交互体验需要靠后续的滚动暂停刷新来做。4.4 第三步滚动期间冻结接口刷新从源头减负我给滚动容器加了防抖并在滚动期间暂停两秒一次的行情定时器let scrollTimer null let pollTimer null function onScroll() { if (scrollTimer) clearTimeout(scrollTimer) stopPolling() scrollTimer setTimeout(() { startPolling() }, 300) } function startPolling() { if (pollTimer) return pollTimer setInterval(fetchLatest, 2000) } function stopPolling() { if (pollTimer) { clearInterval(pollTimer) pollTimer null } }这样用户滚动时不再有后台接口频繁“拆台”页面整体渲染压力也小了很多。滚动结束 300 毫秒后恢复刷新数据也不会滞后太多。4.5 修复后的自测清单修复不能只看“鼠标滚一下没跳”就算完我一般会按下面的清单自测快速滚动观察正在编辑的输入框光标是否保持原位。滚动加载数据后焦点是否还留在原来的业务记录上。在输入中间字符时让后台推送一次数据确认输入内容不会被回填。连续滚动再立即点击输入框确认点击不会被后续渲染打断。用浏览器 Performance 面板录制一次滚动观察是否还有大面积的表格组件更新。如果每一项都通过这个问题的修复才算真正闭环。5. 问题排查与踩坑复盘5.1 一套快速的排查流程如果你接手的是别人留的烂摊子建议按这个顺序查第一步确认是否有稳定行 key。没有就先加80% 的“光标跳到别的行”问题会消失。第二步检查 input 是否直接绑定响应式对象字段。是的话先拆组件隔离本地编辑态。第三步打开 Vue DevTools点击滚动触发后的状态变化观察是哪个数据源导致表格刷新。一般会发现要么是接口轮询要么是某个 loading 状态被改成了 true 然后又被改回 false。第四步把表格组件的更新范围缩小。查看开发工具里的高亮重渲染区域如果整个表格都闪说明数据都收集在父级组件上必须对单元格做拆分。第五步实在定位不了就在 input 上临时挂一个自定义属性或打印 event看它在滚动前后的 dom 引用是否变化。5.2 常见问题与处理方向速查表现象可能原因处理方向光标回到输入框开头input 被重新赋值拆独立组件本地管理 draft失焦再提交焦点直接丢失整行组件被销毁重建检查 v-if、组件切换逻辑改用稳定 key光标跳到其他行行 key 不稳定或索引漂移用业务主键做 row-key避免依赖 index输入内容回退v-model 随异步刷新被改写聚焦期间禁止外部值同步或暂停刷新多个行同时出现编辑态数据源被共享多个组件监听同一对象检查列表数据的引用关系使用浅拷贝隔离滚动加载后整屏闪烁新数据插入导致大面积重排表格虚拟化或滚动期间暂停加载逻辑5.3 只有压测才能发现的隐藏坑有些问题在数据量小的页面上根本看不出来一上生产数据量就崩。我遇到过两次都是表格数据从几十条变成几百条滚动时 caption 数据频繁更新最后发现是组件内部 watch 里同一个字段被多处修改每次修改都触发了一次完整的单元格更新。后来我把 watch 改成深层标记对比并且只关注真正变化的字段渲染次数一下就降下来了。还有一个坑和虚拟滚动有关。如果你的表格用了虚拟列表行节点会被滚动复用意思是“第三行的 DOM 实例”滚动后可能用来渲染“第十行”。这种情况下单元格组件内部的本地 draft 如果不清理用户输入的内容可能会串到其他行。解决方案是给虚拟列表的单元格组件强制绑定一个变化的 key或者监听行数据变化时重置本地值。最后分享一个排查时的土办法给输入框的>