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用法示例:

TypeScript 复制代码
  const [inputText, setInputText] = useState('');

刚开始学习推荐先看下文档,地址:react.dev/reference/r...

useState 是 React 的一个 Hook,它允许你在组件中添加一个状态变量。

useCallback用法示例:

TypeScript 复制代码
  const clearMessages = useCallback(() => {
    setMessages(initialMessages);
  }, []);

文档地址:react.dev/reference/r...

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组件:

TypeScript 复制代码
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地址:github.com/remarkjs/re...

remark-gfm介绍

remark 插件支持 GFM(自动链接字面量、脚注、删除线、表格、任务列表)

GitHub地址:github.com/remarkjs/re...

remark-breaks介绍

remark 插件支持在不使用空格或转义字符的情况下实现硬换行(将回车转换为

标签)。

GitHub地址:github.com/remarkjs/re...

主要是第一个,很多时候用第一个就够了,第二个第三个都只是插件,增加一些功能。

看下在聊天页面中如何使用:

导入这个组件直接使用即可。

SSE

我们之前可能接触到的很多请求模式可能都是发送一个请求收到一个响应这样。

就比如非流式响应,你向AI提问一个问题,AI全部返回一下子返回给你。但是在聊天应用中这种模式,用户或许很难以忍受,因为这种等待的时间要长一点,而且是一下子显示的。

所以需要流式响应,而后端向前端流式响应,就需要用到SSE了。

Server-Sent Events(简称 SSE,服务器发送事件)是一种让服务器主动向客户端(通常是浏览器)推送数据的技术。它基于 HTTP 协议,允许服务器持续发送更新,而客户端只需建立一次连接,便可接收不断传来的消息。

与传统的"客户端请求 → 服务器响应"模式不同,SSE 实现了服务器到客户端的单向实时通信。

首先需要先有一个流式返回的接口,创建一个Web API项目,一个流式接口可以这样写:

csharp 复制代码
  [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>内容即可:

csharp 复制代码
 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实现流式响应。

相关推荐
Dm_dotnet6 小时前
React学习(一):使用react-router构建导航应用
react.js
xixixin_15 小时前
【React】为什么移除事件要写在useEffect的return里面?
前端·javascript·react.js
嘗_15 小时前
react 源码2
前端·javascript·react.js
mapbar_front1 天前
React中useContext的基本使用和原理解析
前端·react.js
嘉琪0011 天前
vue3+ts面试题(一)JSX,SFC
前端·javascript·react.js
AAA不会前端开发1 天前
前端React实战项目 新闻管理发布系统
react.js
刺客_Andy1 天前
React 第四十七节 Router 中useLinkClickHandler使用详解及开发注意事项案例
前端·javascript·react.js
秋枫961 天前
使用React Bootstrap搭建网页界面
前端·react.js·bootstrap
wendao1 天前
我开发了个极简的LLM提供商编辑器
前端·react.js·llm