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) // 整段重包
根因:动画对象每次重建 = 首帧必闪
问题链四步:
v-html更新 =innerHTML整段替换 → 上一轮包的 span 全部销毁- 每个 chunk 重建 span = 新建 CSS 动画对象
- 浏览器对新建动画对象有首帧渲染跳变(即使负 delay 指向中间帧也一样)→ 每 120ms 闪一次
- 把动画时长压到 < 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 管不到。
走过的弯路(真金白银)
- 删功能止血:直接把渐变默认关掉 + 移除选择器——不闪了,但用户质问"你修复闪烁就是删了瀑布渐变?"。修 bug 不能以牺牲效果为代价,这是最差修法
- 压动画时长:fadeMs 180→100ms,赌"动画播完再被冲"——没用,问题在重建本身
- 负 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 条真金白银)
- 删功能止血是最差修法:修 bug 不能以牺牲效果为代价,用户会直接质问
- 压动画时长无效:fadeMs < chunk 间隔也躲不掉——问题在动画对象重建本身
- 负 delay 救不了首帧:CSS 动画负 delay 理论跳到中间帧,新建动画对象的首帧渲染仍有跳变
- intent 块没有 status 字段:flow block 的 status(running/generating)只赋给 tool 块,intent 只有 type/text——用 status 判断"正在流式"会永远 false。正确判断:
flow.status === 'running' && blocks[blocks.length-1] === b(SSE 增量追加在末尾) - 模板内联 ref 每次渲染先解绑再重绑:
(el) => registerLiveIntent(el, gIdx)每次渲染都是新函数,Vue 会先传 null 再传 el → seenLen 归零 → 全文重 append。解决:同 el 重绑保留 seenLen + flush 以el.textContent.length为基准续传,双保险 - watch 引用 TDZ:
watch(blockGroups, ...)写在const blockGroups = computed(...)之前直接 ReferenceError,必须挪到声明之后 - 裸 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 聊天界面的朋友,求个点赞求个关注。
下一篇预告:同样是"动画"坑——流式输出时表格为什么边吐边抖?列宽重算 + 动画叠加的取舍,和这篇是同一类问题的另一面。