vue解决ai流式输出echarts图表、图片闪烁问题

本文是针对 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>

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值