系列 :《从零构建跨端 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
五、样式系统:让 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 用量可视化、计费与配额前端管控。