在AI聊天、智能问答类应用中,AI生成内容的呈现效果直接决定了用户的使用体验。纯文本的展示形式不仅会让代码块、表格等结构化内容变得杂乱难读,还会因缺乏交互能力降低用户操作效率。本文将从需求痛点出发,基于Vue3.5的组合式API,结合MarkdownIt解析引擎与Prism.js语法高亮工具,一步步构建一个集结构化渲染、专业语法高亮、便捷交互、高性能适配于一体的AI答案美化组件,同时补充多场景扩展方案与性能优化技巧,让AI回复既“赏心悦目”又“高效实用”。
一、需求洞察:AI答案展示的核心痛点与解决方向
当AI返回包含代码、表格、列表等复杂格式的内容时,传统纯文本展示会暴露出诸多体验短板,这些痛点也是我们构建美化组件的核心出发点:
- 格式混乱:代码块无缩进、无颜色区分,大段代码挤成一团;表格无边框、无对齐,数据对比困难;多级列表层级模糊,阅读逻辑混乱。
- 交互缺失:无法一键复制代码,需手动框选;长文本无分段,移动端阅读体验差;特殊内容(如公式、流程图)无法精准渲染。
- 性能瓶颈:AI流式输出时,频繁的DOM更新导致页面卡顿;大体积内容渲染时,主线程阻塞引发交互延迟。
- 兼容性弱:不同浏览器对特殊语法的解析差异大,部分Markdown扩展语法(如脚注、任务列表)无法识别。
针对这些痛点,我们的组件需要实现完整的Markdown语法支持、专业的代码高亮、便捷的交互功能、高性能的动态渲染四大核心目标,同时具备可扩展、可定制的特性,适配不同AI应用的品牌风格。
二、技术选型:为什么是Vue3.5+MarkdownIt+Prism.js?
为了平衡功能完整性、性能与开发效率,我们对技术栈进行了多维度对比,最终确定以下核心工具组合:
| 技术模块 | 选型方案 | 选型依据 | 替代方案对比 |
|---|---|---|---|
| 前端框架 | Vue3.5(组合式API) | 轻量高效的响应式系统,组合式API便于逻辑拆分与复用;支持nextTick、computed等特性,适配动态DOM渲染的时序控制;Vue3.5的watch选项flush:post可精准控制DOM更新时机 |
React:需额外引入状态管理库处理响应式,动态DOM事件绑定成本更高;Angular:体积大,学习成本高,不适合轻量级组件开发 |
| Markdown解析 | MarkdownIt | 轻量级(核心体积仅16KB)、高性能,支持丰富的插件扩展(如GFM语法、脚注、任务列表);可自定义渲染规则,便于嵌入交互元素;社区生态成熟,文档完善 | marked:扩展性弱,自定义渲染逻辑复杂;remark:学习曲线陡,适合复杂AST处理,轻量组件场景下冗余 |
| 语法高亮 | Prism.js | 模块化设计,支持按需加载语言包,减少打包体积;主题丰富且可自定义,语法识别精准;支持行号显示、代码高亮行等高级功能 | highlight.js:默认加载全量语言包,体积较大;语法高亮精度略低于Prism.js,自定义主题成本高 |
| UI交互支持 | Element Plus | 提供成熟的通知、弹窗组件,可快速实现“复制成功”反馈;组件样式统一,便于与自定义样式融合;支持响应式适配,降低移动端适配成本 | Ant Design Vue:体积略大,部分组件风格与轻量化场景不符;原生CSS:需手动实现交互反馈,开发效率低 |
此外,我们还会引入copy-to-clipboard实现跨浏览器的代码复制、MutationObserver监听动态DOM变化、lodash-es的防抖节流工具优化高频操作,形成完整的技术闭环。
三、架构设计:分层解耦的组件体系
为了保证组件的可维护性与可扩展性,我们采用三层架构设计,将渲染、解析、交互逻辑完全分离,遵循单一职责原则:
- Markdown解析层:负责将原始Markdown文本转化为带样式标记的HTML字符串,核心是封装MarkdownIt的配置与插件,实现自定义渲染规则。
- 交互功能层:负责处理代码复制、表格响应式适配、长文本滚动等交互逻辑,通过DOM监听与事件委托实现动态内容的交互绑定。
- Vue组件层:作为组件的对外入口,负责接收数据、触发解析、管理样式与状态,同时处理响应式数据更新与生命周期钩子。
三层架构的核心优势在于逻辑解耦:解析层只需关注语法转化,交互层只需关注用户操作,组件层只需关注数据流转,任一模块的修改都不会影响其他模块,便于单元测试与功能扩展。
四、核心功能实现:从解析到交互的全流程拆解
(一)Markdown解析层:自定义渲染规则,实现语法高亮与结构美化
解析层的核心是markdownUtil.js工具文件,通过配置MarkdownIt并扩展其渲染逻辑,实现从Markdown文本到结构化HTML的转化,同时集成Prism.js完成代码高亮。
1. 基础配置与插件扩展
首先初始化MarkdownIt实例,开启基础语法支持,并引入插件扩展GFM(GitHub Flavored Markdown)语法,实现表格、任务列表、脚注等扩展功能:
// utils/markdownUtil.js
import MarkdownIt from 'markdown-it'
import prism from 'prismjs'
import 'prismjs/themes/prism-tomorrow.css' // 引入高亮主题
import markdownItTaskLists from 'markdown-it-task-lists' // 任务列表插件
import markdownItFootnote from 'markdown-it-footnote' // 脚注插件
import markdownItTableOfContents from 'markdown-it-table-of-contents' // 目录插件
// 初始化MarkdownIt实例
const md = new MarkdownIt({
html: true, // 允许解析HTML标签
linkify: true, // 自动识别链接并转化为a标签
breaks: true, // 换行符转化为<br>
typographer: true, // 启用排版优化(如替换引号为弯引号)
highlight: (str, lang) => {
// 重写代码块高亮逻辑
if (!lang || !prism.languages[lang]) {
// 不支持的语言,返回转义后的纯文本
return `<pre class="code-block"><code>${md.utils.escapeHtml(str)}</code></pre>`
}
// 使用Prism.js进行语法高亮
const highlightedCode = prism.highlight(str, prism.languages[lang], lang)
// 注入代码头部(语言标识+复制按钮)
return `<pre class="code-block language-${lang}">
<div class="code-header">
<span class="lang-label">${lang}</span>
<button class="copy-btn" type="button">复制代码</button>
</div>
<code class="code-content">${highlightedCode}</code>
</pre>`
}
})
// 注册扩展插件
md.use(markdownItTaskLists, { enabled: true })
md.use(markdownItFootnote)
md.use(markdownItTableOfContents, {
includeLevel: [1, 2, 3], // 仅生成h1-h3的目录
containerClass: 'toc-container' // 目录容器类名
})
// 暴露渲染方法
export const renderMarkdown = (content) => {
if (!content) return ''
return md.render(content)
}
在这段代码中,highlight函数是核心:它会拦截MarkdownIt的代码块渲染流程,先通过Prism.js完成语法着色,再为代码块添加包含“语言标签”和“复制按钮”的头部结构,既实现了专业高亮,又为后续交互埋下伏笔。
2. 自定义表格渲染(优化移动端适配)
默认的Markdown表格在移动端会出现横向溢出问题,我们可以通过重写MarkdownIt的表格渲染规则,为表格添加响应式容器:
// 重写表格渲染规则
const defaultTableRender = md.renderer.rules.table_open
md.renderer.rules.table_open = (tokens, idx, options, env, self) => {
// 为表格外层包裹响应式容器
return `<div class="table-responsive">${defaultTableRender(tokens, idx, options, env, self)}`
}
md.renderer.rules.table_close = (tokens, idx, options, env, self) => {
return self.renderToken(tokens, idx, options) + '</div>'
}
配合CSS样式,即可实现移动端表格横向滚动,避免内容溢出:
.table-responsive {
width: 100%;
overflow-x: auto;
margin: 16px 0;
border-radius: 4px;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 8px 12px;
border: 1px solid #e5e7eb;
text-align: left;
}
th {
background-color: #f9fafb;
font-weight: 600;
}
(二)交互功能层:动态DOM监听与便捷操作实现
AI答案常为流式输出(内容逐步追加),传统的onMounted事件绑定会因DOM动态更新失效。我们通过MutationObserver监听DOM变化,结合事件委托实现交互功能的动态绑定。
1. 代码复制功能
首先封装复制工具函数,利用copy-to-clipboard实现跨浏览器复制,并通过Element Plus的ElMessage提供操作反馈:
// utils/interactiveUtil.js
import copy from 'copy-to-clipboard'
import { ElMessage } from 'element-plus'
// 代码复制逻辑
export const handleCodeCopy = (event) => {
const target = event.target
// 仅响应复制按钮的点击
if (!target.classList.contains('copy-btn')) return
// 找到对应的代码内容元素
const codeContent = target.parentElement.nextElementSibling
if (!codeContent) return
// 执行复制
const success = copy(codeContent.textContent)
if (success) {
ElMessage.success('代码复制成功')
} else {
ElMessage.error('代码复制失败,请手动复制')
}
}
// 事件委托绑定(全局仅绑定一次)
export const bindCopyEvent = () => {
document.removeEventListener('click', handleCodeCopy)
document.addEventListener('click', handleCodeCopy)
}
// DOM变化监听
export const observeDomChange = () => {
const observer = new MutationObserver((mutations) => {
let hasNewCodeBlock = false
mutations.forEach((mutation) => {
// 检测是否有新的代码块节点添加
if (mutation.type === 'childList' && mutation.addedNodes.length) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1 && (node.classList.contains('code-block') || node.querySelector('.code-block'))) {
hasNewCodeBlock = true
}
})
}
})
// 有新代码块则重新绑定事件(实际通过事件委托无需重复绑定,此处为兼容特殊场景)
if (hasNewCodeBlock) {
setTimeout(bindCopyEvent, 100)
}
})
// 监听body下的所有DOM变化
observer.observe(document.body, {
childList: true,
subtree: true
})
return observer
}
事件委托的优势在于全局仅需绑定一次点击事件,无论DOM如何动态更新,都能响应复制按钮的点击,相比为每个按钮单独绑定事件,大幅减少了内存占用与CPU消耗。
2. 长文本与图片优化
对于AI返回的超长文本,我们可以添加虚拟滚动(通过vue-virtual-scroller实现),避免一次性渲染大量DOM;对于图片内容,添加懒加载与点击放大功能:
// 图片懒加载(结合IntersectionObserver)
export const initImageLazyLoad = () => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target
img.src = img.dataset.src
observer.unobserve(img)
}
})
})
document.querySelectorAll('img[data-src]').forEach((img) => {
observer.observe(img)
})
}
在Markdown解析时,将图片的src替换为data-src,即可实现懒加载:
// 重写图片渲染规则
md.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx]
const src = token.attrGet('src')
const alt = token.content
// 替换为懒加载格式
return `<img data-src="${src}" alt="${alt}" class="md-image" loading="lazy">`
}
(三)Vue组件层:响应式数据与生命周期管理
最终我们将解析层与交互层的能力封装为Vue组件AiAnswerRenderer.vue,通过组合式API实现数据驱动与生命周期管理:
<template>
<div
class="ai-answer-container"
v-html="renderedContent"
ref="answerRef"
></div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { renderMarkdown } from '@/utils/markdownUtil'
import { bindCopyEvent, observeDomChange, initImageLazyLoad } from '@/utils/interactiveUtil'
import 'element-plus/es/components/message/style/css'
// 接收父组件传入的AI原始内容
const props = defineProps({
content: {
type: String,
required: true,
default: ''
},
theme: {
type: String,
default: 'default' // 支持主题切换
}
})
const answerRef = ref(null)
let domObserver = null
// 缓存解析结果,仅当content变化时重新渲染
const renderedContent = computed(() => {
return renderMarkdown(props.content)
})
// 绑定交互事件
const initInteractive = async () => {
await nextTick() // 等待DOM渲染完成
bindCopyEvent()
initImageLazyLoad()
}
// 监听内容变化,重新初始化交互
watch(() => props.content, () => {
initInteractive()
}, { flush: 'post' }) // DOM更新后执行
// 监听主题变化,切换样式
watch(() => props.theme, (newTheme) => {
answerRef.value?.classList.remove('theme-dark', 'theme-light')
answerRef.value?.classList.add(`theme-${newTheme}`)
})
onMounted(() => {
initInteractive()
// 启动DOM变化监听
domObserver = observeDomChange()
})
onUnmounted(() => {
// 销毁监听器,避免内存泄漏
domObserver?.disconnect()
document.removeEventListener('click', handleCodeCopy)
})
</script>
<style scoped>
.ai-answer-container {
line-height: 1.6;
font-size: 14px;
color: #333;
padding: 16px;
border-radius: 8px;
background-color: #fff;
}
/* 主题样式 */
.theme-dark {
background-color: #1f2937;
color: #f3f4f6;
}
.theme-dark .code-block {
background-color: #2d3748;
}
</style>
组件中computed属性的使用,确保了只有当content真正变化时才会重新执行Markdown解析,避免了重复计算;watch的flush:post选项则保证了DOM更新完成后再执行交互初始化,解决了动态内容的时序问题。
五、性能优化:从解析到渲染的全链路提速
为了让组件在大体积内容、高频更新场景下依然流畅,我们需要从多个维度进行性能优化:
-
解析结果缓存:对相同的Markdown内容,缓存其解析后的HTML字符串,避免重复解析。可通过
Map实现缓存:const renderCache = new Map() export const renderMarkdown = (content) => { if (!content) return '' if (renderCache.has(content)) { return renderCache.get(content) } const result = md.render(content) renderCache.set(content, result) // 缓存清理:超过100条时清空最早的缓存 if (renderCache.size > 100) { const firstKey = renderCache.keys().next().value renderCache.delete(firstKey) } return result } -
Prism.js按需加载:默认加载全量语言包会增加体积,通过动态import实现语言包按需加载:
// 动态加载Prism语言包 const loadPrismLang = async (lang) => { if (prism.languages[lang]) return try { await import(prismjs/components/prism-${lang}.min.js) } catch (e) { console.warn(未找到${lang}对应的语法高亮包) } } -
虚拟滚动适配长文本:当AI答案超过一定长度时,启用虚拟滚动,只渲染可视区域内的内容,减少DOM节点数量:
- 减少重绘重排:对代码块、表格等元素的样式修改,优先使用CSS类名切换而非直接操作
style属性;避免在滚动、输入等高频事件中执行DOM操作。
六、场景扩展:适配更多AI内容类型
除了基础的Markdown内容,我们还可以为组件扩展更多AI专属的内容渲染能力:
- 公式渲染:集成
katex或mathjax,实现AI返回的LaTeX公式渲染,满足学术类AI问答需求。 - 流程图/时序图:通过
mermaid插件,解析Markdown中的流程图语法,将AI生成的流程描述转化为可视化图表。 - 多语言支持:为组件添加国际化配置,支持不同语言的交互提示(如“复制成功”的多语言文案)。
- 内容审核:对接内容安全接口,在渲染前过滤敏感信息,保障应用合规性。
七、总结与展望
通过分层架构设计与精细化的功能实现,我们构建的AI答案美化组件不仅解决了传统文本展示的体验痛点,还具备高性能、可扩展、可定制的特性。它既可以作为独立组件集成到各类AI应用中,也能通过扩展插件适配不同行业的专属需求。
未来,随着AI生成内容的形式愈发多样(如3D模型描述、交互式图表指令),组件还可进一步集成多模态渲染能力,实现“文本+视觉+交互”的一体化展示,为用户带来更沉浸式的AI内容体验。
除非注明,否则均为李锋镝的博客原创文章,转载必须以链接形式标明本文链接
文章评论