近期在业务中有做AI小模型实现定制化与客制化开发,要求实现流式输出复杂数学公式与数学符号,前端目前较为好用的公式库就是katex.js
一、katex.js
安装 KaTeX 依赖
在 Vue 项目中安装 katex 及其样式库:
bash
npm install katex
npm install katex/dist/katex.min.css
全局引入 KaTeX 样式
在项目的入口文件(如 main.js)中引入 KaTeX 的 CSS:
javascript
import 'katex/dist/katex.min.css'
在组件中使用 KaTeX
通过 import 引入 KaTeX 的核心方法,并在 Vue 组件的模板或方法中调用:
javascript
import { renderToString } from 'katex'
在模板中动态渲染公式时,可以使用 v-html 绑定 KaTeX 生成的 HTML:
html
<template>
<div v-html="latexFormula"></div>
</template>
<script>
export default {
data() {
return {
formula: 'c = \\pm\\sqrt{a^2 + b^2}'
}
},
computed: {
latexFormula() {
return renderToString(this.formula, {
throwOnError: false // 忽略公式错误
})
}
}
}
</script>
按需加载 KaTeX 组件
将Katex封装成一个通用工具函数以方便在组件中引用和处理内容
javascript
import {
marked
} from "marked";
import hljs from "highlight.js";
import katex from "katex";
// 必须保留 katex 样式,否则公式无样式
import "katex/dist/katex.min.css";
import "highlight.js/styles/github.css";
import "github-markdown-css";
marked.use({
extensions: [{
name: "mathBlock",
level: "block",
start(src) {
return src.indexOf("$$");
},
tokenizer(src, tokens) {
const match = src.match(/^\$\$([\s\S]+?)\$\$/);
if (match) {
return {
type: "mathBlock",
raw: match[0],
text: match[1].trim(),
};
}
},
renderer(token) {
return katex.renderToString(token.text, {
displayMode: true,
throwOnError: false,
strict: "ignore", // 比 false 更宽松,忽略语法警告
trust: true, // 信任复杂 LaTeX 命令
});
},
},
{
name: "math",
level: "inline",
start(src) {
return src.indexOf("$");
},
tokenizer(src, tokens) {
// 优化正则:支持公式内包含 []、^、\ 等特殊字符,且避免与块级 $$ 冲突
// 匹配规则:单个 $ 开头 → 任意字符(包括 \、[]、^)→ 单个 $ 结尾(非 $$)
const match = src.replaceAll("<br />", "\n").match(/^(?!\$\$)\$([\s\S]*?)(?<!\$)\$(?!\$)/);
if (match && match[1].trim()) { // 确保公式内容非空
return {
type: "math",
raw: match[0],
text: match[1].trim(),
};
}
},
renderer(token) {
// 核心:配置 katex 支持复杂 LaTeX 语法(\mathrm、上标、括号等)
return katex.renderToString(token.text, {
displayMode: false,
throwOnError: false, // 错误不崩溃
strict: "ignore", // 忽略非致命语法错误
trust: true, // 允许使用 \mathrm 等命令
macros: {
// 可选:预定义常用宏,确保 \mathrm 生效
"\\mathrm": "\\text{#1}",
},
});
},
},
],
});
// 配置 marked
const renderer = new marked.Renderer();
// 配置 marked
marked.setOptions({
renderer: renderer,
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, {
language: lang
}).value;
}
return hljs.highlightAuto(code).value;
},
breaks: false, // 将换行符转换为 <br>
tables: true, // 启用表格支持
gfm: true, // 启用 GitHub 风格的 Markdown
pedantic: false, // 不要过分严格
sanitize: false, // 不要净化 HTML
smartLists: true, // 使用更智能的列表行为
smartypants: false, // 使用更智能的标点符号
xhtml: false // 不要闭合空标签
});
export default marked;
安全性注意事项
使用 v-html 时,确保公式内容可信或经过过滤,避免 XSS 攻击。KaTeX 默认会过滤危险标签,但混合用户输入时仍需谨慎。
二、流式输出
流式输出的基本概念
流式输出(Streaming)指数据分块传输并实时渲染到页面的技术,适用于聊天应用、日志监控等需要动态更新内容的场景。前端实现的核心是通过分块接收数据并逐步渲染,避免等待全部数据加载完成。
使用 Fetch API 处理流式数据
Fetch API 的 response.body 返回一个 ReadableStream 对象,可通过 getReader() 逐块读取数据:
javascript
fetch('/api/stream')
.then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
function readChunk() {
return reader.read().then(({ done, value }) => {
if (done) return;
const text = decoder.decode(value);
document.getElementById('output').innerHTML += text;
return readChunk(); // 递归读取下一块
});
}
return readChunk();
});
使用 EventSource 接收服务器推送
服务器通过 text/event-stream 格式推送数据,前端通过 EventSource 监听事件:
javascript
const eventSource = new EventSource('/api/sse');
eventSource.onmessage = (event) => {
document.getElementById('output').innerHTML += event.data;
};
WebSocket 实时双向通信
WebSocket 适合需要双向通信的场景,如聊天室:
javascript
const socket = new WebSocket('wss://example.com/stream');
socket.onmessage = (event) => {
document.getElementById('output').innerHTML += event.data;
};
优化渲染性能
- 虚拟滚动:仅渲染可视区域内容,减少 DOM 操作。
- 防抖/节流:合并高频更新,避免界面卡顿。
- 增量 DOM 更新:通过 Diff 算法(如 React 的 Reconciliation)最小化渲染开销。
注意事项
- 编码一致性:确保前后端使用相同的字符编码(如 UTF-8)。
- 错误处理 :监听
error事件并重连流式连接。 - 内存管理:长时间运行的流需定期清理已渲染的数据,防止内存泄漏。
以上方法可根据具体场景组合使用,例如 Fetch API + 虚拟滚动实现高效日志展示。
完整函数示例
javascript
/**
* 流式请求处理函数
*/
async streamRequest(url, data = "") {
try {
try {
const response = await fetch(baseUrl + url, {
method: "post",
headers: { Accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({
session_id: this.historyInfo.session_id || "",
query: data,
})
});
if (!response.body) {
throw new Error("ReadableStream not supported in this browser.");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let { value, done } = await reader.read();
// 直接处理响应文本
const assistantMsg = {
id: this.messages.length,
role: "assistant",
raw: "",
data: "",
needThinking: false,
dialog_type: "other",
endLog: false,
steps: [],
type: "",
needFile: false,
files: [],
};
this.currentTypingText = "";
this.messages.push(assistantMsg);
while (!done) {
const text = decoder.decode(value, { stream: true });
assistantMsg.raw += text.slice(6).split("||")[0];
this.$set(assistantMsg, "data", assistantMsg.raw);
if (data === "[DONE]") {
break;
}
({ value, done } = await reader.read());
}
this.scrollToBottom();
} catch (error) {
console.error("Error fetching stream data:", error);
}
} catch (error) {
console.error("Stream request error:", error);
// this.$message.error("请求失败,请重试");
return null;
}
},