从零搭建一个 AI 聊天应用:React + Node.js 全栈教程

1 阅读5分钟

本文带你从零实现一个可以真正用起来的 AI 聊天 Web 应用:React 前端实现打字机流式渲染,Node.js (Express) 后端代理大模型 API,支持多模型切换、对话历史持久化、Markdown 渲染和代码高亮。全程完整可运行代码。


项目结构

ai-chat/
├── server/                 # Node.js 后端
│   ├── index.js
│   └── package.json
├── client/                 # React 前端(Vite)
│   ├── src/
│   │   ├── App.jsx
│   │   ├── components/
│   │   │   ├── ChatWindow.jsx
│   │   │   ├── MessageItem.jsx
│   │   │   ├── InputBar.jsx
│   │   │   └── ModelSelector.jsx
│   │   └── hooks/
│   │       └── useChat.js
│   └── package.json
└── README.md

技术栈

  • 前端:React 18 + Vite + react-markdown + react-syntax-highlighter
  • 后端:Node.js + Express + openai npm 包(兼容 DeepSeek/通义 API)
  • 模型:DeepSeek-V3、Qwen-Max(可扩展)

后端实现

初始化项目

mkdir ai-chat && cd ai-chat
mkdir server && cd server
npm init -y
npm install express openai cors dotenv

server/index.js

import express from 'express';
import cors from 'cors';
import OpenAI from 'openai';
import 'dotenv/config';

const app = express();
app.use(cors());
app.use(express.json());

// 多模型配置:key 是前端传来的 modelId
const MODEL_CONFIGS = {
  'deepseek-chat': {
    client: new OpenAI({
      apiKey: process.env.DEEPSEEK_API_KEY,
      baseURL: 'https://api.deepseek.com/v1',
    }),
    model: 'deepseek-chat',
  },
  'deepseek-r1': {
    client: new OpenAI({
      apiKey: process.env.DEEPSEEK_API_KEY,
      baseURL: 'https://api.deepseek.com/v1',
    }),
    model: 'deepseek-reasoner',
  },
  'qwen-max': {
    client: new OpenAI({
      apiKey: process.env.DASHSCOPE_API_KEY,
      baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
    }),
    model: 'qwen-max',
  },
  'qwen-turbo': {
    client: new OpenAI({
      apiKey: process.env.DASHSCOPE_API_KEY,
      baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
    }),
    model: 'qwen-turbo',
  },
};

// 提示:如果用笔者开发的 TheRouter(therouter.ai)作为统一入口,
// 上面这段多客户端配置可以简化为单个 client——只需一个 API Key 和一个 baseURL,
// 模型切换只改 model 字符串即可,后端代码量减少一半以上。

// POST /api/chat — 返回 SSE 流式响应
app.post('/api/chat', async (req, res) => {
  const { messages, modelId = 'deepseek-chat' } = req.body;

  const config = MODEL_CONFIGS[modelId];
  if (!config) {
    return res.status(400).json({ error: `Unknown modelId: ${modelId}` });
  }

  // 设置 SSE headers
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();

  try {
    const stream = await config.client.chat.completions.create({
      model: config.model,
      messages,
      stream: true,
      max_tokens: 2048,
    });

    for await (const chunk of stream) {
      const delta = chunk.choices[0]?.delta?.content;
      if (delta) {
        // SSE 格式:data: <json>\n\n
        res.write(`data: ${JSON.stringify({ content: delta })}\n\n`);
      }
    }

    res.write('data: [DONE]\n\n');
  } catch (err) {
    console.error('LLM error:', err.message);
    res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
  } finally {
    res.end();
  }
});

app.listen(3001, () => console.log('Server running on http://localhost:3001'));

server/package.json 中添加 "type": "module" 以启用 ESM,并创建 .env

DEEPSEEK_API_KEY=sk-xxx
DASHSCOPE_API_KEY=sk-xxx

前端实现

cd .. && npm create vite@latest client -- --template react
cd client
npm install react-markdown react-syntax-highlighter remark-gfm

useChat.js — 核心状态与流式逻辑

import { useState, useCallback, useRef } from 'react';

const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:3001';

export function useChat() {
  const [messages, setMessages] = useState(() => {
    // localStorage 持久化:加载历史对话
    try {
      const saved = localStorage.getItem('chat_history');
      return saved ? JSON.parse(saved) : [];
    } catch {
      return [];
    }
  });
  const [streaming, setStreaming] = useState(false);
  const [modelId, setModelId] = useState('deepseek-chat');
  const abortRef = useRef(null);

  // 持久化到 localStorage
  const saveHistory = (msgs) => {
    try {
      // 只保存最近 50 条,防止过大
      localStorage.setItem('chat_history', JSON.stringify(msgs.slice(-50)));
    } catch {}
  };

  const sendMessage = useCallback(async (userText) => {
    if (!userText.trim() || streaming) return;

    const userMsg = { role: 'user', content: userText };
    const newMessages = [...messages, userMsg];
    setMessages(newMessages);

    // 添加一条空的 assistant 消息,稍后流式填充
    const assistantMsg = { role: 'assistant', content: '' };
    setMessages([...newMessages, assistantMsg]);
    setStreaming(true);

    const controller = new AbortController();
    abortRef.current = controller;

    try {
      const res = await fetch(`${API_BASE}/api/chat`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ messages: newMessages, modelId }),
        signal: controller.signal,
      });

      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let accumulated = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const text = decoder.decode(value, { stream: true });
        // 解析 SSE 行
        for (const line of text.split('\n')) {
          if (!line.startsWith('data: ')) continue;
          const payload = line.slice(6);
          if (payload === '[DONE]') break;

          try {
            const { content, error } = JSON.parse(payload);
            if (error) throw new Error(error);
            if (content) {
              accumulated += content;
              // 实时更新最后一条 assistant 消息
              setMessages((prev) => {
                const updated = [...prev];
                updated[updated.length - 1] = {
                  role: 'assistant',
                  content: accumulated,
                };
                return updated;
              });
            }
          } catch {}
        }
      }

      // 流结束后持久化
      const finalMessages = [...newMessages, { role: 'assistant', content: accumulated }];
      saveHistory(finalMessages);
    } catch (err) {
      if (err.name !== 'AbortError') {
        setMessages((prev) => {
          const updated = [...prev];
          updated[updated.length - 1] = {
            role: 'assistant',
            content: `请求失败:${err.message}`,
          };
          return updated;
        });
      }
    } finally {
      setStreaming(false);
    }
  }, [messages, modelId, streaming]);

  const stopStreaming = () => abortRef.current?.abort();

  const clearHistory = () => {
    setMessages([]);
    localStorage.removeItem('chat_history');
  };

  return { messages, streaming, modelId, setModelId, sendMessage, stopStreaming, clearHistory };
}

ModelSelector.jsx

const MODELS = [
  { id: 'deepseek-chat',  label: 'DeepSeek-V3' },
  { id: 'deepseek-r1',   label: 'DeepSeek-R1 (深度推理)' },
  { id: 'qwen-max',      label: 'Qwen-Max' },
  { id: 'qwen-turbo',    label: 'Qwen-Turbo (快速)' },
];

export function ModelSelector({ value, onChange }) {
  return (
    <select
      value={value}
      onChange={(e) => onChange(e.target.value)}
      className="model-selector"
    >
      {MODELS.map((m) => (
        <option key={m.id} value={m.id}>{m.label}</option>
      ))}
    </select>
  );
}

MessageItem.jsx — Markdown + 代码高亮

import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';

export function MessageItem({ role, content }) {
  const isUser = role === 'user';

  return (
    <div className={`message ${isUser ? 'message--user' : 'message--assistant'}`}>
      <div className="message__avatar">{isUser ? '你' : 'AI'}</div>
      <div className="message__body">
        {isUser ? (
          <p>{content}</p>
        ) : (
          <ReactMarkdown
            remarkPlugins={[remarkGfm]}
            components={{
              // 代码块带语法高亮
              code({ node, inline, className, children, ...props }) {
                const match = /language-(\w+)/.exec(className || '');
                return !inline && match ? (
                  <SyntaxHighlighter
                    style={oneDark}
                    language={match[1]}
                    PreTag="div"
                    {...props}
                  >
                    {String(children).replace(/\n$/, '')}
                  </SyntaxHighlighter>
                ) : (
                  <code className={className} {...props}>{children}</code>
                );
              },
            }}
          >
            {content || '▋'}  {/* 流式未完成时显示光标 */}
          </ReactMarkdown>
        )}
      </div>
    </div>
  );
}

InputBar.jsx

import { useState } from 'react';

export function InputBar({ onSend, streaming, onStop }) {
  const [text, setText] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!text.trim()) return;
    onSend(text);
    setText('');
  };

  // Shift+Enter 换行,Enter 发送
  const handleKeyDown = (e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSubmit(e);
    }
  };

  return (
    <form className="input-bar" onSubmit={handleSubmit}>
      <textarea
        value={text}
        onChange={(e) => setText(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder="输入消息,Enter 发送,Shift+Enter 换行..."
        rows={3}
        disabled={streaming}
      />
      {streaming ? (
        <button type="button" onClick={onStop} className="btn btn--stop">
          停止
        </button>
      ) : (
        <button type="submit" className="btn btn--send" disabled={!text.trim()}>
          发送
        </button>
      )}
    </form>
  );
}

App.jsx — 组装

import { useEffect, useRef } from 'react';
import { useChat } from './hooks/useChat';
import { ChatWindow } from './components/ChatWindow';
import { InputBar } from './components/InputBar';
import { ModelSelector } from './components/ModelSelector';
import './App.css';

function App() {
  const { messages, streaming, modelId, setModelId, sendMessage, stopStreaming, clearHistory } = useChat();
  const bottomRef = useRef(null);

  // 新消息自动滚到底部
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  return (
    <div className="app">
      <header className="app__header">
        <h1>AI 聊天</h1>
        <div className="app__controls">
          <ModelSelector value={modelId} onChange={setModelId} />
          <button onClick={clearHistory} className="btn btn--clear">清空</button>
        </div>
      </header>

      <main className="app__main">
        <ChatWindow messages={messages} />
        <div ref={bottomRef} />
      </main>

      <footer className="app__footer">
        <InputBar onSend={sendMessage} streaming={streaming} onStop={stopStreaming} />
      </footer>
    </div>
  );
}

export default App;

几个容易踩的坑

1. SSE 分包问题

网络层可能把多行 SSE 数据放在同一个 read() chunk 里,也可能把一行拆成多个 chunk 返回。正确做法是维护一个 buffer:

let buffer = '';
// 在 while 循环里:
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // 最后一行可能不完整,留到下次
for (const line of lines) {
  // 正常处理 data: ...
}

2. React StrictMode 双重渲染

开发环境下 useEffect 会执行两次,但 useCallback 包裹的函数只有依赖变化才重建,不影响流式逻辑。如果发现消息重复,检查是否有未清理的副作用。

3. 对话上下文长度限制

messages 数组会随对话增长,长对话可能超出模型上下文窗口。简单的截断策略:

// 发送前裁剪:保留 system prompt + 最近 N 轮
const MAX_TURNS = 10;
const contextMessages = [
  { role: 'system', content: '你是一个有帮助的 AI 助手。' },
  ...messages.slice(-(MAX_TURNS * 2)),  // 每轮 user+assistant 各1条
];

部署方案

前端:Vercel

cd client
npm run build
# 安装 Vercel CLI
npx vercel --prod

在 Vercel 项目设置里添加环境变量:

VITE_API_BASE=https://your-backend.com

后端:阿里云函数计算(推荐低成本方案)

函数计算支持 Node.js 18 运行时,按请求计费,适合低频使用。

# 安装 FC CLI
npm install -g @alicloud/fun
fun deploy

template.yml 关键配置:

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  ai-chat-service:
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: AI Chat Backend
    ai-chat-function:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: index.handler
        Runtime: nodejs18
        Timeout: 300  # 流式响应需要较长超时
        EnvironmentVariables:
          DEEPSEEK_API_KEY: !Ref DEEPSEEK_API_KEY

注意:函数计算的流式响应需要开启"HTTP 触发器"并配置 responseType: stream,具体参考阿里云文档。如果函数计算配置麻烦,也可以用阿里云 ECS + PM2 部署,几十元/月搞定。


功能扩展思路

功能实现方式
多会话管理localStorage 存多个会话 ID + 消息列表
文件上传前端读取文件内容拼入 prompt(纯文本)
流式打字机光标内容末尾追加 ,完成后移除
Token 用量显示后端从 stream 最后一个 chunk 读 usage 字段
深色模式CSS 变量 + prefers-color-scheme media query

小结

整个项目的核心链路:

用户输入
  → React state 更新 → fetch POST /api/chat
  → Express 调用 LLM SDK(stream: true)
  → SSE 逐块写回 → ReadableStream 逐块读取
  → setMessages 实时更新 → ReactMarkdown 渲染

关键点只有两个:后端用 SSE 转发流式响应前端用 ReadableStream 逐字渲染。其余的对话历史、Markdown 渲染、模型切换都是在这两个核心之上叠加的。

代码已精简到可以直接跑起来的程度,建议 fork 下来改造成自己的工具。


有问题欢迎评论区交流,觉得有帮助的话点个赞 👍


作者:TheRouter 开发者,专注 AI 模型路由网关。项目主页:therouter.ai