本文带你从零实现一个可以真正用起来的 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 +
openainpm 包(兼容 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