第 8 篇:Markdown 渲染引擎 —— 从流式解析到安全输出

系列 :《从零构建跨端 AI 对话系统》
前置 :第 1-7 篇的完整对话系统
目标:构建一个流式安全的 Markdown 渲染管线,覆盖库选型、流式容错、XSS 防护、自定义渲染器(代码复制、图片放大、表格横滚、链接预览)


一、AI 输出的 Markdown 有多野

大模型返回的 Markdown 不是你在 GitHub 上写的规规矩矩的 Markdown。它可以在一条消息里混入所有格式:

markdown 复制代码
好的,我来解答这道题。

**已知条件**:直角三角形两直角边 $a=3$, $b=4$

| 步骤 | 操作 | 结果 |
|------|------|------|
| 1 | 计算 $a^2$ | 9 |
| 2 | 计算 $b^2$ | 16 |
| 3 | 求和开方 | $\sqrt{25}=5$ |

用 Python 验证:

```python
import math
c = math.sqrt(3**2 + 4**2)
print(f"斜边: {c}")  # 输出: 5.0

💡 拓展 :勾股定理有超过 400 种证明方式,最早记录在《几何原本》中。

∑ n = 1 ∞ 1 n 2 = π 2 6 \sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6} n=1∑∞n21=6π2

复制代码
这条消息里有:**粗体**、行内公式、表格、代码块(带语言标识)、引用、链接、块级公式。Markdown 渲染器必须全部正确处理,而且是在**流式输入**中。

---

## 二、渲染库选型

### 三大主流库对比

| 维度 | marked | markdown-it | remark |
|---|---|---|---|
| 体积 (gzip) | ~8KB | ~30KB | ~100KB+ |
| 速度 | 极快 | 快 | 慢(AST) |
| 扩展性 | renderer 覆盖 | 插件系统 | 插件 + AST 变换 |
| GFM 表格 | ✅ 内置 | 需插件 | 需插件 |
| 流式友好 | ⭐⭐⭐ | ⭐⭐ | ⭐ |
| 类型安全 | 一般 | 好 | 好(TS) |

### 选型结论

AI 对话场景推荐 marked:

✅ 体积最小(移动端友好)

✅ 速度最快(流式场景每帧都要解析)

✅ renderer 覆盖机制足够灵活

✅ 内置 GFM 表格支持

如果你需要高级插件(脚注、任务列表、数学公式内置支持):

→ markdown-it + markdown-it-mathjax3 等插件

如果你做服务端渲染或静态站点:

→ remark(AST 变换能力最强,但对流式场景太重)

复制代码
本篇以 **marked** 为主实现,最后给出 **markdown-it** 的对照方案。

```bash
npm install marked highlight.js dompurify

三、流式 Markdown 的五大陷阱

在流式输入中解析 Markdown,会遇到五类未闭合问题:

陷阱 1:未闭合的代码块

复制代码
流式中途:  "```python\nprint('hello'"
marked 解析:  整个后续内容都被吃进 <code> 里

陷阱 2:未闭合的粗体/斜体

复制代码
流式中途:  "这是 **加粗文"
marked 解析:  "这是 **加粗文" → 原样输出(还行)
但如果:     "这是 **加粗 *斜体"
marked 解析:  嵌套未闭合 → 后续内容全变样式

陷阱 3:未完成的表格

复制代码
流式中途:  "| 列1 | 列2 |\n|---|---"
marked 解析:  缺少表体行 → 可能不渲染为表格

陷阱 4:未完成的链接

复制代码
流式中途:  "点击[这里](https://exam"
marked 解析:  "点击[这里](https://exam" → 原文输出

陷阱 5:公式与 Markdown 冲突

复制代码
公式:       "$a_{1} + b_{2}$"
marked 误读: "$a<em>{1} + b</em>{2}$"  ← _ 被当成斜体

四、完整渲染管线

复制代码
原始文本
   │
   ▼
① protectFormulas()     保护公式 → 占位符
   │
   ▼
② patchUnclosed()       修补未闭合标记(仅流式中)
   │
   ▼
③ marked.parse()        Markdown → HTML
   │
   ▼
④ restoreFormulas()     占位符 → 公式
   │
   ▼
⑤ DOMPurify.sanitize()  XSS 防护
   │
   ▼
安全的 HTML

src/utils/markdownRenderer.js(完整生产版)

js 复制代码
import { marked } from 'marked'
import hljs from 'highlight.js/lib/core'
import DOMPurify from 'dompurify'

// ---- 按需加载语言(减少打包体积) ----
import javascript from 'highlight.js/lib/languages/javascript'
import python from 'highlight.js/lib/languages/python'
import java from 'highlight.js/lib/languages/java'
import cpp from 'highlight.js/lib/languages/cpp'
import css from 'highlight.js/lib/languages/css'
import html from 'highlight.js/lib/languages/xml'
import sql from 'highlight.js/lib/languages/sql'
import bash from 'highlight.js/lib/languages/bash'
import json from 'highlight.js/lib/languages/json'
import typescript from 'highlight.js/lib/languages/typescript'

hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('js', javascript)
hljs.registerLanguage('python', python)
hljs.registerLanguage('py', python)
hljs.registerLanguage('java', java)
hljs.registerLanguage('cpp', cpp)
hljs.registerLanguage('c', cpp)
hljs.registerLanguage('css', css)
hljs.registerLanguage('html', html)
hljs.registerLanguage('xml', html)
hljs.registerLanguage('sql', sql)
hljs.registerLanguage('bash', bash)
hljs.registerLanguage('shell', bash)
hljs.registerLanguage('sh', bash)
hljs.registerLanguage('json', json)
hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('ts', typescript)

// ============ 自定义 Renderer ============

const renderer = new marked.Renderer()

/**
 * 代码块:带语言标签 + 复制按钮 + 行号(可选)
 */
renderer.code = function ({ text, lang }) {
  const language = lang && hljs.getLanguage(lang) ? lang : null
  let highlighted

  if (language) {
    highlighted = hljs.highlight(text, { language }).value
  } else {
    // 未知语言:尝试自动检测
    try {
      const result = hljs.highlightAuto(text)
      highlighted = result.value
    } catch {
      highlighted = escapeHtml(text)
    }
  }

  const displayLang = language || 'text'
  const encodedCode = encodeURIComponent(text)

  return `<div class="code-block" data-lang="${displayLang}">
  <div class="code-header">
    <span class="code-lang">${displayLang}</span>
    <button class="code-copy-btn" data-code="${encodedCode}" onclick="this.textContent='已复制 ✓';setTimeout(()=>this.textContent='复制',2000)">复制</button>
  </div>
  <pre class="code-pre"><code class="hljs language-${displayLang}">${highlighted}</code></pre>
</div>`
}

/**
 * 行内代码
 */
renderer.codespan = function ({ text }) {
  return `<code class="inline-code">${text}</code>`
}

/**
 * 链接:新窗口打开 + 安全属性 + 外部链接图标
 */
renderer.link = function ({ href, title, text }) {
  const isExternal = href && (href.startsWith('http://') || href.startsWith('https://'))
  const icon = isExternal ? ' <span class="link-external">↗</span>' : ''
  const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''
  return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer"${titleAttr} class="md-link">${text}${icon}</a>`
}

/**
 * 图片:懒加载 + 点击放大 + 加载失败处理
 */
renderer.image = function ({ href, title, text }) {
  const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''
  return `<div class="md-image-wrapper">
  <img data-src="${escapeHtml(href)}" alt="${escapeHtml(text)}"${titleAttr}
       class="md-image"
       loading="lazy"
       onclick="window.dispatchEvent(new CustomEvent('preview-image',{detail:'${escapeHtml(href)}'}))"
       onerror="this.onerror=null;this.classList.add('img-error');this.alt='图片加载失败'" />
</div>`
}

/**
 * 表格:包裹在可横向滚动的容器中(移动端关键)
 */
renderer.table = function ({ header, body }) {
  return `<div class="table-wrapper">
  <table class="md-table">
    <thead>${header}</thead>
    <tbody>${body}</tbody>
  </table>
</div>`
}

/**
 * 引用块:加左边高亮条
 */
renderer.blockquote = function ({ text }) {
  return `<blockquote class="md-blockquote">${text}</blockquote>`
}

/**
 * 列表项:支持 checkbox(任务列表)
 */
renderer.listitem = function ({ text, task, checked }) {
  if (task) {
    const icon = checked ? '☑' : '☐'
    const cls = checked ? 'task-done' : 'task-todo'
    return `<li class="task-item ${cls}"><span class="task-check">${icon}</span> ${text}</li>`
  }
  return `<li>${text}</li>`
}

// ---- marked 配置 ----
marked.setOptions({
  renderer,
  breaks: true,       // 单换行 → <br>
  gfm: true,          // GitHub Flavored Markdown
  pedantic: false,
  async: false,        // 同步模式(流式场景必须同步)
})

// ============ 公式保护 ============

/**
 * 将公式替换为占位符,防止 marked 误解析公式内的 _ * 等符号
 *
 * 处理顺序很重要:
 * 1. 先保护代码块(代码块里的 $ 不是公式)
 * 2. 再保护块级公式 $$...$$
 * 3. 再保护行内公式 $...$
 * 4. 最后保护 \(...\) 和 \[...\]
 */
function protectFormulas(text) {
  const formulas = []
  let idx = 0

  function placeholder(match) {
    const id = `%%F${idx}%%`
    formulas[idx] = match
    idx++
    return id
  }

  // 1. 保护代码块(不处理其中的公式)
  const codeBlocks = []
  let cbIdx = 0
  text = text.replace(/```[\s\S]*?```/g, (match) => {
    const id = `%%CB${cbIdx}%%`
    codeBlocks[cbIdx] = match
    cbIdx++
    return id
  })

  // 2. 保护行内代码
  const inlineCodes = []
  let icIdx = 0
  text = text.replace(/`[^`\n]+`/g, (match) => {
    const id = `%%IC${icIdx}%%`
    inlineCodes[icIdx] = match
    icIdx++
    return id
  })

  // 3. 块级公式 $$...$$
  text = text.replace(/\$\$([\s\S]*?)\$\$/g, placeholder)

  // 4. \[...\]
  text = text.replace(/\\\[([\s\S]*?)\\\]/g, placeholder)

  // 5. 行内公式 $...$ (不匹配转义的 \$ 和跨行的)
  text = text.replace(/(?<!\\)\$([^\$\n]+?)\$/g, placeholder)

  // 6. \(...\)
  text = text.replace(/\\\([\s\S]*?\\\)/g, placeholder)

  return { text, formulas, codeBlocks, inlineCodes }
}

/**
 * 还原所有占位符
 */
function restoreAll(html, formulas, codeBlocks, inlineCodes) {
  // 还原公式
  for (let i = 0; i < formulas.length; i++) {
    html = html.replace(`%%F${i}%%`, formulas[i])
  }
  // 还原行内代码
  for (let i = 0; i < inlineCodes.length; i++) {
    html = html.replace(`%%IC${i}%%`, inlineCodes[i])
  }
  // 还原代码块
  for (let i = 0; i < codeBlocks.length; i++) {
    html = html.replace(`%%CB${i}%%`, codeBlocks[i])
  }
  return html
}

// ============ 流式容错 ============

/**
 * 修补流式输入中的未闭合标记
 * 仅在 isStreaming=true 时调用
 */
function patchUnclosed(text) {
  // 1. 代码块 ```
  const tripleCount = countOccurrences(text, '```')
  if (tripleCount % 2 !== 0) {
    // 找到最后一个未闭合的 ```,给它闭合
    text += '\n```'
  }

  // 2. 表格:如果有表头分隔行(|---|)但没有表体行,补一个空行
  //    这样 marked 能正确渲染为表格而不是普通文本
  const lines = text.split('\n')
  const lastNonEmpty = findLastNonEmptyLine(lines)
  if (lastNonEmpty >= 0 && /^\|[\s\-:|]+\|$/.test(lines[lastNonEmpty].trim())) {
    // 分隔行是最后一行,补一个空的表体行
    const colCount = lines[lastNonEmpty].split('|').filter(Boolean).length
    text += '\n|' + ' |'.repeat(colCount)
  }

  // 3. 未完成的链接 [text](url 补上 )
  //    简单处理:检测最后一个 [...]( 后面是否有 )
  const lastOpenLink = text.lastIndexOf('](')
  if (lastOpenLink !== -1) {
    const afterLink = text.substring(lastOpenLink + 2)
    if (!afterLink.includes(')')) {
      text += ')'
    }
  }

  return text
}

function countOccurrences(str, substr) {
  let count = 0
  let pos = 0
  while ((pos = str.indexOf(substr, pos)) !== -1) {
    count++
    pos += substr.length
  }
  return count
}

function findLastNonEmptyLine(lines) {
  for (let i = lines.length - 1; i >= 0; i--) {
    if (lines[i].trim()) return i
  }
  return -1
}

// ============ XSS 防护 ============

/**
 * DOMPurify 配置
 *
 * AI 可能返回恶意内容(注入攻击 / prompt injection):
 *   <script>alert('xss')</script>
 *   <img src=x onerror="alert('xss')">
 *   <iframe src="https://evil.com">
 *
 * 我们只允许安全的 HTML 标签和属性
 */
const purifyConfig = {
  // 允许的标签
  ALLOWED_TAGS: [
    // 基础
    'p', 'br', 'hr', 'span', 'div',
    // 文本格式
    'strong', 'b', 'em', 'i', 'u', 's', 'del', 'mark', 'sub', 'sup',
    // 标题
    'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
    // 列表
    'ul', 'ol', 'li',
    // 代码
    'pre', 'code',
    // 表格
    'table', 'thead', 'tbody', 'tr', 'th', 'td',
    // 引用
    'blockquote',
    // 链接和图片
    'a', 'img',
    // 自定义组件容器
    'button',
    // MathJax 输出标签(typeset 后会生成这些)
    'mjx-container', 'mjx-math', 'mjx-mrow', 'mjx-mi', 'mjx-mo',
    'mjx-mn', 'mjx-msup', 'mjx-msub', 'mjx-msqrt', 'mjx-mfrac',
    'mjx-munder', 'mjx-mover', 'mjx-mtable', 'mjx-mtr', 'mjx-mtd',
    'mjx-mtext', 'mjx-mspace', 'mjx-mpadded', 'mjx-merror',
    'mjx-assistive-mml', 'math', 'semantics', 'mrow', 'mi', 'mo',
    'mn', 'msup', 'msub', 'msqrt', 'mfrac', 'munderover',
  ],
  // 允许的属性
  ALLOWED_ATTR: [
    'class', 'style', 'id',
    'href', 'target', 'rel', 'title',
    'src', 'alt', 'loading', 'data-src',
    'data-code', 'data-lang', 'data-index',
    'onclick', // 仅用于我们自己生成的复制按钮和图片预览
    'onerror',  // 仅用于图片加载失败
    'colspan', 'rowspan', 'align',
  ],
  // 允许的 URI schemes
  ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
}

// ---- DOMPurify hooks ----
// 移除危险的 event handler(除了我们自己生成的)
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
  // 只允许特定元素上的 onclick
  if (data.attrName === 'onclick') {
    if (!node.classList?.contains('code-copy-btn') && !node.classList?.contains('md-image')) {
      data.keepAttr = false
    }
  }
  // 只允许 img 上的 onerror
  if (data.attrName === 'onerror' && node.tagName !== 'IMG') {
    data.keepAttr = false
  }
})

// ============ 主渲染函数 ============

/**
 * 渲染 Markdown 为安全 HTML
 *
 * @param {string} raw - 原始 Markdown 文本
 * @param {boolean} isStreaming - 是否在流式输入中
 * @returns {string} 安全的 HTML
 */
export function renderMarkdown(raw, isStreaming = false) {
  if (!raw) return ''

  let text = raw

  // 1. 保护公式和代码
  const { text: protectedText, formulas, codeBlocks, inlineCodes } = protectFormulas(text)
  text = protectedText

  // 2. 流式容错
  if (isStreaming) {
    text = patchUnclosed(text)
  }

  // 3. Markdown → HTML
  let html = marked.parse(text)

  // 4. 还原占位符
  html = restoreAll(html, formulas, codeBlocks, inlineCodes)

  // 5. XSS 防护
  html = DOMPurify.sanitize(html, purifyConfig)

  return html
}

/**
 * 纯文本提取(用于复制、搜索等场景)
 */
export function markdownToPlainText(raw) {
  if (!raw) return ''
  return raw
    .replace(/```[\s\S]*?```/g, '')        // 移除代码块
    .replace(/`([^`]+)`/g, '$1')           // 行内代码保留内容
    .replace(/\*\*([^*]+)\*\*/g, '$1')     // 粗体
    .replace(/\*([^*]+)\*/g, '$1')         // 斜体
    .replace(/~~([^~]+)~~/g, '$1')         // 删除线
    .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // 链接保留文字
    .replace(/#+\s/g, '')                  // 标题
    .replace(/\$\$[\s\S]*?\$\$/g, '[公式]') // 块级公式
    .replace(/\$([^$]+)\$/g, '$1')         // 行内公式保留内容
    .replace(/^\s*[-*+]\s/gm, '')          // 无序列表标记
    .replace(/^\s*\d+\.\s/gm, '')          // 有序列表标记
    .replace(/^\s*>\s/gm, '')              // 引用标记
    .replace(/\n{3,}/g, '\n\n')            // 压缩空行
    .trim()
}

// ---- 辅助 ----
function escapeHtml(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}

五、样式系统:让 Markdown 好看

src/styles/markdown.css

css 复制代码
/* ============ 基础文本 ============ */
.markdown-body {
  font-size: 14px;
  line-height: 1.7;
  color: #1f2937;
  word-break: break-word;
}

.markdown-body > *:first-child { margin-top: 0; }
.markdown-body > *:last-child { margin-bottom: 0; }

/* 段落 */
.markdown-body p {
  margin: 0.6em 0;
}

/* 标题 */
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4 {
  margin: 1em 0 0.5em;
  font-weight: 600;
  line-height: 1.3;
}

.markdown-body h1 { font-size: 1.4em; }
.markdown-body h2 { font-size: 1.25em; }
.markdown-body h3 { font-size: 1.1em; }
.markdown-body h4 { font-size: 1em; }

/* 粗体斜体 */
.markdown-body strong { font-weight: 600; }
.markdown-body em { font-style: italic; }
.markdown-body del { text-decoration: line-through; color: #9ca3af; }

/* 分隔线 */
.markdown-body hr {
  border: none;
  border-top: 1px solid #e5e7eb;
  margin: 1.2em 0;
}

/* ============ 代码 ============ */

/* 行内代码 */
.markdown-body .inline-code {
  background: #f3f4f6;
  padding: 2px 6px;
  border-radius: 4px;
  font-family: 'Menlo', 'Monaco', 'Consolas', 'Courier New', monospace;
  font-size: 0.88em;
  color: #d63384;
  word-break: break-all;
}

/* 代码块容器 */
.markdown-body .code-block {
  margin: 0.8em 0;
  border-radius: 8px;
  overflow: hidden;
  border: 1px solid #e5e7eb;
  background: #fafafa;
}

/* 代码块头部 */
.markdown-body .code-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 4px 12px;
  background: #f3f4f6;
  border-bottom: 1px solid #e5e7eb;
}

.markdown-body .code-lang {
  font-size: 11px;
  color: #6b7280;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

.markdown-body .code-copy-btn {
  background: none;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  padding: 2px 10px;
  font-size: 11px;
  cursor: pointer;
  color: #374151;
  transition: all 0.15s;
  font-family: inherit;
}

.markdown-body .code-copy-btn:hover {
  background: #e5e7eb;
}

/* 代码内容 */
.markdown-body .code-pre {
  margin: 0;
  padding: 12px 16px;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

.markdown-body .code-pre code {
  font-family: 'Menlo', 'Monaco', 'Consolas', monospace;
  font-size: 13px;
  line-height: 1.5;
  tab-size: 2;
}

/* ============ 表格 ============ */

.markdown-body .table-wrapper {
  margin: 0.8em 0;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}

.markdown-body .md-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
  white-space: nowrap; /* 移动端防止表格被压扁 */
}

.markdown-body .md-table th,
.markdown-body .md-table td {
  padding: 8px 12px;
  border-bottom: 1px solid #e5e7eb;
  text-align: left;
}

.markdown-body .md-table th {
  background: #f9fafb;
  font-weight: 600;
  color: #374151;
}

.markdown-body .md-table tr:last-child td {
  border-bottom: none;
}

.markdown-body .md-table tr:hover td {
  background: #f9fafb;
}

/* ============ 引用 ============ */

.markdown-body .md-blockquote {
  margin: 0.8em 0;
  padding: 8px 16px;
  border-left: 3px solid #3b82f6;
  background: #eff6ff;
  border-radius: 0 6px 6px 0;
  color: #374151;
}

.markdown-body .md-blockquote p {
  margin: 0.3em 0;
}

/* ============ 列表 ============ */

.markdown-body ul,
.markdown-body ol {
  padding-left: 1.5em;
  margin: 0.5em 0;
}

.markdown-body li {
  margin: 0.25em 0;
}

/* 任务列表 */
.markdown-body .task-item {
  list-style: none;
  margin-left: -1.5em;
}

.markdown-body .task-check {
  margin-right: 4px;
}

.markdown-body .task-done {
  color: #9ca3af;
  text-decoration: line-through;
}

/* ============ 链接 ============ */

.markdown-body .md-link {
  color: #2563eb;
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: border-color 0.15s;
}

.markdown-body .md-link:hover {
  border-bottom-color: #2563eb;
}

.markdown-body .link-external {
  font-size: 0.8em;
  vertical-align: super;
  opacity: 0.5;
}

/* ============ 图片 ============ */

.markdown-body .md-image-wrapper {
  margin: 0.8em 0;
}

.markdown-body .md-image {
  max-width: 100%;
  max-height: 400px;
  border-radius: 8px;
  cursor: zoom-in;
  transition: transform 0.2s;
}

.markdown-body .md-image:hover {
  transform: scale(1.01);
}

.markdown-body .img-error {
  display: inline-block;
  padding: 12px;
  background: #fef2f2;
  border: 1px dashed #fca5a5;
  border-radius: 8px;
  color: #dc2626;
  font-size: 12px;
  cursor: default;
}

六、图片预览:点击放大

src/components/ImagePreview.vue

vue 复制代码
<template>
  <Teleport to="body">
    <Transition name="zoom">
      <div v-if="visible" class="preview-mask" @click="close">
        <img :src="src" class="preview-image" @click.stop />
        <button class="preview-close" @click="close">✕</button>
      </div>
    </Transition>
  </Teleport>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const visible = ref(false)
const src = ref('')

function onPreview(e) {
  src.value = e.detail
  visible.value = true
}

function close() {
  visible.value = false
}

function onKeydown(e) {
  if (e.key === 'Escape') close()
}

onMounted(() => {
  window.addEventListener('preview-image', onPreview)
  window.addEventListener('keydown', onKeydown)
})

onBeforeUnmount(() => {
  window.removeEventListener('preview-image', onPreview)
  window.removeEventListener('keydown', onKeydown)
})
</script>

<style scoped>
.preview-mask {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.85);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
  cursor: zoom-out;
}

.preview-image {
  max-width: 90vw;
  max-height: 90vh;
  object-fit: contain;
  border-radius: 4px;
  cursor: default;
}

.preview-close {
  position: fixed;
  top: 16px;
  right: 16px;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: none;
  background: rgba(255,255,255,0.15);
  color: white;
  font-size: 18px;
  cursor: pointer;
  transition: background 0.15s;
}

.preview-close:hover {
  background: rgba(255,255,255,0.3);
}

.zoom-enter-active,
.zoom-leave-active {
  transition: all 0.25s ease;
}

.zoom-enter-from,
.zoom-leave-to {
  opacity: 0;
}

.zoom-enter-from .preview-image,
.zoom-leave-to .preview-image {
  transform: scale(0.9);
}
</style>

在 App.vue 中挂载:

vue 复制代码
<template>
  <ChatLayout />
  <ImagePreview />
</template>

七、代码复制:事件委托

代码块的复制按钮是通过 innerHTML 动态生成的,不能用 Vue 的 @click。用事件委托:

src/composables/useCodeCopy.js

js 复制代码
import { onMounted, onBeforeUnmount } from 'vue'

/**
 * 代码块复制功能(事件委托)
 *
 * 原理:在 document 上监听 click,检测 target 是否是 .code-copy-btn
 * 好处:不管有多少代码块动态生成,一个监听器搞定
 */
export function useCodeCopy() {
  async function onClick(e) {
    const btn = e.target.closest('.code-copy-btn')
    if (!btn) return

    const code = decodeURIComponent(btn.dataset.code || '')
    if (!code) return

    try {
      await navigator.clipboard.writeText(code)
      btn.textContent = '已复制 ✓'
    } catch {
      // 降级方案
      const ta = document.createElement('textarea')
      ta.value = code
      ta.style.cssText = 'position:fixed;left:-9999px;opacity:0'
      document.body.appendChild(ta)
      ta.select()
      document.execCommand('copy')
      document.body.removeChild(ta)
      btn.textContent = '已复制 ✓'
    }

    setTimeout(() => {
      btn.textContent = '复制'
    }, 2000)
  }

  onMounted(() => {
    document.addEventListener('click', onClick)
  })

  onBeforeUnmount(() => {
    document.removeEventListener('click', onClick)
  })
}

在 App.vue 中启用:

js 复制代码
import { useCodeCopy } from './composables/useCodeCopy'
useCodeCopy()

八、markdown-it 对照方案

如果选择 markdown-it(插件生态更丰富):

bash 复制代码
npm install markdown-it markdown-it-highlightjs markdown-it-task-lists

src/utils/markdownRendererMdit.js

js 复制代码
import MarkdownIt from 'markdown-it'
import highlightjsPlugin from 'markdown-it-highlightjs'
import taskListPlugin from 'markdown-it-task-lists'
import DOMPurify from 'dompurify'

const md = new MarkdownIt({
  html: false,         // 禁止原始 HTML(安全)
  breaks: true,        // 换行 → <br>
  linkify: true,       // 自动识别 URL
  typographer: false,  // 不做引号替换
})
  .use(highlightjsPlugin, { auto: true, code: false })
  .use(taskListPlugin, { enabled: true })

// ---- 自定义渲染规则 ----

// 链接:新窗口打开
const defaultLinkRender = md.renderer.rules.link_open ||
  function (tokens, idx, options, env, self) {
    return self.renderToken(tokens, idx, options)
  }

md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
  tokens[idx].attrSet('target', '_blank')
  tokens[idx].attrSet('rel', 'noopener noreferrer')
  return defaultLinkRender(tokens, idx, options, env, self)
}

// 代码块:加复制按钮
md.renderer.rules.fence = function (tokens, idx) {
  const token = tokens[idx]
  const code = token.content
  const lang = token.info.trim() || 'text'
  const encodedCode = encodeURIComponent(code)

  // highlight.js 已经通过插件处理了高亮
  const highlighted = md.options.highlight
    ? md.options.highlight(code, lang)
    : md.utils.escapeHtml(code)

  return `<div class="code-block" data-lang="${lang}">
    <div class="code-header">
      <span class="code-lang">${lang}</span>
      <button class="code-copy-btn" data-code="${encodedCode}">复制</button>
    </div>
    <pre class="code-pre"><code class="hljs language-${lang}">${highlighted}</code></pre>
  </div>`
}

// 表格包裹
md.renderer.rules.table_open = () => '<div class="table-wrapper"><table class="md-table">'
md.renderer.rules.table_close = () => '</table></div>'

/**
 * 渲染 Markdown(markdown-it 版本)
 */
export function renderMarkdown(raw, isStreaming = false) {
  if (!raw) return ''

  let text = raw
  const { text: protectedText, formulas, codeBlocks, inlineCodes } = protectFormulas(text)
  text = protectedText

  if (isStreaming) {
    text = patchUnclosed(text)
  }

  let html = md.render(text)
  html = restoreAll(html, formulas, codeBlocks, inlineCodes)
  html = DOMPurify.sanitize(html, purifyConfig)

  return html
}

// protectFormulas / restoreAll / patchUnclosed 和 marked 版完全相同,直接复用

marked vs markdown-it 在流式场景的关键差异

复制代码
marked:
  - parse() 一次性输出 HTML 字符串
  - 每次 content 变化重新 parse 全文
  - 速度快,8KB 体积

markdown-it:
  - render() 也是一次性输出
  - 但内部多了 token 化 + 规则链,略慢
  - 插件生态好,30KB 体积

结论:流式场景下两者行为一致(每次都是全文重新解析)
     性能差异在可接受范围内(marked 约 0.5ms/次,markdown-it 约 1ms/次)
     选 marked 还是 markdown-it 主要看你需不需要插件

九、完整渲染管线时序图

复制代码
SSE chunk 到达 (每 30-50ms)
     │
     ▼
rAF 节流合并 → displayContent 更新 (每 16ms 最多一次)
     │
     ▼
watch(content) 触发
     │
     ├── 计算 tail (未处理的新增内容)
     │
     ├── findSafeSplit(tail)
     │   检测: $..$ $$...$$ \(..\) \[..\] ```...```
     │     │
     │     ├── 安全部分 → doFreeze()
     │     │                 │
     │     │                 ▼
     │     │            renderMarkdown(chunk, false)
     │     │                 │
     │     │                 ├── protectFormulas()
     │     │                 ├── marked.parse()
     │     │                 ├── restoreFormulas()
     │     │                 └── DOMPurify.sanitize()
     │     │                 │
     │     │                 ▼
     │     │            frozenRef.appendChild(span)
     │     │                 │
     │     │                 ├── hasFormula? → MathJax.typesetPromise([span])
     │     │                 └── 无公式 → 不 typeset (hljs 已在 parse 时处理)
     │     │
     │     └── 不安全尾部 → pendingHtml
     │                          │
     │                          ▼
     │                     renderMarkdown(tail, true)
     │                          │
     │                          ├── patchUnclosed() ← 修补 ```等
     │                          ├── marked.parse()
     │                          └── DOMPurify.sanitize()
     │                          │
     │                          ▼
     │                     v-html="pendingHtml" (Vue 管理)
     │
     └── msg.done = true
              │
              ▼
         剩余内容 → doFreeze(remaining, forceTypeset=true)
         MathJax 最终 typeset

十、本篇核心要点

要点 实现
公式保护 先替换为占位符,marked 处理后还原,避免 _ 被误解析为斜体
流式容错 patchUnclosed() 临时闭合代码块、表格、链接
代码高亮 highlight.js 按需加载语言,在 renderer.code 中同步高亮
XSS 防护 DOMPurify 白名单 + hook 限制 onclick 的使用范围
表格横滚 table-wrapper 包裹 + overflow-x: auto
图片预览 自定义事件 preview-image + Teleport 全屏蒙层
代码复制 事件委托 + data-code + clipboard API 降级
保护顺序 代码块 → 行内代码 → 块级公式 → 行内公式(顺序不能乱)

下一篇预告

第 9 篇:多模型切换与配置 ------ 不只是换个 API 地址

模型适配器模式深入、配置面板 UI(temperature/top_p 滑块)、System Prompt 管理、Token 用量可视化、计费与配额前端管控。

相关推荐
鹿鸣天涯2 小时前
华为安全产品缺省帐号与密码
安全·华为
coding随想2 小时前
告别构建焦虑!用 Shoelace 打造零配置的现代 Web 应用
前端
kronos.荒2 小时前
滑动窗口:寻找字符串中的字母异位词
开发语言·python
大傻^2 小时前
【AI安全攻防战】提示词攻击与防护:从“奶奶漏洞“到企业级防御体系
人工智能·安全·提示词安全
大学在校生,求offer联系2 小时前
YuFeng-XGuard-Reason安全护栏模型实测评价
人工智能·安全
css趣多多2 小时前
resize.js
前端·javascript·vue.js
网云工程师手记2 小时前
企业防火墙端口映射完整配置与安全收敛实操手册
运维·服务器·网络·安全·网络安全
_codemonster2 小时前
java web修改了文件和新建了文件需要注意的问题
java·开发语言·前端
小冰球2 小时前
前端侦探:我是如何挖掘出网站里 28 个"隐藏商品"的?
前端·vue.js