流式瀑布渐变闪烁根治-硬核技术版

0 阅读6分钟

v-html 整段重渲染导致 CSS 动画闪烁:流式瀑布渐变的增量 DOM 渲染根治(附 7 个踩坑)

摘要:给 AI 聊天加"流式瀑布渐变"时,用 v-html 整段重渲染 + CSS 动画会导致每 120ms 闪一次。根因是每次 chunk 更新都会销毁并重建动画对象,触发浏览器首帧渲染跳变。本文给出根治方案——流式中改用增量 DOM 渲染:每 chunk 只 append 新文本为渐变 span,动画对象只建一次、永不重建,流结束后再一次性渲染 markdown。附 7 条真金白银的踩坑清单与可复用的判断框架,代码来自开源项目 Yosuri。

在这里插入图片描述

硬核技术 · 代码全部来自 Yosuri(开源 AGPL-3.0)的桌面端聊天主链路

痛点开场

给 AI 聊天加"流式瀑布渐变"(仿 ChatGPT 那种新字符逐批淡入的尾巴),实现完一测:每 120ms 闪一下。压动画时长、负 delay 恢复进度、关掉重试……全试过,还是闪。修了一天,最后发现是"动画对象重建"这类 bug 的教科书案例。

背景:主链路是 v-html 整段重渲染

主链路走 agentflow(SSE + 四态机工作流),回答渲染在 AgentWorkflowPanel.flow-intent.markdown-body

<div class="flow-intent markdown-body" v-html="group.html"></div>

SSE 每 ~120ms 一个 chunk,group.html 整段重渲染。想加渐变 = 给文本包 .stream-fade-seg span 做 CSS 动画(opacity 0→1 淡入),上一轮已经写好的逻辑:

// 每 chunk 把文本重新包成 span,用负 animation-delay 恢复各字符已播进度
node.parentNode.replaceChild(frag, node)  // 整段重包

根因:动画对象每次重建 = 首帧必闪

问题链四步:

  1. v-html 更新 = innerHTML 整段替换 → 上一轮包的 span 全部销毁
  2. 每个 chunk 重建 span = 新建 CSS 动画对象
  3. 浏览器对新建动画对象有首帧渲染跳变(即使负 delay 指向中间帧也一样)→ 每 120ms 闪一次
  4. 把动画时长压到 < chunk 间隔(180→100ms)也没用:只要"动画对象重建"存在,首帧跳变就存在
❌ v-html 整段重渲染 + CSS 动画 = 必闪

chunk 1 ──► [span A] 动画播放中
chunk 2 ──► v-html 冲掉 span A ──► 重建 span A'(新动画对象)──► 首帧跳变 = 闪
chunk 3 ──► v-html 冲掉 span A' ──► 重建 span A'' ──► 又闪

负 delay 的原理没错(跳到中间帧),但"新建动画对象的首帧渲染"这一环在浏览器实现里就是会跳一下,负 delay 管不到。

走过的弯路(真金白银)

  1. 删功能止血:直接把渐变默认关掉 + 移除选择器——不闪了,但用户质问"你修复闪烁就是删了瀑布渐变?"。修 bug 不能以牺牲效果为代价,这是最差修法
  2. 压动画时长:fadeMs 180→100ms,赌"动画播完再被冲"——没用,问题在重建本身
  3. 负 delay 恢复进度:理论上无缝,实测首帧还是跳——浏览器新建动画对象的行为,CSS 层救不了

结论:v-html 整段替换这条路走不通,必须换渲染架构。

正确解法:增量 DOM 渲染

思路一句话:流式中不让 v-html 碰正文,每 chunk 只把新文本 append 成渐变 span——动画对象只建一次、永不重建、播完拆回纯文本。流结束后一次性 v-html 渲染整段 markdown(无动画,也不闪)。

✅ 增量 DOM 渲染 = 不闪

chunk 1 ──► append [span A](动画播一次,animationend 拆回纯文本)
chunk 2 ──► append [span B]A 已是纯文本,不受影响)
chunk 3 ──► append [span C]
……       每个 span 独立动画、互不销毁

核心代码(Vue 3 SFC)

const liveIntentMap = new Map() // gIdx -> { el, seenLen }

// 模板内联 ref 每次渲染都会先解绑(null)再重绑(el):同一个容器重绑必须保留 seenLen
function registerLiveIntent(el, gIdx) {
  if (!el) { liveIntentMap.delete(gIdx); return }
  const prev = liveIntentMap.get(gIdx)
  if (prev && prev.el === el && prev.seenLen > 0) return
  liveIntentMap.set(gIdx, { el, seenLen: 0 })
}

function flushLiveIntent(group, gIdx) {
  const st = liveIntentMap.get(gIdx)
  if (!st || !st.el) return
  const text = group.text || ''
  // 以容器现有文本总长为基准续传:即使 seenLen 被重置(gIdx 移位/ref 重绑)也不重复 append
  const existing = st.el.textContent.length
  if (existing >= text.length) return
  const fresh = text.slice(existing)
  st.seenLen = text.length
  if (!fresh) return
  const span = document.createElement('span')
  span.className = 'stream-fade-seg'
  span.textContent = fresh
  span.addEventListener('animationend', () => {
    const p = span.parentNode
    if (p) p.replaceChild(document.createTextNode(span.textContent), span)
  }, { once: true })
  st.el.appendChild(span)
}

// 注意:必须在 blockGroups computed 声明之后注册(闭包引用,避免 TDZ)
watch(blockGroups, (groups) => {
  groups.forEach((g, gIdx) => {
    if (g.type === 'visible' && g.live) flushLiveIntent(g, gIdx)
  })
}, { deep: true })

模板侧的关键切换:

<div v-if="group.type === 'visible'" class="flow-intent markdown-body"
  :class="{ 'flow-intent-live': group.live }"
  v-html="group.live ? undefined : group.html"
  :ref="group.live ? (el) => registerLiveIntent(el, gIdx) : undefined"></div>
  • live = 还在流式输出:v-html=undefined → Vue 不碰子节点,append 的 span 安全
  • live 结束:v-html=group.html → 一次性渲染整段 markdown,无动画不闪
  • 渐变 span 复用现有 CSS(.stream-fade-seg + @keyframes om-stream-fade),零新样式

踩坑清单(7 条真金白银)

  1. 删功能止血是最差修法:修 bug 不能以牺牲效果为代价,用户会直接质问
  2. 压动画时长无效:fadeMs < chunk 间隔也躲不掉——问题在动画对象重建本身
  3. 负 delay 救不了首帧:CSS 动画负 delay 理论跳到中间帧,新建动画对象的首帧渲染仍有跳变
  4. intent 块没有 status 字段:flow block 的 status(running/generating)只赋给 tool 块,intent 只有 type/text——用 status 判断"正在流式"会永远 false。正确判断:flow.status === 'running' && blocks[blocks.length-1] === b(SSE 增量追加在末尾)
  5. 模板内联 ref 每次渲染先解绑再重绑(el) => registerLiveIntent(el, gIdx) 每次渲染都是新函数,Vue 会先传 null 再传 el → seenLen 归零 → 全文重 append。解决:同 el 重绑保留 seenLen + flush 以 el.textContent.length 为基准续传,双保险
  6. watch 引用 TDZwatch(blockGroups, ...) 写在 const blockGroups = computed(...) 之前直接 ReferenceError,必须挪到声明之后
  7. 裸 span 无段落:增量期间容器里是裸 span(无 <p>),换行要 white-space: pre-wrap 才可见,结束后 markdown 段落样式接管

可复用的判断框架

"动画对象重建"是一类 bug 的根源:凡是 v-html / innerHTML 整段替换 + CSS 动画的组合,动画对象每次重建都有首帧跳变。两个出口:

  • 不要整段替换 → 增量 DOM(本文方案)
  • 不要动画 → 纯文本直出(阉割效果,最后的选择)

改代码前先分清效果实现:用户要的是瀑布渐变(效果),v-html 重包 span 只是其中一种实现;实现走不通就换实现,别砍效果。

项目引流

这套代码跑在 Yosuri(开源 AGPL-3.0)桌面端——一个把免费模型池、Agent 工作流、多文件工程交付、记忆系统装进一个聊天窗口的本地 AI 客户端。核心的流式渲染、增量 DOM、四态机 agentflow 全部开源(GitHub:Rescenix/Yosuri),直接读源码就能看到本文方案的完整落地(含更多踩坑注释)。

写在最后

这篇修了一天:先删功能止血被骂,再压时长失败,负 delay 理论想通实测还是闪,最后换渲染架构才落地。代码都是真机验证过的。觉得有用,转发给写 AI 聊天界面的朋友,求个点赞求个关注。

下一篇预告:同样是"动画"坑——流式输出时表格为什么边吐边抖?列宽重算 + 动画叠加的取舍,和这篇是同一类问题的另一面。