React(二):構建一個簡單的聊天助手學到的React知識
前言
先來看下效果。
非流式(基本不會用):

流式:

其實創建React項目一般都會直接使用組件庫,比如Ant-Design,就比如構建聊天助手,其實使用Ant-Design-X就比較好,但是畢竟Ant-Design-X太新了,AI估計寫的不太好,需要自己看文檔,后面可以考慮用下Ant-Design-X。剛開始學習可以先不用組件,直接把界面交給AI,樣式也直接讓AI來寫。等到對React有一些基礎的了解了,就可以自己上手看組件庫的文檔,去使用組件庫了。畢竟AI寫的界面維護起來挺麻煩的,一堆.css文件,隨著界面的增加,.css樣式也越多,后面要改哪塊自己也不太清楚,當然寫Demo無所謂了,直接交給AI即可。
總結一下在構建這個簡單的聊天助手中學到的React知識,包含React本身的概念與相關生態。
項目結構
WPF中如果有一個服務邏輯很多個頁面都可以共用,我們一般會新建一個Service文件夾,把這個共用服務放到這個位置,就比如說向LLM發送一個API請求,這個多個頁面都要用到,如果只是寫在某一個頁面的按鈕點擊事件中,那么就要寫很多次,而且頁面包含了太多邏輯,不好維護。
問AI之后,AI給出的方案是增加types、services與hooks,如下所示:

types與services大概知道是什么東西,types就是相當于WPF中我們寫的Model,定義了一些數據結構:

作用:統一定義前端與服務層之間傳遞的結構化數據的類型,保證調用處與實現處在編譯期即可發現不匹配。
Services中就是封裝了API請求,如下所示:

作用:對外部系統(HTTP、SSE、WebSocket 等)進行通信封裝;聚焦網絡協議、錯誤處理、數據解析與重試等細節,不參與 UI 狀態管理。
目前就幾個方法,就使用了靜態方法即可。在WPF中一般是將服務類注入到容器中,但目前沒有接觸到React中有這樣操作,而是采用了一個自定義hook。
現在來看看這個自定義hook:

作用:圍繞 React 狀態構建可復用的“用例邏輯”,把服務結果轉化為組件可直接使用的狀態與方法(messages、輸入框、loading、發送/清空等)。
自定義hook是React中一個很重要的概念,現在來我們來了解一下。
自定義 Hook 是把“可復用的狀態與副作用邏輯”封裝成以 use 開頭的函數,供多個組件共享,不直接渲染 UI。
使用自定義Hook有什么好處?
1、復用與一致性
多頁面/多個組件可以共用同一份聊天邏輯,無需重復粘貼網絡請求與狀態管理代碼。
任一處修復或優化(例如流式解析、錯誤提示格式)會自動惠及所有使用該 Hook 的組件。
2、狀態編排集中、UI 簡化
就是把一些原本寫在UI中的邏輯提取出來,避免了在UI中寫太多邏輯。
在我這個Demo中,整體結構是這樣的:

現在大概了解了一下自定義Hook,讓我們看看在組件中是如何使用的:

首先導入這個自定義Hook,會發現這個Hook已經自帶了一些屬性與方法,然后在這個UI組件中直接使用這些屬性與方法即可,現在我們的這個組件的代碼量比起之前已經大大減少了。
React Hook
現在遇到了兩個ReactHook,分別是useState與useCallback。
useState用法示例:
const [inputText, setInputText] = useState('');
剛開始學習推薦先看下文檔,地址:https://react.dev/reference/react/useState
useState 是 React 的一個 Hook,它允許你在組件中添加一個狀態變量。
useCallback用法示例:
const clearMessages = useCallback(() => {
setMessages(initialMessages);
}, []);
文檔地址:https://react.dev/reference/react/useCallback
useCallback 是一個 React Hook,它允許你在重新渲染之間緩存函數定義。
React 的 useCallback() 用來“記憶”回調函數的引用,只有依賴列表中的值變化時才重新創建函數。
它的價值在于:傳給使用 React.memo 或在 useEffect 里使用的子組件/第三方庫時,避免無意義的重新執行。當某回調本身被其他鉤子/效果的依賴引用時,防止依賴變化導致的循環或多余重跑。
看了一下解釋感覺還是有點迷,我們只要搞清楚為什么我們在這個自定義hook中要使用useCallback就行了。
穩定引用,減少下游重渲染:這些回調會被 hook 返回并作為 props 傳給頁面或子組件。如果不使用 useCallback(),每次父組件渲染都會得到新的函數引用,導致使用 React.memo 的子組件或依賴回調引用的庫產生不必要的重渲染或重復訂閱。
控制依賴觸發時機,避免無意義的副作用重跑:消費者很可能在 useEffect 或 useMemo 的依賴數組里引用這些回調。通過 useCallback() 明確依賴變更時機,保證只有在真正相關的狀態變動時才更新回調引用,從而避免“每次渲染都觸發 effect”的問題。
維持事件處理器和鏈式依賴的穩定性:handleKeyPress() 依賴并調用 sendMessage()。如果不使用 useCallback,sendMessage 每次渲染都會變,進而導致 handleKeyPress 每次也變,繼續向下游(輸入框/按鈕等)傳播不必要的引用變化。
react生態:react-markdown
學習使用React除了學習React本身的概念與設計思想外,很重要的一點就是多接觸一些react生態。
在構建一個簡單的聊天助手很重要的一點就是渲染md格式文本,因為LLM比較喜歡返回md格式內容,如果不渲染直接顯示,會不好看,很多 ** ## 這類符號。在React中渲染md格式內容,說實話比在WPF中簡單多了,WPF中渲染md內容,目前還沒找到一個比較好用的解決方案。
在React中丟給AI就能寫很多東西,AI寫了一個MarkdownRenderer組件:
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
import './MarkdownRenderer.css';
interface MarkdownRendererProps {
content: string;
isStreaming?: boolean;
}
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
isStreaming = false
}) => {
return (
<div className="markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
h1: ({ children }) => <h1 className="markdown-h1">{children as React.ReactNode}</h1>,
h2: ({ children }) => <h2 className="markdown-h2">{children as React.ReactNode}</h2>,
h3: ({ children }) => <h3 className="markdown-h3">{children as React.ReactNode}</h3>,
h4: ({ children }) => <h4 className="markdown-h4">{children as React.ReactNode}</h4>,
h5: ({ children }) => <h5 className="markdown-h5">{children as React.ReactNode}</h5>,
h6: ({ children }) => <h6 className="markdown-h6">{children as React.ReactNode}</h6>,
p: ({ children }) => <p className="markdown-p">{children as React.ReactNode}</p>,
ul: ({ children }) => <ul className="markdown-ul">{children as React.ReactNode}</ul>,
ol: ({ children }) => <ol className="markdown-ol">{children as React.ReactNode}</ol>,
li: ({ children }) => <li className="markdown-li">{children as React.ReactNode}</li>,
blockquote: ({ children }) => (
<blockquote className="markdown-blockquote">{children as React.ReactNode}</blockquote>
),
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const isInline = (props as any).inline ?? !className;
if (isInline) {
return (
<code className="markdown-inline-code" {...props}>
{children as React.ReactNode}
</code>
);
}
const langClass = match ? className || '' : '';
return (
<code className={`markdown-code-block ${langClass}`} {...props}>
{children as React.ReactNode}
</code>
);
},
pre: ({ children }) => <pre className="markdown-pre">{children as React.ReactNode}</pre>,
a: ({ href, children }) => (
<a href={href} className="markdown-link" target="_blank" rel="noopener noreferrer">
{children as React.ReactNode}
</a>
),
strong: ({ children }) => <strong className="markdown-strong">{children as React.ReactNode}</strong>,
em: ({ children }) => <em className="markdown-em">{children as React.ReactNode}</em>,
hr: () => <hr className="markdown-hr" />,
table: ({ children }) => <table className="markdown-table">{children as React.ReactNode}</table>,
thead: ({ children }) => <thead className="markdown-thead">{children as React.ReactNode}</thead>,
tbody: ({ children }) => <tbody className="markdown-tbody">{children as React.ReactNode}</tbody>,
tr: ({ children }) => <tr className="markdown-tr">{children as React.ReactNode}</tr>,
th: ({ children }) => <th className="markdown-th">{children as React.ReactNode}</th>,
td: ({ children }) => <td className="markdown-td">{children as React.ReactNode}</td>,
}}
>
{content}
</ReactMarkdown>
{isStreaming && (
<span className="streaming-cursor">|</span>
)}
</div>
);
};
export default MarkdownRenderer;
了解React的生態,我們可以看看AI使用了什么庫。
react-markdown介紹
這個包是一個 React 組件,可以接收一個 Markdown 字符串,并將其安全地渲染為 React 元素。您可以傳遞插件來改變 Markdown 的轉換方式,還可以傳遞組件來替代普通的 HTML 元素。
GitHub地址:https://github.com/remarkjs/react-markdown

remark-gfm介紹
remark 插件支持 GFM(自動鏈接字面量、腳注、刪除線、表格、任務列表)
GitHub地址:https://github.com/remarkjs/remark-gfm

remark-breaks介紹
remark 插件支持在不使用空格或轉義字符的情況下實現硬換行(將回車轉換為
標簽)。
GitHub地址:https://github.com/remarkjs/remark-breaks

主要是第一個,很多時候用第一個就夠了,第二個第三個都只是插件,增加一些功能。
看下在聊天頁面中如何使用:

導入這個組件直接使用即可。
SSE
我們之前可能接觸到的很多請求模式可能都是發送一個請求收到一個響應這樣。
就比如非流式響應,你向AI提問一個問題,AI全部返回一下子返回給你。但是在聊天應用中這種模式,用戶或許很難以忍受,因為這種等待的時間要長一點,而且是一下子顯示的。

所以需要流式響應,而后端向前端流式響應,就需要用到SSE了。
Server-Sent Events(簡稱 SSE,服務器發送事件)是一種讓服務器主動向客戶端(通常是瀏覽器)推送數據的技術。它基于 HTTP 協議,允許服務器持續發送更新,而客戶端只需建立一次連接,便可接收不斷傳來的消息。
與傳統的“客戶端請求 → 服務器響應”模式不同,SSE 實現了服務器到客戶端的單向實時通信。

首先需要先有一個流式返回的接口,創建一個Web API項目,一個流式接口可以這樣寫:
[HttpGet("ai-response-streaming")]
public async Task<IActionResult> GetAIResponseStreaming([FromQuery] string prompt)
{
try
{
if (string.IsNullOrWhiteSpace(prompt))
{
return BadRequest("Prompt cannot be empty.");
}
_logger.LogInformation("Received streaming AI request with prompt: {Prompt}", prompt);
// 更規范地設置 SSE 響應頭
Response.ContentType = "text/event-stream";
Response.Headers["Cache-Control"] = "no-cache";
Response.Headers["Connection"] = "keep-alive";
await foreach (var chunk in _agentFrameworkService.GetAIResponseStreaming(prompt))
{
if (string.IsNullOrEmpty(chunk)) continue;
// 規范化換行,并將多行內容按多條 data: 發送,確保前端正確還原 Markdown 的行語義
var normalized = chunk.Replace("\r\n", "\n").Replace("\r", "\n");
var lines = normalized.Split('\n');
foreach (var line in lines)
{
await Response.WriteAsync($"data: {line}\n");
}
// 空行表示一個 SSE 事件結束(等價于原先的 "\n\n")
await Response.WriteAsync("\n");
await Response.Body.FlushAsync();
}
await Response.WriteAsync("data: [DONE]\n\n");
await Response.Body.FlushAsync();
return new EmptyResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred while getting streaming AI response");
return StatusCode(500, "An error occurred while processing your request.");
}
}
在調用的這個服務中返回IAsyncEnumerable<string>內容即可:
public async IAsyncEnumerable<string> GetAIResponseStreaming(string prompt)
{
ApiKeyCredential apiKeyCredential = new ApiKeyCredential(apiKey);
OpenAIClientOptions openAIClientOptions = new OpenAIClientOptions();
openAIClientOptions.Endpoint = new Uri(endpoint);
AIAgent agent = new OpenAIClient(apiKeyCredential, openAIClientOptions)
.GetChatClient(model)
.CreateAIAgent("你是一個有用的助手。");
await foreach (var update in agent.RunStreamingAsync(prompt))
{
var chunk = update?.ToString() ?? string.Empty;
// 確保每個分片以換行結束,避免 Markdown 結構(如 #、```csharp)被分片打斷后丟失必要的換行
if (!chunk.EndsWith("\n"))
{
chunk += "\n";
}
yield return chunk;
}
}
寫好這個接口之后,可以使用自帶的Swagger或者API Fox等工具測試一下流式傳輸效果:

沒問題之后,后端就先不管了,去看看React中是如何處理的。
在React中使用fetch來發起這個流式請求:

總結
通過構建一個簡單的聊天助手目前我們學習了:
1、React項目的簡單架構types、services與hooks。
2、學習了兩個React Hook,分別是useState與useCallback。
3、學習了在React中渲染md格式內容可以使用react-markdown。
4、學習了使用SSE實現流式響應。

浙公網安備 33010602011771號