问题场景
你在做一个 AI 聊天应用。服务端通过 SSE(Server-Sent Events)一个字一个字地吐内容,前端逐步拼接显示------就像 ChatGPT 打字的效果。
同时,你需要用 MathJax(或任何第三方库)把文本中的 LaTeX 公式渲染成漂亮的数学符号。
看起来很简单,对吧?
一、天真的实现和它的灾难
1.1 最直觉的写法
vue
<template>
<div>
<span v-html="message" ref="content"></span>
<span v-if="isStreaming" class="cursor">|</span>
</div>
</template>
<script>
export default {
props: {
message: String, // 随流式输出不断增长
isStreaming: Boolean // 是否还在接收中
},
updated() {
// 每次内容变了就跑一次 MathJax
if (window.MathJax) {
window.MathJax.typesetPromise([this.$refs.content])
}
}
}
</script>
1.2 灾难是怎么发生的
假设 AI 返回:根据勾股定理 $a^2 + b^2 = c^2$,所以...
时刻1: message = "根据勾股定理 $a^2 + b^2 = c^2$"
→ Vue 设置 innerHTML
→ MathJax 把 "$a^2 + b^2 = c^2$" 转换成一堆 <mjx-container> 节点
→ 页面显示精美公式 ✅
时刻2: message = "根据勾股定理 $a^2 + b^2 = c^2$,所以"
→ Vue 发现 message 变了
→ Vue 用新的原始字符串重写 innerHTML ← 💥 灾难在这里
→ MathJax 之前生成的 <mjx-container> 全部被销毁
→ 页面闪回原始 LaTeX 文本
→ updated 里再次调用 MathJax 重新渲染
→ 公式闪烁一下又出来了
时刻3: message 又变了 → 重复 💥
每个 SSE chunk 都会导致一次「还原 → 重新渲染」的闪烁。如果内容里有 10 个公式,每次都重新渲染 10 个,性能也很差。
1.3 根本原因
Vue 的 v-html 是声明式 的:它说"这个元素的 innerHTML 应该是 message 的值"。每次 message 变化,Vue 都忠实地用原始字符串覆盖整个 innerHTML。
而 MathJax 是命令式 的:它说"我把 DOM 里的 LaTeX 文本改成了数学符号节点"。
两者冲突:Vue 覆盖了 MathJax 的成果,MathJax 又覆盖 Vue 的原始文本,循环往复。
二、双区域渲染:核心思想
把显示区域切成两半
┌──────────────────────────────────────────┐
│ 🧊 冻结区 (Frozen Zone) │ 📝 流式区 │
│ │(Pending Zone)│
│ 用 ref 直接操作 DOM │ v-html 绑定 │
│ Vue 不碰 │ Vue 每次重写 │
│ MathJax 成果永久保留 │ 显示未处理尾部 │
└──────────────────────────────────────────┘
html
<span ref="frozen"></span><span v-html="pending"></span>
- 冻结区 :没有
v-html、没有{``{ }},Vue 的虚拟 DOM diff 认为它是静态节点,不会动它。我们通过ref+appendChild直接操作真实 DOM。 - 流式区 :绑定一个
pending变量,只包含还没处理的尾部文本。每次 Vue 重写的只是这一小段。
三、完整实现
3.1 模板
html
<template>
<div class="chat-bubble">
<!-- 冻结区:Vue 不管,MathJax 成果永久保留 -->
<span ref="frozen"></span>
<!-- 流式区:Vue 响应式更新,只显示未处理的尾部 -->
<span v-html="pending"></span>
<!-- 打字光标 -->
<span v-if="isStreaming" class="cursor">|</span>
</div>
</template>
3.2 完整脚本
js
export default {
props: {
message: String, // 随 SSE 不断增长的完整内容
isStreaming: Boolean, // 流式是否结束
},
data() {
return {
processedIndex: 0, // message 中已冻结到哪个位置
pending: '', // 流式区显示的文本
freezeTimer: null, // 节流定时器
}
},
watch: {
// ---- 核心:监听内容变化 ----
message(newVal) {
if (!newVal) return
// 计算本次新增的尾部
const tail = newVal.substring(this.processedIndex)
// 检查尾部是否有未闭合的公式
const safeLen = this.findSafeSplit(tail)
if (safeLen > 0 && safeLen < tail.length) {
// 情况A:尾部一部分安全、一部分有未闭合公式
// 安全部分 → 冻结;不安全部分 → 留在流式区
this.freeze(tail.substring(0, safeLen))
this.pending = tail.substring(safeLen)
} else if (safeLen === tail.length) {
// 情况B:全部安全(无未闭合公式)
// 先显示在流式区,500ms 后批量冻结(节流)
this.pending = tail
this.scheduleFreeze()
} else {
// 情况C:开头就是未闭合公式,全部留在流式区
this.pending = tail
}
},
// ---- 流式结束:最终渲染 ----
isStreaming(val) {
if (!val) {
clearTimeout(this.freezeTimer)
this.pending = ''
const remaining = (this.message || '').substring(this.processedIndex)
if (remaining) {
this.freeze(remaining, true) // 强制 typeset
}
}
},
},
methods: {
/**
* 将内容追加到冻结区
* @param {string} html - 要冻结的 HTML
* @param {boolean} forceTypeset - 是否强制 typeset(用于最终渲染)
*/
freeze(html, forceTypeset = false) {
const frozen = this.$refs.frozen
if (!frozen) return
// 创建一个新的 span 包裹这批内容
const span = document.createElement('span')
span.innerHTML = html
frozen.appendChild(span)
// 更新已处理位置
this.processedIndex += html.length
// 只对含公式的内容跑 MathJax(或强制 typeset)
if (forceTypeset || this.hasFormula(html)) {
this.typeset(span)
}
},
/**
* 节流冻结:500ms 内的多次更新合并为一次 DOM 操作
*/
scheduleFreeze() {
clearTimeout(this.freezeTimer)
this.freezeTimer = setTimeout(() => {
const tail = (this.message || '').substring(this.processedIndex)
if (!tail) return
const safeLen = this.findSafeSplit(tail)
if (safeLen > 0) {
this.freeze(tail.substring(0, safeLen))
this.pending = tail.substring(safeLen)
}
}, 500)
},
/**
* 对单个元素调用 MathJax
* 注意:只处理传入的元素,不会影响其他已渲染的节点
*/
typeset(el) {
if (!window.MathJax || !el) return
try {
window.MathJax.typesetPromise([el]).catch(e => console.warn('MathJax:', e))
} catch (e) {
console.warn('MathJax error:', e)
}
},
/** 检查文本中是否包含公式分隔符 */
hasFormula(text) {
return /\$|\\\(|\\\[/.test(text)
},
/**
* 🔑 核心算法:找到所有公式分隔符都闭合的最远安全位置
*
* 输入: "hello $x^2$ world $y"
* 分析: hello → 安全
* $x^2$ → 完整公式,安全
* world → 安全
* $y → $ 未闭合!不安全
* 返回: 18 (即 "hello $x^2$ world " 的长度)
*/
findSafeSplit(text) {
let i = 0
let lastSafe = 0
while (i < text.length) {
if (text.startsWith('$$', i)) {
// 块级公式 $$...$$
const end = text.indexOf('$$', i + 2)
if (end === -1) return lastSafe // 未闭合
i = end + 2
lastSafe = i
} else if (text[i] === '$' && (i === 0 || text[i - 1] !== '\\')) {
// 行内公式 $...$(排除转义的 \$)
const end = text.indexOf('$', i + 1)
if (end === -1) return lastSafe // 未闭合
i = end + 1
lastSafe = i
} else if (text.startsWith('\\(', i)) {
// 行内公式 \(...\)
const end = text.indexOf('\\)', i + 2)
if (end === -1) return lastSafe
i = end + 2
lastSafe = i
} else if (text.startsWith('\\[', i)) {
// 块级公式 \[...\]
const end = text.indexOf('\\]', i + 2)
if (end === -1) return lastSafe
i = end + 2
lastSafe = i
} else {
// 普通字符,一定是安全的
i++
lastSafe = i
}
}
return lastSafe
},
},
}
四、图解:一次完整的流式过程
AI 最终输出: "你好 $E=mc^2$ 再见 $F=ma$"
第 1 帧
message = "你好 $E=mc"
findSafeSplit("你好 $E=mc") → 3 ("你好 " 安全, "$E=mc" 未闭合)
┌─────────────────────────────────────────────┐
│ frozen: [你好 ] │ pending: "$E=mc" │
└─────────────────────────────────────────────┘
DOM 操作: frozen.appendChild(<span>你好 </span>) ← 无公式,不跑 MathJax
第 2 帧
message = "你好 $E=mc^2$ 再见"
tail (从 processedIndex=3 开始) = "$E=mc^2$ 再见"
findSafeSplit("$E=mc^2$ 再见") → 13 (全部安全, $ 已闭合)
┌─────────────────────────────────────────────────────────┐
│ frozen: [你好 ][$E=mc^2$ 再见] │ pending: "" │
└─────────────────────────────────────────────────────────┘
↑
MathJax.typesetPromise 只处理这个 span
→ "$E=mc^2$" 变成 <mjx-container>...</mjx-container>
第一个 span [你好 ] 完全不受影响!
第 3 帧
message = "你好 $E=mc^2$ 再见 $F=ma$"
tail = " $F=ma$"
findSafeSplit → 7 (全部安全)
┌───────────────────────────────────────────────────────────────────┐
│ frozen: [你好 ][E=mc² 再见][ $F=ma$] │ pending: "" │
└───────────────────────────────────────────────────────────────────┘
↑ 不动! ↑ 新增,MathJax 处理
E=mc² 的渲染结果从头到尾没有被碰过。
五、为什么 Vue 不会覆盖冻结区?
这是最核心的问题。答案在 Vue 的 patch 机制中:
html
<span ref="frozen"></span>
这个元素:
- 没有
v-html→ Vue 不会在更新时设置innerHTML - 没有
v-text→ Vue 不会设置textContent - 没有子组件或动态子节点 → Vue 的 children diff 认为它是空的
- 只有一个
ref→ 这只是给你一个引用,不参与渲染
所以 Vue 在 patch 阶段会跳过 这个元素。我们通过 ref 拿到真实 DOM 节点后,用原生 appendChild 添加的内容,完全在 Vue 的视野之外。
对比:
html
<!-- Vue 管 → 每次 pending 变了就重写 innerHTML -->
<span v-html="pending"></span>
<!-- Vue 不管 → 你用 appendChild 加什么都永远保留 -->
<span ref="frozen"></span>
六、findSafeSplit 的状态机原理
这个函数本质上是一个有限状态自动机,只有两个状态:
普通字符
┌───────────────┐
│ ▼
[正常态] ──$──→ [公式内]
▲ │
│ $ (找到闭合符)
└───────────────┘
扫描过程中,lastSafe 指针只在正常态 时前进。一旦进入公式内但找不到闭合符,立即返回上一个 lastSafe------因为这之后的内容可能还没传完。
"hello $x^2$ world $y^"
^^^^^ → lastSafe = 5
^^^^^ ^ → 公式开关完毕,lastSafe = 18
^^^^^^ → 找到 $ 但没找到关闭的 $
→ return 18
→ "hello $x^2$ world " 可以冻结
→ "$y^" 留在流式区等待
七、节流策略
为什么需要 scheduleFreeze?考虑这个场景:
chunk 1: "你"
chunk 2: "你好"
chunk 3: "你好啊"
chunk 4: "你好啊,"
chunk 5: "你好啊,今天"
5 个 chunk 内没有任何公式,findSafeSplit 每次返回 tail.length(全部安全)。如果每次都冻结,就是 5 次 DOM 操作。
用 scheduleFreeze(500ms 节流),这 5 个 chunk 合并为 1 次 appendChild,DOM 操作量减少 80%。
但如果中间出现了公式闭合(safeLen < tail.length),会立即冻结,不等 500ms。公式优先,纯文本延迟。
八、扩展:不只是 MathJax
这个模式适用于所有第三方库修改 DOM 后需要在 Vue 响应式更新中保持的场景:
| 场景 | 第三方库 | 冻结时机 |
|---|---|---|
| 数学公式 | MathJax / KaTeX | 公式分隔符闭合时 |
| 代码高亮 | Prism.js / highlight.js | 代码块 ``` 闭合时 |
| 流程图 | Mermaid | ```mermaid 块闭合时 |
| Markdown | marked / markdown-it | 段落结束时(\n\n) |
只需替换 findSafeSplit 的分隔符检测逻辑和 typeset 的调用即可。
九、完整 Vue 3 Composition API 版本
如果你用的是 Vue 3 + <script setup>:
vue
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({
message: String,
isStreaming: Boolean,
})
const frozen = ref(null) // template ref
const pending = ref('')
let processedIndex = 0
let freezeTimer = null
function freeze(html, forceTypeset = false) {
if (!frozen.value) return
const span = document.createElement('span')
span.innerHTML = html
frozen.value.appendChild(span)
processedIndex += html.length
if (forceTypeset || /\$|\\\(|\\\[/.test(html)) {
window.MathJax?.typesetPromise?.([span]).catch(() => {})
}
}
function findSafeSplit(text) {
let i = 0, lastSafe = 0
while (i < text.length) {
if (text.startsWith('$$', i)) {
const end = text.indexOf('$$', i + 2)
if (end === -1) return lastSafe
i = end + 2; lastSafe = i
} else if (text[i] === '$' && (i === 0 || text[i - 1] !== '\\')) {
const end = text.indexOf('$', i + 1)
if (end === -1) return lastSafe
i = end + 1; lastSafe = i
} else if (text.startsWith('\\(', i)) {
const end = text.indexOf('\\)', i + 2)
if (end === -1) return lastSafe
i = end + 2; lastSafe = i
} else if (text.startsWith('\\[', i)) {
const end = text.indexOf('\\]', i + 2)
if (end === -1) return lastSafe
i = end + 2; lastSafe = i
} else {
i++; lastSafe = i
}
}
return lastSafe
}
watch(() => props.message, (newVal) => {
if (!newVal) return
const tail = newVal.substring(processedIndex)
const safeLen = findSafeSplit(tail)
if (safeLen > 0 && safeLen < tail.length) {
freeze(tail.substring(0, safeLen))
pending.value = tail.substring(safeLen)
} else if (safeLen === tail.length) {
pending.value = tail
clearTimeout(freezeTimer)
freezeTimer = setTimeout(() => {
const t = (props.message || '').substring(processedIndex)
const s = findSafeSplit(t)
if (s > 0) {
freeze(t.substring(0, s))
pending.value = t.substring(s)
}
}, 500)
} else {
pending.value = tail
}
})
watch(() => props.isStreaming, (val) => {
if (!val) {
clearTimeout(freezeTimer)
pending.value = ''
const remaining = (props.message || '').substring(processedIndex)
if (remaining) freeze(remaining, true)
}
})
onBeforeUnmount(() => clearTimeout(freezeTimer))
</script>
<template>
<div class="chat-bubble">
<span ref="frozen"></span>
<span v-html="pending"></span>
<span v-if="isStreaming" class="cursor">|</span>
</div>
</template>
十、一句话总结
把第三方库操作过的 DOM 放在
ref元素里,把还在变化的内容放在v-html元素里。两个区域物理隔离,Vue 管 Vue 的,MathJax 管 MathJax 的,互不干扰。