本文是针对 vue 生态下 ai 流式输出 echarts 图表的解决方案。
vue生态下主流的md渲染基本都是通过v-html来实现,因此就会出现ai流式输出,导致图片、表格等重复渲染,体感上就是一直在闪烁。
原理是使用v-html在内容发生变化时,会重新渲染整个节点,导致不可避免地闪烁,因此我们需要抛弃v-html改为手动增加dom节点。
我这里使用的是marked这个库,将md转换成html。
实现逻辑:将新的内容转换成html,然后和旧的内容进行比对,更换发生变化的内容。这里并不需要特别复杂的算法,因为ai的内容是流式输出的,前面的内容基本上不会变化,只要比对最后一个节点即可,需要注意的是,像think这个过程,在没有全部输出的时候,会被解析成多个节点,全部的think输出后,think会被解析成一个节点,只替换第一个节点后面的会出现重复内容。因此更新逻辑应该是从第一个节点开始比对,如果不同删除后面所有的节点,更换成新的节点。
1、将md转换成html字符串
2、正则分割成各个dom节点
3、和旧的html进行比对
4、从不同的那一个节点开始更新
核心代码:
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js';
// 初始化marked配置
const renderer = new marked.Renderer();
const originalCode = renderer.code.bind(renderer);
let previousHtml = ''; // 用于存储上一次渲染的 HTML 内容
marked.setOptions({
breaks: true,
highlight: (code, lang) => {
// 完整高亮配置
return hljs.highlightAuto(code).value;
},
renderer
});
// 安全解析函数
const safeParse = html => {
return props.sanitize ? DOMPurify.sanitize(html) : html;
};
// 渲染逻辑
const renderMarkdown = async () => {
const rawHtml = marked.parse(props.content);
if (previousHtml === rawHtml) {
return;
}
const regex = /<(\w+)[^>]*>[\s\S]*?<\/\1>/g;
const rawHtmlBlocks = rawHtml.match(regex) || [];
const previousBlocks = previousHtml.match(regex) || [];
const container = contentRef.value;
rawHtmlBlocks.forEach((newBlock, index) => {
const previousBlock = previousBlocks[index];
const safeBlock = safeParse(newBlock);
const temp = document.createElement('div');
temp.innerHTML = safeBlock;
const newNode = temp.firstElementChild;
if (container.children[index]) {
if (previousBlock !== newBlock) {
// 删除index之后的所有节点
while (container.children.length > index + 1) {
container.removeChild(container.children[index]);
}
const existingNode = container.children[index];
existingNode.replaceWith(newNode.cloneNode(true));
}
} else {
// 新增末尾节点
container.appendChild(newNode.cloneNode(true));
}
});
// 更新previousHtml引用
previousHtml = rawHtml;
};
watch(() => props.content, renderMarkdown);
onMounted(renderMarkdown);
然后是渲染echarts,基本上做完增量渲染就很简单了,我这里用的是marked就用marked为例。
我这里其实是投机了,就是根据echarts的配置来生成id,用来后面渲染使用,应该用一个map之类的来保存会更加合适。
function stringHash (str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) + hash + str.charCodeAt(i);
}
return hash >>> 0; // 转换为正数
}
renderer.code = function (code, language) {
if (code.lang === 'echarts') {
if (code.raw.trim().startsWith('```') && code.raw.trim().endsWith('```')) {
const chartId = stringHash(code.raw).toString(36);
return `<div id="${chartId}" class="echart-container"></div>`;
} else {
return `<div class="echart-loading">图表渲染中</div>`;
}
}
return originalCode(code, language);
};
// 渲染逻辑
const renderMarkdown = async () => {
// ...前面不变
await nextTick();
// 获取所有匹配的配置块
const configBlocks = [...props.content.matchAll(/```echarts\n([\s\S]*?)\n```/g)];
// 遍历容器时使用索引对应配置
contentRef.value.querySelectorAll('.echart-container').forEach((container, index) => {
const configCode = configBlocks[index]?.[1];
if (!configCode) return;
if (chartInstances.has(container.id)) {
return;
}
try {
const chart = echarts.init(container);
const config = new Function(`return (${configCode})`)();
chart.setOption(config);
// 响应式处理
const resizeObserver = new ResizeObserver(() => chart.resize());
resizeObserver.observe(container);
onBeforeUnmount(() => resizeObserver.disconnect());
chartInstances.set(container.id, chart);
} catch (error) {
container.innerHTML = `<div class="error">${error.message}</div>`;
}
});
}
完整代码如下
<template>
<div class="markdown-container">
<div ref="contentRef" class="markdown-content">
<div
class="loading-box"
v-if="!props.content || props.content === ''"
v-loading="true"
></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onBeforeUnmount, nextTick } from 'vue';
import * as echarts from 'echarts';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js'; // 新增高亮库
const props = defineProps({
content: String,
sanitize: {
type: Boolean,
default: true
}
});
const contentRef = ref(null);
const chartInstances = new Map();
// 初始化marked配置
const renderer = new marked.Renderer();
const originalCode = renderer.code.bind(renderer);
let previousHtml = ''; // 用于存储上一次渲染的 HTML 内容
function stringHash (str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) + hash + str.charCodeAt(i);
}
return hash >>> 0; // 转换为正数
}
renderer.code = function (code, language) {
if (code.lang === 'echarts') {
if (code.raw.trim().startsWith('```') && code.raw.trim().endsWith('```')) {
const chartId = stringHash(code.raw).toString(36);
return `<div id="${chartId}" class="echart-container"></div>`;
} else {
return `<div class="echart-loading">图表渲染中</div>`;
}
}
return originalCode(code, language);
};
marked.setOptions({
breaks: true,
highlight: (code, lang) => {
// 完整高亮配置
return hljs.highlightAuto(code).value;
},
renderer
});
// 安全解析函数
const safeParse = html => {
return props.sanitize ? DOMPurify.sanitize(html) : html;
};
// 渲染逻辑
const renderMarkdown = async () => {
const rawHtml = marked.parse(props.content);
if (previousHtml === rawHtml) {
return;
}
const regex = /<(\w+)[^>]*>[\s\S]*?<\/\1>/g;
const rawHtmlBlocks = rawHtml.match(regex) || [];
const previousBlocks = previousHtml.match(regex) || [];
const container = contentRef.value;
rawHtmlBlocks.forEach((newBlock, index) => {
const previousBlock = previousBlocks[index];
const safeBlock = safeParse(newBlock);
const temp = document.createElement('div');
temp.innerHTML = safeBlock;
const newNode = temp.firstElementChild;
if (container.children[index]) {
if (previousBlock !== newBlock) {
// 删除index之后的所有节点
while (container.children.length > index + 1) {
container.removeChild(container.children[index]);
}
const existingNode = container.children[index];
existingNode.replaceWith(newNode.cloneNode(true));
}
} else {
// 新增末尾节点
container.appendChild(newNode.cloneNode(true));
}
});
// 更新previousHtml引用
previousHtml = rawHtml;
await nextTick();
// 获取所有匹配的配置块
const configBlocks = [...props.content.matchAll(/```echarts\n([\s\S]*?)\n```/g)];
// 遍历容器时使用索引对应配置
contentRef.value.querySelectorAll('.echart-container').forEach((container, index) => {
const configCode = configBlocks[index]?.[1];
if (!configCode) return;
if (chartInstances.has(container.id)) {
return;
}
try {
const chart = echarts.init(container);
const config = new Function(`return (${configCode})`)();
chart.setOption(config);
// 响应式处理
const resizeObserver = new ResizeObserver(() => chart.resize());
resizeObserver.observe(container);
onBeforeUnmount(() => resizeObserver.disconnect());
chartInstances.set(container.id, chart);
} catch (error) {
container.innerHTML = `<div class="error">${error.message}</div>`;
}
});
};
watch(() => props.content, renderMarkdown);
onMounted(renderMarkdown);
</script>
<style lang="scss" scoped>
.markdown-container {
width: 100%;
min-width: 0;
}
.error {
color: #ff4d4f;
padding: 10px;
background: #fff2f0;
border: 1px solid #ffccc7;
}
</style>
<style>
.echart-container {
height: 300px;
}
.echart-loading {
height: 300px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #333;
}
.loading-box{
height: 40px;
width: 10px;
}
</style>