0️⃣ 前言
GPT
的流输出还是要渲染成 markdown
, 网络上的教程还是蛮多的,个人打算用 VUE3
创建一个调用 GPT
的 stream
对话的网页,但是查阅了很多资料,发现大部分的处理方法都是用v-html
或者是innerHTML
的强制刷新,这样导致文本在框选了的时候,就没有办法保证框选中的文本一直是标记的(因为这样会造成浏览器重新刷新一次内容), 但是ChatGPT
的是动态递增的,是不会影响之前的内容的选中的, 那么这里给出一个简单的这样的实现思路.
总的来说就是这个博客介绍一个 在 HTML
上动态更新markdown 内容(不是重新刷新)
2025-04-12 更新
- 加了textarea位置 支持输入自定义的markdown内容查看渲染效果
- 优化了渲染队列, 只拿最新数据做渲染, 减少渲染压力
- 代码改动的commit可以看: github: Commit 97c10a3 或者是 Gitee: Commit 97c10a3
🔍 开发平台
- 框架是
VUE3
和vite
node
版本是v20.15.1
markdown
渲染核心工具是markdown-it
- 参考了我之前用的做静态个人网页的项目的
markdown
的渲染方法: VUE3_Static_Blog_WebSite
😎 体验预览
👉 个人开发网站 在线体验: pldz1.com/_codespace/…
👉 githubio 在线体验: pldz1.github.io/_codespace/…
👉 下面的代码源码被放在了: Demos/sse_markdown
拉取下来输入 npm run dev
运行查看效果.
预览的效果如下所示:
1️⃣ 实现介绍
🏠 用到的库
package.json
如下所示:
"clipboard": "^2.0.10",
"highlight.js": "^11.4.0",
"jquery": "^3.6.0",
"markdown-it": "^12.3.2",
"markdown-it-abbr": "^1.0.4",
"markdown-it-container": "^3.0.0",
"markdown-it-deflist": "^2.1.0",
"markdown-it-emoji": "^2.0.0",
"markdown-it-footnote": "^3.0.3",
"markdown-it-ins": "^3.0.1",
"markdown-it-mark": "^3.0.1",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
"markdown-it-task-lists": "^2.1.1",
"markdown-it-toc": "^1.1.0",
"markdown-it-toc-done-right": "^4.2.0",
"@DatatracCorporation/markdown-it-mermaid": "npm:@datatraccorporation/markdown-it-mermaid@^0.5.0"
📻 用 JavaScript 模仿 SSE
JavaScript 模仿 SSE 的返回代码:
export const codeContent = "xxxxxxxxxxxxxxxxxxxxxx";
/** SSE 生成器 */
export function* yieldContent() {
let i = 0;
while (i < codeContent.length) {
// 随机生成 1 到 20 之间的数
const chunkSize = Math.floor(Math.random() * 20) + 1;
// 获取一个片段
const chunk = codeContent.slice(i, i + chunkSize);
yield chunk; // 一次性返回这部分字符
i += chunkSize; // 更新索引,跳过已经返回的字符
// 休眠 20ms
yield new Promise((resolve) => setTimeout(resolve, 20));
}
}
JavaScript 接收 SSE 的结果的例子:
/** Step 1. 处理 SSE 返回的内容. */
const processStep = (generator) => {
// 获取生成迭代对象
const result = generator.next();
// 生成器结束
if (result.done) return;
if (result.value instanceof Promise) {
// 如果值是 Promise,等待它完成再继续
result.value
.then(() => {
// 无意义的结果, 继续执行下一个步骤
processStep(generator);
})
.catch((error) => {
console.error("Error occurred during promise resolution:", error);
});
} else {
// 将当前的部分内容加入队列进行渲染
// 这个时候拿到的 result.value 是 string 是有意义的结果,用来处理具体逻辑
// <ANY FUNCTION>(result.value);
// Input your code under here.
// ................
//
// 继续下一步
processStep(generator);
}
};
/** Step 0. 开始 SSE 模拟 */
const yieldedSSEContent = () => {
const generator = yieldContent();
// 开始处理生成器的每一步
processStep(generator);
};
📕 markdown-it 插件配置
用法还是蛮简单的, 用 use
方法可以直接使用这个插件.
import MarkdownIt from "markdown-it";
import emoji from "markdown-it-emoji";
import deflist from "markdown-it-deflist";
import abbr from "markdown-it-abbr";
import footnote from "markdown-it-footnote";
import ins from "markdown-it-ins";
import mark from "markdown-it-mark";
import taskLists from "markdown-it-task-lists";
import container from "markdown-it-container";
import toc from "markdown-it-toc-done-right";
import mermaid from "@DatatracCorporation/markdown-it-mermaid";
var config = {
html: true,
xhtmlOut: true,
breaks: true,
langPrefix: "lang-",
linkify: false,
typographer: true,
quotes: "“”‘’",
};
let markdownIt = new MarkdownIt(config);
markdownIt
.use(emoji)
.use(deflist)
.use(abbr)
.use(footnote)
.use(ins)
.use(mark)
.use(taskLists)
.use(container)
.use(container, "hljs-left")
.use(container, "hljs-center")
.use(container, "hljs-right")
.use(toc)
.use(mermaid);
export default markdownIt;
💼 Code Block 的渲染
这个主要是对要放入 markdown
内容的 div
的内容进行额外的样式处理
import "highlight.js/styles/atom-one-dark.css";
import $ from "jquery";
import hljs from "highlight.js/lib/core";
import javascript from "highlight.js/lib/languages/javascript";
import vbscript from "highlight.js/lib/languages/vbscript";
import python from "highlight.js/lib/languages/python";
import matlab from "highlight.js/lib/languages/matlab";
import csharp from "highlight.js/lib/languages/csharp";
import shell from "highlight.js/lib/languages/shell";
import vhdl from "highlight.js/lib/languages/vhdl";
import java from "highlight.js/lib/languages/java";
import css from "highlight.js/lib/languages/css";
import xml from "highlight.js/lib/languages/xml";
import sql from "highlight.js/lib/languages/sql";
import cpp from "highlight.js/lib/languages/cpp";
import c from "highlight.js/lib/languages/c";
import ClipboardJS from "clipboard";
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("vbscript", vbscript);
hljs.registerLanguage("python", python);
hljs.registerLanguage("matlab", matlab);
hljs.registerLanguage("csharp", csharp);
hljs.registerLanguage("shell", shell);
hljs.registerLanguage("vhdl", vhdl);
hljs.registerLanguage("java", java);
hljs.registerLanguage("html", xml);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("css", css);
hljs.registerLanguage("sql", sql);
hljs.registerLanguage("cpp", cpp);
hljs.registerLanguage("c", c);
hljs.configure({ ignoreUnescapedHTML: true });
/**
* 高亮代码块
* @param {Element} element 包含 pre code 代码块的元素
*/
function highlightCode(element) {
const codeEls = element.querySelectorAll("pre code");
codeEls.forEach((el) => {
hljs.highlightElement(el);
});
}
/**
* 给代码块添加复制按钮
* @param {Element} element 包含 pre code 代码块的元素
*/
function buildCopyButton(element) {
let $pres = $(element).find("pre");
if (!$pres.length) return;
$pres.each(function () {
var t = $(this).children("code").text();
// 创建按钮
var btn = $('<span class="copy">复制</span>').attr(
"data-clipboard-text",
t
);
$(this).prepend(btn);
var c = new ClipboardJS(btn[0]);
c.on("success", function () {
btn.addClass("copyed").text("复制成功");
setTimeout(function () {
btn.text("复制").removeClass("copyed");
}, 1000);
});
c.on("error", function () {
btn.text("复制失败");
});
});
}
/** 构建生成中的 markdown 的内容 */
export function buildCodeBlock(element) {
highlightCode(element);
buildCopyButton(element);
}
✨ 动态渲染 markdown
对比两个 html div
的 DOM
树, 对比各种属性和层级变化, 动态的更新
这个函数有一个硬代码, 因为 root
节点要被保护一下, 所以加入了判断里.
/** 核心函数, 对比节点的内容 实现动态更新 markdown 的 div 而不是用 innerHTML 的属性全部刷新 */
export function deepCloneAndUpdate(div1, div2) {
// 递归比较和更新 div1 和 div2 的子节点
function compareAndUpdate(node1, node2) {
// 情况 1:node1 是文本节点,更新文本内容
if (
node1 &&
node1.nodeType === Node.TEXT_NODE &&
node2.nodeType === Node.TEXT_NODE
) {
if (node1.nodeValue !== node2.nodeValue) {
// 更新文本内容
node1.nodeValue = node2.nodeValue;
}
return;
}
// 情况 2:node1 和 node2 的标签名不同,替换整个节点
if (!node1 || node1.tagName !== node2.tagName) {
// 克隆 node2 节点
const newNode = node2.cloneNode(true);
if (node1) {
// 替换旧节点
node1.parentNode.replaceChild(newNode, node1);
} else {
// 如果 node1 不存在,直接新增
node2.parentNode.appendChild(newNode);
}
return;
}
// 情况 3:节点的 class 或其他属性更新, 注意对root节点的保护
if (
node1.className !== "article-content" &&
node1.className !== node2.className
) {
// 3.1 更新 className
node1.className = node2.className;
}
// 3.2 对 id 的更新 注意对root节点的保护
if (node1.id !== "article-content" && node1.id !== node2.id) {
node1.id = node2.id;
}
// 3.3 对 style 的更新
if (node1.style.cssText !== node2.style.cssText) {
node1.style.cssText = node2.style.cssText;
}
// 情况 4:递归对比和更新子节点
const children1 = Array.from(node1.childNodes); // node1 的所有子节点
const children2 = Array.from(node2.childNodes); // node2 的所有子节点
// 遍历 node2 的子节点,逐个与 node1 的对应子节点比较
children2.forEach((child2, index) => {
const child1 = children1[index];
if (!child1) {
// 如果 child1 不存在,直接克隆 child2 并添加到 node1
const newChild = child2.cloneNode(true);
node1.appendChild(newChild);
} else {
// 如果 child1 存在,递归比较和更新
compareAndUpdate(child1, child2);
}
});
// 删除 node1 中多余的子节点
if (children1.length > children2.length) {
for (let i = children2.length; i < children1.length; i++) {
node1.removeChild(children1[i]);
}
}
}
// 从 div2 根节点开始与 div1 比较
compareAndUpdate(div1, div2);
}
💡 主页渲染
主页渲染很重要, 因为要处理 SSE
的生成速度比 markdown
的动态渲染快, 要用异步队列来处理这个频率.
<template>
<div class="article-details">
<div class="container">
<button class="start-btn" @click="yieldedSSEContent">开始模仿SSE</button>
<div class="post-body">
<div class="article-content" id="article-content"></div>
</div>
</div>
</div>
</template>
<script setup>
import markdownIt from "./module/markdown-it";
import { deepCloneAndUpdate, buildCodeBlock } from "./module/code-block.js";
import { yieldContent } from "./module/server.js";
let htmlData = "";
let el = null;
let isRendering = false;
const renderQueue = [];
/** Step 4. 渲染markdown的 HTML Element. */
const renderMarkdown = (data) => {
if (!el) el = document.getElementById("article-content");
if (!el) return;
const tmpDiv = document.createElement("div");
tmpDiv.innerHTML = markdownIt.render(data); // 只渲染当前的块
buildCodeBlock(tmpDiv);
// 这里不再拼接 htmlData,而是每次渲染独立的块
deepCloneAndUpdate(el, tmpDiv);
};
/** Step 3. 处理异步渲染 */
const processRenderQueue = () => {
if (renderQueue.length === 0) {
isRendering = false; // 队列为空时标记渲染完成
return;
}
const data = renderQueue.shift(); // 获取并移除队列中的第一个渲染任务
renderMarkdown(data); // 执行渲染操作
// 继续处理下一个渲染任务
setTimeout(processRenderQueue, 0);
};
/** Step 2. 异步队列控制渲染 */
const enqueueRender = (data) => {
htmlData += data;
renderQueue.push(htmlData);
// 如果当前没有渲染任务在进行,启动渲染队列
if (!isRendering) {
isRendering = true;
processRenderQueue();
}
};
/** Step 1. 处理 SSE 返回的内容. */
const processStep = (generator) => {
// 获取下一个值
const result = generator.next();
// 如果生成器结束,停止并设置 state 为 "done"
if (result.done) {
// 渲染生成器最后的值
enqueueRender("");
return;
}
if (result.value instanceof Promise) {
// 如果值是 Promise,等待它完成再继续
result.value
.then(() => {
// 继续执行下一个步骤
processStep(generator);
})
.catch((error) => {
console.error("Error occurred during promise resolution:", error);
});
} else {
// 将当前的部分内容加入队列进行渲染
enqueueRender(result.value);
// 继续下一步
processStep(generator);
}
};
/** Step 0. 开始 SSE 模拟 */
const yieldedSSEContent = () => {
// 重置 Demo 的div
const el = document.getElementById("article-content");
if (el) {
htmlData = "";
el.innerHTML = "";
}
// 正式的 DEMO 开始, 获取生成器
const generator = yieldContent();
// 开始处理生成器的每一步
processStep(generator);
};
</script>
✏️ 总结
-
性能优化:
- 使用 生成器 (
yield
) 和 异步队列 控制数据的分块渲染,避免一次性渲染过多内容导致卡顿。 - 渲染过程中,通过
setTimeout
控制任务的执行频率,减轻浏览器渲染压力。
- 使用 生成器 (
-
动态节点对比:
- 通过
deepCloneAndUpdate
实现 DOM 节点的精准比较与更新,避免直接使用innerHTML
。该方法递归地比较并更新元素的文本、属性和子节点,最大限度减少不必要的 DOM 操作,提高性能。
- 通过