广瀚云办公
55.99MB · 2025-10-09
在 开发AI聊天应用时,AI的答案呈现质量直接决定了用户体验的上限。这篇文章将深入解析如何基于 Vue 3.5、MarkdownIt 和 Prism.js,设计并实现一个集语法高亮、代码复制于一体的 AI 答案美化组件。我们将揭秘从底层解析到高级交互的完整架构,让你的 AI 回复真正 “赏心悦目”。
我们的目标是构建一个 专业级、可复用 的 Markdown 渲染组件,其技术栈聚焦于前端主流和高性能库:
你是否遇到过 AI 生成的代码块挤成一团、表格难以阅读、或者复制代码还需要手动框选的尴尬?这些正是传统纯文本展示的用户体验痛点。
挑战点 | 痛点描述 | 我们的解决方案 |
---|---|---|
Markdown 兼容性 | 复杂的表格、脚注等扩展语法无法正确渲染。 | MarkdownIt 及其插件扩展,保证对 GFM(GitHub Flavored Markdown)的全面支持。 |
代码高亮 | 大段代码缺乏颜色区分,阅读障碍大。 | 集成 Prism.js,利用其强大的语言支持和主题系统,提供专业级高亮。 |
交互流畅性 | 无法快速复制代码、表格在移动端显示溢出。 | MutationObserver 动态监听 DOM,实现一键复制;CSS 响应式处理表格溢出。 |
性能瓶颈 | 流式渲染时,频繁的 DOM 操作导致页面卡顿。 | nextTick 和 computed 优化,并引入事件委托和组件懒加载策略。 |
为了应对上述挑战,我们将组件拆分成三个相互独立的层次,遵循单一职责原则(SRP),确保高内聚、低耦合。
graph TD
A[AiAnswer组件 (AiAnswer.vue)] -->|调用| B[Markdown解析层 (markdownUtil.js)]
A -->|触发| C[交互功能层 (codeCopyUtil.js)]
A -->|注入| D[样式美化层 (CSS/SCSS)]
B -->|MarkdownIt 解析| B1[Prism.js 高亮]
C -->|MutationObserver 监听| C1[copy-to-clipboard 复制]
style A fill:#f0f9ff,stroke:#3b82f6
style B fill:#f0fdf4,stroke:#10b981
style C fill:#fff7ed,stroke:#f97316
Markdown 解析层只负责生成 HTML 字符串,交互功能层只负责监听和绑定事件。组件本身(AiAnswer.vue
)仅负责接收数据、触发渲染,并管理样式,避免了逻辑混乱,使得单元测试变得非常简单。
markdownUtil.js
是整个渲染流程的核心,它通过配置 MarkdownIt,并重写其代码块(fence
)的渲染规则,将 Prism.js 的高亮结果注入到最终的 HTML 中。
markdownUtil.js
// utils/markdownUtil.js
// ... 引入 MarkdownIt 和 Prism.js ...
const markdownIt = (markdown, name = 'copy-code') => {
const md = new MarkdownIt({
// ... 核心配置:开启HTML、链接识别、换行符转<br>等
highlight: function (str, lang) {
// 核心高亮逻辑:重写代码块渲染函数
if (lang && Prism.languages[lang]) {
try {
const highlighted = Prism.highlight(str, Prism.languages[lang], lang)
// 注意:这里直接输出了包含复制按钮的HTML结构
return `<pre class="language-${lang}">
<div class="code-header">
<span class="language-label">${lang}</span>
<button class="copy-code">复制</button> // 注入复制按钮
</div>
<code class="code-content">${highlighted}</code>
</pre>`
} catch (error) { /* 容错处理 */ }
}
return `<pre><code>${md.utils.escapeHtml(str)}</code></pre>`
}
})
return md.render(markdown)
}
highlight
函数重写:这是实现自定义高亮的关键入口。默认情况下,MarkdownIt 仅生成 <pre><code>...</code></pre>
标签。我们在这里截胡,利用 Prism.js 进行高亮,并在<pre>
内部额外注入了div.code-header
结构,包含了语言标签和最重要的 “复制” 按钮。try...catch
保证了即使 Prism.js 不支持该语言,也能退化到普通的 <code>
标签,不会中断渲染。MutationObserver
的动态绑定由于 AI 答案往往是流式更新(即内容不断追加),或者通过 v-html
动态渲染,传统的 onMounted
绑定事件可能会失效。我们必须使用一个 “聪明的侦察兵” 来监听 DOM 的变化。
codeCopyUtil.js
// utils/codeCopyUtil.js
// ... 引入 copy-to-clipboard 和 ElMessage ...
const copyCode = async (event) => { /* ... 复制代码到剪贴板的实现 ... */ }
const bindCodeCopyEvents = () => {
// 性能优化:先移除旧的监听,再对所有 .copy-code 按钮重新绑定 click 事件
const buttons = document.querySelectorAll('.copy-code')
buttons.forEach((btn) => {
btn.removeEventListener('click', copyCode)
btn.addEventListener('click', copyCode)
})
}
// 核心:动态监听机制
const observeCodeBlocks = () => {
const observer = new MutationObserver((mutations) => {
let shouldBind = false
mutations.forEach((mutation) => {
// 仅关注新增节点,判断是否包含 .copy-code 元素
if (mutation.type === 'childList') {
// ... 判断新增节点是否包含代码块
if (shouldBind) {
// 异步绑定:等待DOM完全稳定后再绑定事件
setTimeout(bindCodeCopyEvents, 100)
}
}
})
})
// 监听全局 body 的子节点变化和子树变化
observer.observe(document.body, {
childList: true,
subtree: true
})
return observer
}
MutationObserver
:它是一个浏览器原生 API,用于监听 DOM 树的变化。我们将其配置为监听整个 DOM 树的子节点和子树(subtree: true
),确保无论是内容整体替换还是追加,都能被捕获。setTimeout(bindCodeCopyEvents, 100)
:这是一个关键的性能与稳定性平衡点。在 DOM 变化后,我们不立即绑定事件,而是等待 100ms,让浏览器有时间完成所有 DOM 操作和重绘,避免在 Vue 尚未完全更新完成时操作 DOM 导致错误或性能损耗。copyCode
:利用 copy-to-clipboard
库实现跨浏览器兼容的复制,并配合 Element Plus 的ElMessage
给予用户清晰的复制成功或失败的反馈,增强交互体验。AiAnswer.vue
组件通过 Vue 3.5 Composition API 实现了简洁高效的数据驱动和事件管理。
<script setup>
// ... 导入依赖 ...
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { bindCodeCopyEvents } from '@/utils/codeCopyUtil.js'
const props = defineProps({ content: String })
// 核心:使用 computed 属性缓存渲染结果,避免重复解析
const renderedContent = computed(() => {
if (props.content) {
return markdownIt(props.content, 'copy-code')
}
return ''
})
const bindEvents = () => {
nextTick(() => { // 确保在 DOM 更新后执行
bindCodeCopyEvents()
})
}
// 监听内容变化,重新绑定事件(支持流式更新)
watch(() => props.content, () => {
bindEvents()
}, { flush: 'post' }) // Vue 3 特性:在 DOM 渲染后执行监听回调
onMounted(() => {
bindEvents()
})
</script>
computed
缓存:renderedContent
只有当 props.content
真正变化时才会重新计算,极大地优化了渲染性能。watch
与 flush: 'post'
:这是 Vue 3.5 的一个工程优化点。flush: 'post'
确保了回调函数在组件自身的 DOM 更新之后执行。由于我们依赖 v-html
渲染了新的 DOM 结构,必须在它渲染完成之后才能去查找并绑定 .copy-code
按钮,保证了时序正确性。为了交付一个工业级的组件,我们还需要考虑在高并发和大数据量场景下的性能。
在代码复制场景中,如果页面有几百个代码块,绑定几百个 click
监听器是非常低效的。更优雅的方案是使用事件委托。
// 优化后的 bindCodeCopyEvents:只绑定一个全局监听器
const bindCodeCopyEvents = () => {
// 只绑定一次全局事件监听,并判断目标元素
document.removeEventListener('click', delegateCodeCopy)
document.addEventListener('click', delegateCodeCopy)
}
const delegateCodeCopy = (event) => {
if (event.target.classList.contains('copy-code')) {
copyCode(event)
}
}
价值:将数百次事件绑定简化为一次全局监听,大大减少了内存占用和 CPU 消耗。
Prism.js 默认会加载所有语言包,如果使用按需加载,可以减少打包体积。
// 最佳实践:按需动态加载 Prism.js 语言包
const loadLanguage = async (lang) => {
// 检查语言包是否已加载
if (!Prism.languages[lang]) {
try {
// 动态 import 语言组件
await import(`prismjs/components/prism-${lang}`)
} catch (e) {
console.warn(`Prism.js 无法加载语言包: ${lang}`)
}
}
}
// 在 highlight: function 中调用 loadLanguage(lang)
通过这种方式,只有当 AI 答案中出现新的编程语言时,才会加载对应的解析脚本,实现了真正的组件懒加载。
我们通过模块化设计,成功构建了一个集功能、性能、美观于一体的 AI 答案渲染组件:
v-html
结合MutationObserver
,解决了动态渲染内容的事件绑定难题。computed
缓存、事件委托和按需加载策略,确保了组件在高频更新场景下的流畅性。这个组件不仅能让你 Vue3 应用中的 AI 答案看起来更专业,更重要的是它提供了一个处理动态、异步、富文本内容的通用前端解决方案。