ReactMarkdown SyntaxHighlighter处理流式字符串时失效问题
问题描述
流式获取数据并传递给ReactMarkdown组件时,语法高亮功能失效;但传递完整字符串时,语法高亮正常工作。推测ReactMarkdown无法解析不完整的流式字符串。
流式数据获取代码
const response = await fetch( `${publicRuntimeConfig.REACT_APP_API_URL}/api/v1/get_response_stream`, { method: "POST", body: JSON.stringify({ user_input: user_input, topic: router.query.topic, }), headers: { "Content-Type": "application/json", authorization: "Bearer " + globaluser.token, }, } ); const reader = response.body.getReader(); let first = false; while (true) { const { done, value } = await reader.read(); if (done) { // 复制最终结果硬编码时,语法高亮正常 console.log(chats, "Done"); break; } const text = new TextDecoder().decode(value); let updated = text ?.replace(/data:/g, "") ?.replace(/data: /g, "") ?.replace(/\s+/g, " "); //?.replace(/\n/g, "<br>") //.trim(); // console.log(updated); if (!first) { // 添加新消息 setChats((d) => [...d, updated]); first = true; } else { // 更新最后一条消息 setChats((d) => { let existingChats = [...d]; let newValue = `${ existingChats[existingChats.length - 1] }${updated}`; //console.log(newValue); existingChats[existingChats.length - 1] = newValue; return existingChats; }); bottomRef.current?.scrollIntoView(); } }
聊天内容渲染代码
{chats.map((chat, i) => <Typography key={i} variant='subtitle1' sx={{ whiteSpace: "break-spaces", }} className='editor' > <ReactMarkdown children={chat .replace("then user: ", "") .replace("then system: ", "") .replace("system : ", "") .replace("system: ", "") .replace("user: ", "") .replace(/^(\d+)\.\s/gm, "$1\\. ") .replace(/^\*\s/gm, "\\* ")} components={{ code({ node, inline, className, children, ...props }) { const match = /language-(\w+)/.exec( className || "" ); return !inline && match ? ( <SyntaxHighlighter {...props} children={String(children).replace( /\n$/, "" )} style={dark} language={match[1]} PreTag='div' /> ) : ( <code {...props} className={className}> {children} </code> ); }, }} /> </Typography> )}
解决方案
核心原因
流式传输时,代码块是逐步拼接的,ReactMarkdown每次渲染的是不完整的Markdown片段(比如只收到javascript`开头,还没到结尾的),此时无法识别为完整的代码块,自然不会触发语法高亮逻辑。
具体解决方法
1. 跟踪代码块闭合状态,延迟高亮
在流式拼接内容时,记录是否处于未闭合的代码块中,只有当代码块完整时,才启用语法高亮:
修改流式数据获取逻辑,添加代码块状态跟踪
const response = await fetch( `${publicRuntimeConfig.REACT_APP_API_URL}/api/v1/get_response_stream`, { method: "POST", body: JSON.stringify({ user_input: user_input, topic: router.query.topic, }), headers: { "Content-Type": "application/json", authorization: "Bearer " + globaluser.token, }, } ); const reader = response.body.getReader(); let first = false; let inUnclosedCodeBlock = false; // 标记是否存在未闭合的代码块 while (true) { const { done, value } = await reader.read(); if (done) { // 处理未闭合的代码块(可选:补全闭合标记,或保持原样) if (inUnclosedCodeBlock) { setChats(prev => { const newChats = [...prev]; newChats[newChats.length - 1] += '```'; return newChats; }); } console.log(chats, "Done"); break; } const text = new TextDecoder().decode(value); let updated = text ?.replace(/data:/g, "") ?.replace(/data: /g, "") ?.replace(/\s+/g, " "); // 统计当前片段中的代码块标记数量,更新闭合状态 const codeBlockMarkers = updated.match(/```/g) || []; codeBlockMarkers.forEach(() => { inUnclosedCodeBlock = !inUnclosedCodeBlock; }); if (!first) { setChats((d) => [...d, updated]); first = true; } else { setChats((d) => { let existingChats = [...d]; let newValue = `${existingChats[existingChats.length - 1]}${updated}`; existingChats[existingChats.length - 1] = newValue; return existingChats; }); bottomRef.current?.scrollIntoView(); } }
修改渲染逻辑,根据代码块状态启用高亮
添加辅助函数判断内容是否包含完整代码块,再决定是否触发语法高亮:
// 辅助函数:检查内容中的代码块是否全部闭合 const hasCompleteCodeBlocks = (content) => { const markerCount = (content.match(/```/g) || []).length; return markerCount % 2 === 0; }; // 渲染组件部分 {chats.map((chat, i) => { const processedChat = chat .replace("then user: ", "") .replace("then system: ", "") .replace("system : ", "") .replace("system: ", "") .replace("user: ", "") .replace(/^(\d+)\.\s/gm, "$1\\. ") .replace(/^\*\s/gm, "\\* "); const isCodeComplete = hasCompleteCodeBlocks(processedChat); return ( <Typography key={i} variant='subtitle1' sx={{ whiteSpace: "break-spaces" }} className='editor' > <ReactMarkdown children={processedChat} components={{ code({ node, inline, className, children, ...props }) { // 如果代码块不完整且不是行内代码,先渲染普通文本 if (!isCodeComplete && !inline) { return <pre {...props}><code>{children}</code></pre>; } const match = /language-(\w+)/.exec(className || ""); return !inline && match ? ( <SyntaxHighlighter {...props} children={String(children).replace(/\n$/, "")} style={dark} language={match[1]} PreTag='div' /> ) : ( <code {...props} className={className}> {children} </code> ); }, }} /> </Typography> ); })}
2. 折中方案:积累内容后再渲染
如果对实时性要求不高,可以将流式数据先拼接成完整字符串,再一次性传递给ReactMarkdown。但这种方式会失去流式输出的即时反馈,适合对高亮优先级高于实时性的场景。
内容的提问来源于stack exchange,提问作者Muhammad Sami
相关产品推荐
相关产品推荐

