流式输出场景下的「双区域渲染」:让第三方 DOM 操作在 Vue 响应式更新中存活

问题场景

你在做一个 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 的,互不干扰。

相关推荐
css趣多多1 小时前
setup() 函数与语法糖
前端·javascript·vue.js
前端程序猿i2 小时前
第 3 篇:消息气泡组件 —— 远比你想的复杂
开发语言·前端·javascript·vue.js
1314lay_10072 小时前
color: var(--el-color-success); CSS里面使用函数
前端·css
墨染青竹梦悠然2 小时前
基于SpringBoot + vue的农产品销售系统(华夏鲜仓)
vue.js·spring boot·python·django·毕业设计·毕设
爱上妖精的尾巴2 小时前
8-7 WPS JS宏 正则表达式 元字符应用-提取连续数字
javascript·wps·jsa
xlq223222 小时前
16.环境变量与地址空间
前端·chrome
JasonSJX2 小时前
海海软件正式发布全新 DRM-X官网 Next.js 重构、多语言升级与 SEO 优化,助力全球数字版权保护
开发语言·javascript·安全·重构·视频防录屏·开源drm·加密保护课程
wulijuan8886662 小时前
Vue 组件的通信方式有哪些?
前端·javascript·vue.js