一次鼠标交互触发两次 Vue flushJobs:把高频交互从 VDOM 里拆出来

现象一句话版:在 5ms 帧预算下滚动 / 悬停 K 线,每一帧的 requestAnimationFrame 前后各出现一段 Vue flushJobs,合起来吃掉约 16% 的预算------但它们本来都不该存在。

这篇文章记录一次完整的性能排查与架构收敛:默认 K 线 tooltip、鼠标坐标、hover 光标、Pane 头部、主图图例,这些高频交互状态是如何一遍遍穿过 Vue VDOM 的,以及如何把它们逐条挪回「RAF 内直接更新 DOM」的路径。


一、现象:每一帧,Vue 被唤醒两次

对 K 线图做滚动或悬停时,用 Performance 录制,每帧的时序大致是:

text 复制代码
pointermove 事件任务
  └─ Vue flushJobs        ← 帧前第一段
requestAnimationFrame
  └─ Core 绘制、发布帧快照
  └─ Vue flushJobs        ← 帧后第二段
浏览器 layout / paint

在 5ms 帧预算下,前后两段合计约 0.79ms,接近预算的 16%。单看每次都不大,但它是每帧固定发生的,属于可以也必须消掉的结构性开销,而不是偶发抖动。

关键问题不是「某次 setProperty 很慢」,而是:一次鼠标交互,为什么 Vue 被更新了两次?

二、根因:一笔交互穿过两套调度器

拆开调用栈后,两段 flushJobs 的来源完全不同:

  • 帧前那段 :pointermove 事件处理器里,把鼠标坐标写进了 Vue ref。事件任务结束时 Vue 排队一个微任务。
  • 帧后那段 :Core 在 requestAnimationFrame 内完成绘制并发布帧快照,Vue 侧订阅了这些快照,再把状态写回 Vue ref。RAF 回调结束前又排一个微任务。

也就是说,同一次鼠标移动,被拆成了「事件阶段」和「RAF 阶段」两次独立的 Vue 更新。而这两次更新驱动的是同一批 UI:tooltip 位置、hover 光标、Pane 高亮。

核心矛盾在于:这些状态的高频性(每帧都变)和它们所在的位置(Vue VDOM)不匹配。Vue VDOM 的 diff 与 patch 是为「低频、语义化、可组合」的 UI 设计的;把它用在「每帧都在变的坐标和文本」上,就必然付出重复的队列与 patch 成本。

三、逐条收敛:高频状态离开 VDOM

思路很朴素:高频路径只更新 Canvas 或直接写 DOM,Vue 只处理低频业务状态。 逐条看我们在 KLineChart 里移出了什么。

3.1 默认 K 线 tooltip:直接订阅 kernel

默认 tooltip 的内容(开高低收、成交量等)本来就有一条「绕过 VNode」的直接 DOM 路径。我们把它的显示与定位也接上这条路径:

  • 位置由 interactionState 快照直接写 el.style.left / top
  • 内容按 hoveredIndex 增量更新 DOM 节点
  • 不再有任何 Vue ref 参与默认 tooltip

于是默认 tooltip 完全从 VDOM 里消失。

3.2 鼠标坐标:普通变量

pointermove 里原本写 mousePos.value(Vue ref),每帧触发一次 flush。改成普通对象 let mousePos 后,只在「确实要移动 marker tooltip」时才去写 DOM。事件阶段不再产生 Vue 微任务。

3.3 hover 光标与 Pane 状态:classList / style 直写

interactionState 里 isDragging、hoveredIndex、hoveredPaneBoundaryId 这些字段原本整包写进 Vue shallowRef,让整个 KLineChart 模板进入更新队列。现在直接在订阅回调里:

typescript 复制代码
stage?.classList.toggle('is-dragging', next.isDragging)
container.style.cursor = next.hoveredIndex !== null ? 'pointer' : 'crosshair'
line.classList.toggle('is-active', line.dataset.paneId === next.hoveredPaneBoundaryId)

RAF 后只改 class 和 style,不再唤醒 Vue。

3.4 viewport:不再整包进入 Vue

滚动时 Core 每帧更新 visibleFrom / visibleTo。Vue 侧却订阅了整个 viewport 对象------即使页面真正用到的 zoomLevel 没变,对象引用每帧都是新的,照样触发一次 flush。

把整包订阅改成按字段投影 + 值相等短路 :Vue 只订阅低频的 zoomLevel,visibleFrom / visibleTo 留在 Core。区间选择 overlay 需要跟随滚动时,才在「确实存在选区」时临时订阅 viewport。

typescript 复制代码
export function useControllerSignalValue(controllerRef, select, project, fallback) {
  // 订阅 Core 信号,仅当 project() 结果 Object.is 不等时才写 Vue ref
  const sync = () => {
    const next = project(signal.peek())
    if (Object.is(snapshot.value, next)) return
    snapshot.value = next
  }
}

3.5 主图图例:回到 Canvas

最后排查到残留的 set textContent,根因是预览页启用了 #legend 自定义 slot。图例的 OHLC、成交量、指标数值随 hover 每帧变化,Vue 就得每帧 patch 这些文本。

默认图例本来就有 Canvas 绘制路径,于是:

  • 预览页移除 #legend,回到 Canvas 图例
  • 对外暴露类型化配置对象 legend,控制 visible 与 visibleIndicatorIds
  • #legend slot 保留给「确实需要完全自定义 DOM」的调用方,但作为显式的高频 opt-in
vue 复制代码
<KLineChart :legend="{ visible: true, visibleIndicatorIds: ['MA', 'BOLL'] }" />

四、边界:什么是「合理的高频开销」

把默认路径清干净之后,我们保留了两类「允许进 VDOM」的高频更新,作为调用方显式选择的 opt-in:

  • #kline-tooltip / #marker-tooltip / #legend:调用方要用 Vue 模板自定义这些 UI,就得接受高频 patch 成本。
  • 区间选择 overlay:只有确实存在选区时才会临时订阅滚动。

原则是:默认路径不付高频 VDOM 的账;调用方明确要自定义时才承担。 高频渲染数据与低频业务/UI 状态不再共用一个组件更新边界。

五、沉淀下来的三条原则

  1. 高频路径只更新 Canvas / DOM,别让状态穿过 VDOM。 坐标、光标、hover、tooltip 这类每帧变化的状态,直接操作 DOM 是成本最低、也最符合渲染本质的做法。
  2. 按字段订阅,而不是整包订阅。 一个高频对象里往往只有低频字段被页面真正用到;值相等短路能挡住大量无意义的 Vue 无效刷新。
  3. 把高频默认路径与高频 opt-in 分开。 默认行为要快;扩展能力要显式。让调用方在「要快」和「要自由」之间自己选。

六、结语

这个问题的表象是「每帧多两段微任务」,本质是高频交互状态被放在了错误的渲染层里。修复不是压缩某次 patch 的时间,而是把状态搬到对的位置:默认路径直接操作 DOM,Vue 只处理真正需要它的事件。


相关代码:KLineChartQuant(Canvas / WebGL / WebGPU 混合渲染的金融图表库),改动位于 packages/vue 的 KLineChart 组件与 packages/core 的主图图例渲染器。

相关推荐
小万74228 分钟前
God‘s Eye View 深度解析:15 路公开信号 + CesiumJS 的浏览器级 OSINT 控制台,60 帧渲染的“真实间谍卫星模拟器”
前端
IT星宿34 分钟前
浏览器标签页全是一个图标:我用名字哈希生成 favicon,让每个项目都有身份标识
前端
全栈项目管理程序猿44 分钟前
ArcGIS JS 基础教程(32):体元系列 - VoxelSlice 体元切片
前端·javascript
拖孩3 小时前
AI 帮我写的小程序「厨菜记」上线满月了,挣了两百块!!!
前端·后端·微信小程序
用户360055579003 小时前
Kestrel_VLLM - 13 · 为什么排除 MoE FFN Tiling 与 Tensor Core
前端
流烟默3 小时前
SSE 实战:在 SpringBoot + Vue 项目中零依赖实现轻量级实时消息推送
vue.js·spring boot·后端·sse
Hilaku3 小时前
AI 流式输出都用 SSE,什么时候必须换 WebSocket?
前端·javascript·程序员
奇牙coding3 小时前
Codex CLI 升级后多文件编辑静默失效:config.yaml 枚举变更排查与修复指南
java·前端·javascript·ai
小羊没烦恼!3 小时前
Ajax与JSON的一些总结
android·java·前端·数据库·mysql·算法·okhttp
程序员老赵3 小时前
Docker 部署 Filestash:轻松搭建浏览器文件管理平台
前端·docker·开源