Resume AI 项目学习文档
Resume AI 项目学习文档
这份文档面向前端入门读者:你会一点 JavaScript 和 React,但可能还不熟 Node.js、SSE 流式传输、大模型 API 和 Prompt 工程。
读完后,你应该能回答三个问题:
- 这个项目从输入简历到展示分析结果,中间发生了什么。
- 为什么 SSE 流式输出是这个项目最核心的技术点。
- 如果要继续扩展这个项目,应该从哪些模块入手。
1. 项目解决什么问题
Resume AI 做的是一个“简历和岗位 JD 匹配分析”工具。
用户在页面输入两段文本:
- 简历内容
- 职位描述,也就是 JD
前端把这两段文本发送给后端,后端再调用 DeepSeek 模型,让模型输出结构化 JSON:
{
"matchScore": 85,
"strengths": ["优势"],
"gaps": ["差距"],
"suggestions": [
{
"original": "原文",
"improved": "优化后",
"reason": "原因"
}
],
"keywords": ["关键词"]
}
前端拿到这些字段后,就可以把匹配度渲染成进度条,把优势、差距、关键词、改写建议渲染成更清晰的 UI。
这个项目的核心不是“调用一次 AI 接口”这么简单,而是把一次 AI 分析做成一个完整 Web 应用:
- 前端负责输入、展示、历史记录。
- 后端负责隐藏 API Key、组织 Prompt、调用 DeepSeek。
- SSE 负责让模型边生成、页面边展示,避免用户长时间看到空白。
2. 整体数据流
先看完整链路:
用户输入简历和 JD
|
v
React 表单提交
|
v
fetch('/api/analyze', POST)
|
v
Vite dev proxy 转发到 http://localhost:3001
|
v
Express 路由 POST /api/analyze
|
v
buildPrompt(resume, jobDescription)
|
v
DeepSeek Chat Completions stream
|
v
后端 res.write('data: ...\n\n')
|
v
前端 ReadableStream 持续读取
|
v
setOutput(prev => prev + text)
|
v
JSON 完整后解析并渲染结果卡片
这里有两个地址容易混淆:
http://localhost:3001是后端 API 服务。http://localhost:5173通常是前端 Vite 页面服务。
所以浏览器访问 http://localhost:3001 出现 Cannot GET / 是正常的。后端没有做首页,它只提供 API。检查后端是否启动成功应该访问:
http://localhost:3001/health
前端页面要打开 Vite 终端输出的 Local: 地址,比如:
http://localhost:5173/
3. 项目目录拆解
当前项目主要结构如下:
resume-ai/
├── backend/
│ ├── index.js
│ ├── routes/
│ │ └── analyze.js
│ ├── prompts/
│ │ └── templates.js
│ ├── .env.example
│ └── package.json
├── frontend/
│ ├── index.html
│ ├── vite.config.js
│ ├── src/
│ │ ├── App.jsx
│ │ ├── components/
│ │ │ ├── ResumeInput.jsx
│ │ │ ├── StreamOutput.jsx
│ │ │ └── HistoryPanel.jsx
│ │ ├── hooks/
│ │ │ └── useSSE.js
│ │ └── styles.css
│ └── package.json
└── docs/
└── learning-guide.md
每个目录的职责:
backend:Node.js + Express 后端,负责 API、DeepSeek 调用、SSE 输出。backend/routes/analyze.js:最核心后端路由。backend/prompts/templates.js:Prompt 模板,决定模型输出什么结构。frontend:React + Vite 前端。frontend/src/hooks/useSSE.js:最核心前端 Hook,负责读取流式响应。frontend/src/components:页面组件。frontend/src/App.jsx:把输入、输出、历史记录串起来。
初学者要先建立一个概念:前端不能直接调用 DeepSeek。API Key 属于敏感信息,必须放在后端环境变量里。
4. 本地如何运行
4.1 安装依赖
后端:
cd E:\AIGC\codex\ForJob\resume-ai\backend
npm install
前端:
cd E:\AIGC\codex\ForJob\resume-ai\frontend
npm install
4.2 配置 DeepSeek API Key
进入后端目录:
cd E:\AIGC\codex\ForJob\resume-ai\backend
Copy-Item .env.example .env
notepad .env
.env 里应该类似这样:
DEEPSEEK_API_KEY=你的真实_deepseek_api_key
PORT=3001
注意:
DEEPSEEK_API_KEY=你的_deepseek_key只是占位符,不能直接用。.env不应该提交到 Git,因为里面有真实密钥。.env.example可以提交,因为它只提供变量名模板。
4.3 启动后端
cd E:\AIGC\codex\ForJob\resume-ai\backend
npm run dev
看到这行就说明后端启动成功:
Resume AI backend listening on http://localhost:3001
验证后端健康状态:
http://localhost:3001/health
返回:
{"ok":true}
4.4 启动前端
另开一个 PowerShell:
cd E:\AIGC\codex\ForJob\resume-ai\frontend
npm run dev
Vite 会输出类似:
Local: http://localhost:5173/
打开这个地址才是页面。
如果 localhost:5173 打不开,可以强制指定本机地址和端口:
npm run dev -- --host 127.0.0.1 --port 5174
然后访问:
http://127.0.0.1:5174/
5. 后端入口:Express 服务
文件:backend/index.js
这个文件负责启动后端服务:
import 'dotenv/config';
import cors from 'cors';
import express from 'express';
import analyzeRouter from './routes/analyze.js';
const app = express();
const port = process.env.PORT || 3001;
app.use(cors());
app.use(express.json({ limit: '1mb' }));
app.get('/health', (_req, res) => {
res.json({ ok: true });
});
app.use('/api/analyze', analyzeRouter);
app.listen(port, () => {
console.log(`Resume AI backend listening on http://localhost:${port}`);
});
逐段解释:
import 'dotenv/config':自动读取.env,让process.env.DEEPSEEK_API_KEY可用。cors():允许前端跨端口请求后端。express.json({ limit: '1mb' }):让后端能读取 JSON 请求体。/health:健康检查接口,只用来确认后端是否活着。/api/analyze:挂载简历分析路由。
初学者容易误解的点:
localhost:3001不是前端页面,它只是 API 服务。Cannot GET /不等于后端失败,只是没有写/路由。- 前端开发时访问的是 Vite 地址,不是 Express 地址。
6. DeepSeek API 接入
文件:backend/routes/analyze.js
这个项目仍然使用 openai npm 包:
import OpenAI from 'openai';
原因是 DeepSeek 提供 OpenAI-compatible API。也就是说,它的接口形状和 OpenAI Chat Completions 很接近,所以可以复用 OpenAI SDK,只需要改三个关键配置:
const DEEPSEEK_BASE_URL = 'https://api.deepseek.com';
const DEEPSEEK_MODEL = 'deepseek-v4-flash';
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: DEEPSEEK_BASE_URL,
});
三个配置分别是什么意思:
baseURL:请求发到哪里。这里不是 OpenAI,而是 DeepSeek。apiKey:你的 DeepSeek 密钥,从.env读取。model:使用哪个 DeepSeek 模型。当前项目使用deepseek-v4-flash。
真正发起模型请求的是这段:
const stream = await openai.chat.completions.create({
model: DEEPSEEK_MODEL,
stream: true,
response_format: { type: 'json_object' },
messages: buildPrompt(resume, jobDescription),
});
重点参数:
stream: true:开启流式输出。response_format: { type: 'json_object' }:要求模型输出 JSON 对象。messages:Prompt 消息数组,由buildPrompt生成。
为什么 API Key 放后端:
- 前端代码会被浏览器下载,任何人都能看到。
- 如果把 Key 写在前端,别人可以拿你的 Key 调用模型,消耗你的额度。
- 后端相当于“安全代理”,前端只请求自己的后端,真正的 DeepSeek Key 永远不暴露给浏览器。
7. Prompt 工程与 JSON 输出
文件:backend/prompts/templates.js
Prompt 是这个项目的“大脑”。它不只是让 AI 随便写一段分析,而是约束模型输出固定结构。
核心代码:
export function buildPrompt(resume, jd) {
return [
{
role: 'system',
content: `你是一位资深 HR 和简历顾问。请严格输出合法 json,不要输出 Markdown、解释文字或代码块。
输出必须符合以下 JSON 结构:
{
"matchScore": 85,
"strengths": ["简历优势"],
"gaps": ["与 JD 的差距"],
"suggestions": [
{
"original": "简历原文",
"improved": "改写后的表达",
"reason": "改写原因"
}
],
"keywords": ["JD 中未充分体现的关键词"]
}`
},
{
role: 'user',
content: `职位描述:\n${jd}\n\n我的简历:\n${resume}`,
},
];
}
为什么分成 system 和 user:
system:告诉模型它应该扮演什么角色、输出什么格式。user:放用户真正输入的简历和 JD。
为什么要 JSON:
- 纯文本不稳定,前端很难解析。
- JSON 有字段名,前端可以分别渲染匹配度、优势、差距、建议。
- 后续如果要做图表、历史对比、导出报告,结构化数据更容易处理。
初学者容易误解的点:
- Prompt 不是越长越好,关键是结构清晰。
- 即使写了“严格输出 JSON”,模型仍可能出错,所以前端解析时要做好失败兜底。
response_format可以增强 JSON 约束,但不能替代后端和前端的错误处理。
8. SSE 流式传输原理
SSE 是 Server-Sent Events 的缩写。它的特点是:服务器可以持续向浏览器推送文本事件。
这个项目里,SSE 的作用是让 DeepSeek 生成一点,后端就转发一点,前端就展示一点。
8.1 为什么不用普通 fetch 等完整返回
普通请求的体验是:
用户点击分析
页面 loading
后端等待 DeepSeek 完整生成
一次性返回完整结果
页面展示
如果模型要生成 10 秒,用户会看到 10 秒空白。
流式请求的体验是:
用户点击分析
后端立即建立连接
模型生成第一段内容
后端立刻写给前端
前端马上展示
模型继续生成
页面持续更新
它不一定让总耗时变短,但会显著降低“用户感受到的等待时间”。
8.2 后端如何开启 SSE
文件:backend/routes/analyze.js
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
逐行解释:
Content-Type: text/event-stream:告诉浏览器这是 SSE 数据。Cache-Control: no-cache, no-transform:不要缓存,也不要让中间层改写流。Connection: keep-alive:保持连接不断开。flushHeaders():立刻把响应头发出去,让连接尽快建立。
8.3 SSE 数据格式
后端写数据时使用:
function writeSse(res, payload) {
res.write(`data: ${typeof payload === 'string' ? payload : JSON.stringify(payload)}\n\n`);
}
SSE 的基本格式是:
data: 一段数据
data: 下一段数据
注意最后有两个换行 \n\n,它表示一个事件结束。
项目中发送普通文本:
writeSse(res, { text });
实际发给前端类似:
data: {"text":"这里是一小段模型输出"}
模型结束时发送:
writeSse(res, '[DONE]');
实际是:
data: [DONE]
[DONE] 是前后端约定的结束标记。前端看到它,就知道可以停止 loading。
8.4 后端如何转发 DeepSeek 流
DeepSeek 返回的是一个异步流,所以后端用:
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content || '';
if (text) {
writeSse(res, { text });
}
}
这段代码可以理解为:
- DeepSeek 每生成一小块内容,就得到一个
chunk。 - 从
chunk里取出新增文本。 - 如果有文本,就通过 SSE 写给浏览器。
初学者容易误解的点:
for await...of不是普通数组循环,它是在消费异步数据流。delta.content是“本次新增内容”,不是完整答案。- 前端要自己把每次新增内容拼起来。
8.5 错误也要通过 SSE 返回
当前项目的错误处理:
} catch (error) {
writeSse(res, { error: getErrorMessage(error) });
writeSse(res, '[DONE]');
} finally {
res.end();
}
这样做的好处是:
- 即使 DeepSeek 报错,前端也能收到错误信息。
- 发送
[DONE]后,前端能结束 loading。 res.end()保证 HTTP 连接关闭。
如果不这样处理,前端可能一直停留在“分析中...”。
9. 前端 useSSE Hook
文件:frontend/src/hooks/useSSE.js
这个 Hook 是前端最核心的部分。它负责:
- 发起 POST 请求。
- 读取后端 SSE 流。
- 把碎片文本拼成完整输出。
- 处理
[DONE]。 - 处理错误信息。
9.1 状态设计
const [output, setOutput] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const abortControllerRef = useRef(null);
四个状态分别是:
output:模型已经输出的文本。error:请求或模型错误。loading:是否正在分析。abortControllerRef:保存上一次请求的取消控制器。
为什么要 AbortController:
abortControllerRef.current?.abort();
如果用户连续点击分析,旧请求应该被取消,否则多个流同时返回,页面内容会混在一起。
9.2 为什么用 fetch 而不是 EventSource
浏览器原生 EventSource 更像是专门为 SSE 设计的工具,但它主要适合 GET 请求。
这个项目需要传简历和 JD,两段文本可能很长,所以更适合 POST:
const response = await fetch('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resume, jobDescription }),
signal: abortController.signal,
});
所以项目用 fetch + ReadableStream 自己读取 SSE。
9.3 读取流式响应
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
含义:
getReader():拿到流读取器。TextDecoder():把二进制数据转成字符串。buffer:缓存半截数据。
为什么需要 buffer?
网络传输不保证一次读到一个完整 SSE 事件。比如后端发送:
data: {"text":"hello"}
前端可能第一次只读到:
data: {"tex
第二次才读到:
t":"hello"}
所以不能简单地对每次 value 直接解析,必须先拼到 buffer,再按 \n\n 分割完整事件。
9.4 分帧逻辑
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
这段代码做了三件事:
- 把新读到的数据追加到
buffer。 - 用
\n\n切出完整 SSE 事件。 - 最后一段可能是不完整的,放回
buffer等下一次继续拼。
接着解析每个事件:
const data = parseSseBlock(block);
if (data === '[DONE]') {
setLoading(false);
return;
}
const payload = JSON.parse(data);
if (payload.error) {
setError(payload.error);
setLoading(false);
return;
}
if (payload.text) {
setOutput((prev) => prev + payload.text);
}
逻辑顺序:
- 空数据跳过。
[DONE]表示结束。{ error }表示后端或 DeepSeek 出错。{ text }表示模型新增文本,把它追加到output。
初学者容易误解的点:
response.body不是完整字符串,而是一个可读流。TextDecoder不是解析 JSON 的,它只负责把字节转成文本。JSON.parse只能解析完整 JSON,所以必须先正确分帧。setOutput(prev => prev + payload.text)要用函数写法,避免拿到旧状态。
10. 前端组件如何串起来
文件:frontend/src/App.jsx
App.jsx 是页面总控,它把三个主要部分组合起来:
<ResumeInput loading={loading} onAnalyze={analyze} />
<StreamOutput error={error} loading={loading} output={output} parsedOutput={parsedOutput} />
<HistoryPanel history={history} />
三个组件职责:
ResumeInput:输入简历和 JD,点击按钮触发分析。StreamOutput:展示流式原文、错误、解析后的结构化结果。HistoryPanel:展示历史分析结果。
10.1 解析 JSON
模型输出是逐段拼起来的,刚开始可能不是完整 JSON。比如:
{"matchScore":
这时 JSON.parse 会失败。所以 App.jsx 用 try...catch:
const parsedOutput = useMemo(() => {
try {
return output ? JSON.parse(output) : null;
} catch {
return null;
}
}, [output]);
含义:
- 如果
output暂时不是合法 JSON,就返回null。 - 如果完整 JSON 已经生成完,就返回对象。
StreamOutput根据parsedOutput决定展示结构化 UI 还是原始流式文本。
这是 AI 流式 JSON 输出里很常见的处理方式。
10.2 历史记录
项目用 localStorage 保存最近几次分析结果:
localStorage.setItem(HISTORY_KEY, JSON.stringify(next));
优点:
- 简单,不需要数据库。
- 刷新页面后历史还在。
- 适合学习项目和个人 Demo。
局限:
- 只能保存在当前浏览器。
- 换电脑、清缓存后会丢失。
- 不适合多用户生产系统。
如果以后要做成正式产品,可以改成后端数据库存储。
11. Vite 代理是怎么工作的
文件:frontend/vite.config.js
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3001',
},
},
});
前端代码里请求的是:
fetch('/api/analyze', ...)
浏览器当前页面地址是:
http://localhost:5173
所以请求会先发给:
http://localhost:5173/api/analyze
Vite 看到 /api 开头,就代理给:
http://localhost:3001/api/analyze
这样前端代码不用写死后端完整地址。
初学者容易误解的点:
- 代理只在 Vite 开发服务器里生效。
- 如果前端没有启动,请求当然不会代理。
- 如果后端没启动,前端页面能打开,但点击分析会请求失败。
12. 常见报错排查
12.1 浏览器打开 localhost:3001 显示 Cannot GET /
这是正常的。
原因:后端没有写 / 首页路由。
正确检查方式:
http://localhost:3001/health
正确打开页面的方式:访问前端 Vite 地址,比如:
http://localhost:5173/
12.2 localhost:5173 连接失败
说明前端 Vite 服务没有成功启动,或者端口不是 5173。
解决:
cd E:\AIGC\codex\ForJob\resume-ai\frontend
npm run dev
看终端输出的 Local: 地址,浏览器打开那个地址。
如果仍然失败:
npm run dev -- --host 127.0.0.1 --port 5174
然后访问:
http://127.0.0.1:5174/
12.3 提示缺少 DEEPSEEK_API_KEY
后端会返回类似:
Missing DEEPSEEK_API_KEY. Add it to backend/.env before analyzing resumes.
原因:后端没有读取到 DeepSeek Key。
检查:
cd E:\AIGC\codex\ForJob\resume-ai\backend
notepad .env
确认里面有:
DEEPSEEK_API_KEY=真实key
PORT=3001
修改 .env 后,需要重启后端。
12.4 API Key 还是占位符
如果 .env 里是:
DEEPSEEK_API_KEY=你的_deepseek_key
这不能用。必须换成真实 DeepSeek API Key。
12.5 点击分析后一直 loading
可能原因:
- 后端没有启动。
- DeepSeek API Key 错误。
- 网络访问 DeepSeek 失败。
- 模型请求报错。
当前项目已经做了 SSE 错误事件处理。如果后端捕获到错误,会发送:
data: {"error":"错误信息"}
data: [DONE]
前端收到后会显示错误并停止 loading。
13. 代码用了哪些语法
这一章专门解释项目里出现的语法。你不需要一次性背完,先知道每种语法解决什么问题,再回到代码里对照看。
13.1 ES Module:import 和 export
项目里的代码:
import express from 'express';
import OpenAI from 'openai';
import { buildPrompt } from '../prompts/templates.js';
export async function analyzeResume(req, res) {
// ...
}
export default router;
这是 ES Module 模块语法,用来把代码拆成多个文件。
import express from 'express':导入第三方包的默认导出。import { buildPrompt } from ...:导入某个具名导出。export function ...:把函数导出去,其他文件可以按名字导入。export default ...:导出默认值,其他文件可以自己决定导入后的名字。
为什么项目能用这种语法?因为 backend/package.json 和 frontend/package.json 里都有:
{
"type": "module"
}
初学者容易误解的点:
import/export和老式require/module.exports不要混着用。- 本地文件导入时要写扩展名,比如
./routes/analyze.js。 default export一个文件通常只有一个;named export可以有多个。
13.2 Express 路由和中间件
项目里的代码:
app.use(cors());
app.use(express.json({ limit: '1mb' }));
app.use('/api/analyze', analyzeRouter);
以及:
router.post('/', analyzeResume);
这是 Express 的中间件和路由语法。
app.use(cors()):注册跨域中间件。app.use(express.json(...)):让 Express 能解析 JSON 请求体。app.use('/api/analyze', analyzeRouter):把一组路由挂到/api/analyze下。router.post('/', analyzeResume):处理 POST 请求。
最终路径是这样拼出来的:
app.use('/api/analyze', analyzeRouter)
+
router.post('/', analyzeResume)
=
POST /api/analyze
初学者容易误解的点:
router.post('/')不是网站根路径/,它是相对于/api/analyze的子路径。app.use()不只是挂路由,也可以挂中间件。- 前端请求的是
/api/analyze,不是/api/analyze/某个文件名。
13.3 环境变量:process.env
项目里的代码:
const port = process.env.PORT || 3001;
以及:
apiKey: process.env.DEEPSEEK_API_KEY,
process.env 是 Node.js 读取环境变量的方式。项目通过:
import 'dotenv/config';
自动读取 backend/.env 文件。
为什么 API Key 要放环境变量:
- API Key 是敏感信息,不能写死在代码里。
- 前端代码会暴露给浏览器,所以不能把 Key 放前端。
.env可以本地保存真实 Key,.env.example只保存变量名模板。
初学者容易误解的点:
- 改了
.env后,需要重启后端服务。 DEEPSEEK_API_KEY=你的_deepseek_key是占位符,不是真实 Key。.env不应该提交到 Git。
13.4 解构赋值、空值合并、可选链
项目里的代码:
const { resume, jobDescription } = req.body ?? {};
这里同时用了两个语法:
- 解构赋值:从对象里直接取字段。
- 空值合并
??:左边是null或undefined时,才使用右边。
这行代码等价于更啰嗦的写法:
const body = req.body === null || req.body === undefined ? {} : req.body;
const resume = body.resume;
const jobDescription = body.jobDescription;
项目里还有可选链:
if (!resume?.trim() || !jobDescription?.trim()) {
throw new Error('Both resume and jobDescription are required.');
}
以及:
const text = chunk.choices[0]?.delta?.content || '';
?. 的意思是:如果前面的值不存在,就停止继续取属性,并返回 undefined,不会直接报错。
为什么这里要这样写:
- 用户可能没有传
resume。 - DeepSeek 的某个
chunk里可能没有delta.content。 - 用可选链可以让代码更稳,不会因为某个字段缺失直接崩掉。
初学者容易误解的点:
??只处理null和undefined,不会把空字符串''当成默认值。||会把空字符串、0、false都当成需要替换的值。?.只能防止取属性时报错,不能保证业务数据一定正确。
13.5 async/await 和 for await...of
项目里的代码:
export async function analyzeResume(req, res) {
const stream = await openai.chat.completions.create({
model: DEEPSEEK_MODEL,
stream: true,
response_format: { type: 'json_object' },
messages: buildPrompt(resume, jobDescription),
});
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content || '';
}
}
async/await 是处理异步任务的语法:
async function表示这个函数里可以使用await。await表示等待一个异步操作完成。
for await...of 是更特殊的语法,用来消费异步流。
普通数组可以这样遍历:
for (const item of list) {
// ...
}
DeepSeek 返回的是“边生成边到达”的异步流,所以要这样遍历:
for await (const chunk of stream) {
// 每次拿到一小段模型输出
}
初学者容易误解的点:
await不是让程序卡死,而是让当前异步函数等待结果。for await...of不是遍历一次性数组,而是持续读取流。chunk不是完整结果,只是本次新增的一小段。
13.6 try/catch/finally 错误处理
项目里的代码:
try {
// 校验输入、调用 DeepSeek、写 SSE
} catch (error) {
writeSse(res, { error: getErrorMessage(error) });
writeSse(res, '[DONE]');
} finally {
res.end();
}
含义:
try:放可能出错的代码。catch:出错后执行,用来返回错误信息。finally:不管成功还是失败,最后都会执行。
为什么这里必须写 finally:
- SSE 是长连接,必须明确结束。
- 如果出错后不
res.end(),浏览器可能一直等。 - 即使 DeepSeek 报错,也要通知前端
[DONE]。
初学者容易误解的点:
- SSE 开始写响应头后,不能再像普通接口那样改成
res.status(500).json(...)。 - 所以错误也要通过
data: {"error":"..."}这种 SSE 格式发给前端。
13.7 SSE 响应语法
项目里的代码:
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
这些是 HTTP 响应头:
text/event-stream:告诉浏览器这是 SSE。no-cache, no-transform:不要缓存,也不要改写响应流。keep-alive:保持连接。flushHeaders():立刻发送响应头,尽快建立连接。
发送数据的代码:
res.write(`data: ${typeof payload === 'string' ? payload : JSON.stringify(payload)}\n\n`);
这里用了模板字符串:
`data: ${...}\n\n`
${...} 可以把变量或表达式插入字符串。
SSE 单条事件的格式是:
data: 内容
最后两个换行 \n\n 很重要,它表示一条 SSE 事件结束。
初学者容易误解的点:
res.write()是持续写数据,不是一次性返回完整响应。[DONE]不是 SSE 标准内置语法,是这个项目自己约定的结束标记。{ error }也是项目自定义格式,用来让前端停止 loading 并展示错误。
13.8 React 函数组件和 Props
项目里的代码:
function App() {
return (
<main className="app-shell">
<ResumeInput loading={loading} onAnalyze={analyze} />
<StreamOutput error={error} loading={loading} output={output} parsedOutput={parsedOutput} />
</main>
);
}
以及:
export default function ResumeInput({ loading, onAnalyze }) {
// ...
}
这是 React 函数组件。
App是父组件。ResumeInput、StreamOutput是子组件。loading={loading}、onAnalyze={analyze}是 Props。
Props 可以理解成父组件传给子组件的参数。
这段:
function ResumeInput({ loading, onAnalyze }) {
用了参数解构,相当于:
function ResumeInput(props) {
const loading = props.loading;
const onAnalyze = props.onAnalyze;
}
初学者容易误解的点:
- Props 是从父组件传下来的,子组件不应该直接修改 Props。
- 子组件要通知父组件做事,通常通过函数 Props,比如
onAnalyze。
13.9 useState:组件状态
项目里的代码:
const [resume, setResume] = useState(SAMPLE_RESUME);
const [jobDescription, setJobDescription] = useState(SAMPLE_JD);
以及:
const [output, setOutput] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
useState 用来保存组件状态。
写法固定是:
const [状态值, 修改状态的函数] = useState(初始值);
比如:
setLoading(true);
会让 loading 变成 true,React 会重新渲染页面。
这里还有一个重要写法:
setOutput((prev) => prev + payload.text);
这是函数式更新。它的意思是:基于上一次状态 prev 计算下一次状态。
为什么这里要用函数式更新:
- 流式响应会连续触发很多次。
- 每次都要在旧文本后追加新文本。
- 用
prev => prev + payload.text可以避免拿到过期的output。
初学者容易误解的点:
setState不是立刻同步修改变量,而是通知 React 更新。- 依赖旧状态计算新状态时,优先用函数式更新。
13.10 useRef:保存可变对象
项目里的代码:
const abortControllerRef = useRef(null);
后面使用:
abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
useRef 可以保存一个跨渲染周期都不丢失的对象。
这里它保存的是当前请求的 AbortController。当用户再次点击分析时,先取消上一次请求,再创建新请求。
为什么不用 useState:
AbortController不需要展示在页面上。- 修改它不需要触发重新渲染。
useRef更适合保存这种“可变但不影响 UI”的值。
初学者容易误解的点:
useRef的值放在.current上。- 改
.current不会自动触发页面重新渲染。
13.11 useMemo:缓存计算结果
项目里的代码:
const parsedOutput = useMemo(() => {
try {
return output ? JSON.parse(output) : null;
} catch {
return null;
}
}, [output]);
useMemo 的作用是:只有依赖变化时,才重新计算结果。
这里的依赖是:
[output]
也就是说,只有模型输出文本变化时,才尝试重新 JSON.parse(output)。
为什么这里要 try/catch:
- 流式输出刚开始不是完整 JSON。
- 不完整 JSON 直接
JSON.parse会报错。 - 报错时返回
null,等后面 JSON 完整了再解析。
初学者容易误解的点:
useMemo不是必须的性能优化神器,只适合缓存有计算成本或需要稳定引用的结果。- 这里更重要的是“解析失败时兜底”,而不是性能。
13.12 useEffect:副作用
项目里的代码:
useEffect(() => {
if (loading || error || !parsedOutput) {
return;
}
setHistory((items) => {
const next = [
{
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
result: parsedOutput,
},
...items,
].slice(0, 8);
localStorage.setItem(HISTORY_KEY, JSON.stringify(next));
return next;
});
}, [error, loading, parsedOutput]);
useEffect 用来处理副作用。副作用指的是“渲染 UI 之外的事情”,比如:
- 写入
localStorage - 发请求
- 订阅事件
- 操作浏览器 API
这里的逻辑是:
- 如果还在 loading,不保存。
- 如果有 error,不保存。
- 如果 JSON 还没解析成功,不保存。
- 只有分析完成且 JSON 有效时,才写入历史记录。
初学者容易误解的点:
useEffect的依赖数组很重要。[error, loading, parsedOutput]表示这些值变化时才重新执行。- 不要在每次渲染时都写
localStorage,否则容易重复写入。
13.13 JSX 条件渲染
项目里的代码:
{loading && <span className="status-dot">Streaming</span>}
这是 && 条件渲染。只有 loading 为真时,右边的元素才显示。
还有三元表达式:
{parsedOutput ? (
<div className="result-grid">...</div>
) : (
<pre className="stream-box">{output || '等待分析结果...'}</pre>
)}
意思是:
- 如果
parsedOutput存在,展示结构化结果。 - 否则展示原始流式文本或等待提示。
初学者容易误解的点:
- JSX 里的
{}表示进入 JavaScript 表达式。 - JSX 里不能直接写普通
if语句,但可以用&&或三元表达式。
13.14 受控表单
项目里的代码:
<textarea
value={resume}
onChange={(event) => setResume(event.target.value)}
placeholder="粘贴你的简历内容"
/>
这是 React 受控表单。
value={resume}:输入框显示的值来自 React 状态。onChange={...}:用户输入时,把新值写回 React 状态。
这形成一个闭环:
用户输入 -> onChange -> setResume -> resume 更新 -> textarea 显示新值
初学者容易误解的点:
- 只写
value不写onChange,输入框会变成只读。 - React 推荐重要表单用受控组件,方便校验、提交和清空。
13.15 浏览器 fetch、ReadableStream 和 TextDecoder
项目里的代码:
const response = await fetch('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resume, jobDescription }),
signal: abortController.signal,
});
这是浏览器的 fetch API,用 POST 请求后端。
method: 'POST':提交数据。headers:告诉后端请求体是 JSON。body: JSON.stringify(...):把对象转成 JSON 字符串。signal:绑定取消请求的控制器。
流式读取:
const reader = response.body.getReader();
const decoder = new TextDecoder();
response.body 是一个 ReadableStream。项目没有直接 await response.json(),因为那样要等完整响应结束。这里要边读边展示,所以用 getReader()。
TextDecoder 的作用是把网络返回的二进制数据转成字符串。
初学者容易误解的点:
fetch返回后,不代表所有内容都已经下载完。response.body.getReader()读到的是一块一块的字节数据。TextDecoder只负责转文本,不负责解析 SSE 或 JSON。
13.16 buffer 分帧
项目里的代码:
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
为什么要这样写?
因为网络分块不一定等于 SSE 分块。后端可能发送了一条完整 SSE:
data: {"text":"hello"}
但浏览器可能分两次收到:
data: {"tex
和:
t":"hello"}
所以前端必须:
- 先把新内容拼到
buffer。 - 用
\n\n切出完整事件。 - 把最后可能不完整的一段留到下次继续拼。
初学者容易误解的点:
- 不能每次
reader.read()后就直接JSON.parse。 - 必须先确认拿到的是完整 SSE 事件。
blocks.pop()取出来的最后一段,可能只是半截数据。
13.17 数组方法:map、filter、slice、join
项目里的代码:
return block
.split('\n')
.filter((line) => line.startsWith('data: '))
.map((line) => line.slice(6))
.join('\n');
这段代码在解析 SSE:
split('\n'):按行拆开。filter(...):只保留data:开头的行。map(...):把每一行前面的data:去掉。join('\n'):再拼回一个字符串。
渲染列表时也用了 map:
{items.map((item, index) => (
<li key={`${item}-${index}`}>{item}</li>
))}
React 中经常用 map 把数组变成一组 JSX 元素。
初学者容易误解的点:
map会返回新数组,不会修改原数组。- React 列表必须有
key,帮助 React 识别每一项。 slice(0, 8)是截取前 8 条,不会修改原数组。
13.18 JSON 和 localStorage
项目里的代码:
JSON.stringify({ resume, jobDescription })
以及:
JSON.parse(output)
JSON 是前后端传数据最常见的格式。
JSON.stringify:把 JavaScript 对象转成字符串。JSON.parse:把 JSON 字符串转回 JavaScript 对象。
保存历史记录:
localStorage.setItem(HISTORY_KEY, JSON.stringify(next));
读取历史记录:
JSON.parse(localStorage.getItem(HISTORY_KEY)) ?? [];
为什么要 JSON.stringify?
因为 localStorage 只能保存字符串,不能直接保存对象。
初学者容易误解的点:
JSON.parse遇到非法 JSON 会报错,所以项目用try/catch包住。localStorage是浏览器本地存储,不是数据库。- 换浏览器或清缓存后,历史记录可能会丢失。
13.19 箭头函数和回调函数
项目里经常出现这种写法:
app.listen(port, () => {
console.log(`Resume AI backend listening on http://localhost:${port}`);
});
以及:
onChange={(event) => setResume(event.target.value)}
() => {} 是箭头函数,常用于回调。
回调函数的意思是:现在先把函数交给别人,等某个时机到了再执行。
例子:
app.listen(..., callback):服务启动后执行 callback。onChange={callback}:输入框变化时执行 callback。setHistory((items) => ...):React 把旧状态传给 callback,然后拿返回值作为新状态。
初学者容易误解的点:
onChange={setResume(...)}是错误思路,因为会立刻执行。onChange={(event) => setResume(...)}才是把函数交给 React,等输入变化时再执行。
13.20 学习这些语法的顺序
建议按这个顺序学:
- 先学
import/export、函数、对象、数组方法。 - 再学 React 的
useState、Props、受控表单。 - 然后学
fetch、async/await、try/catch。 - 最后学 SSE、
ReadableStream、TextDecoder、for await...of。
不要一开始就死磕所有语法。更好的方式是沿着项目主线学:
输入框语法 -> 点击提交 -> fetch 请求 -> 后端路由 -> DeepSeek 异步流 -> SSE -> 前端流式读取 -> JSON 渲染
每次看到一个语法,都问三个问题:
- 它在这段代码里解决什么问题?
- 如果不用它,代码会变啰嗦还是会出错?
- 它和前后端数据流有什么关系?
能回答这三个问题,就说明你不是在背语法,而是在真正理解项目。
14. 这个项目的核心技术难点
14.1 难点一:流式链路不能断
完整流式链路是:
DeepSeek stream
-> Node for await...of
-> Express res.write
-> 浏览器 ReadableStream
-> TextDecoder
-> buffer 分帧
-> React setOutput
任何一环处理不好,都会退化成“等完整结果才显示”,甚至直接卡住。
14.2 难点二:JSON 和流式输出天然有矛盾
JSON 必须完整才可以解析,但流式输出一开始只是一部分。
所以项目采用两阶段展示:
- JSON 不完整时:展示原始流式文本。
- JSON 完整后:解析成对象,展示结构化 UI。
这就是 parsedOutput 可能为 null 的原因。
14.3 难点三:错误处理必须走同一条流
SSE 一旦开始写响应头,就不能再像普通接口那样返回一个新的 JSON 错误响应。
所以错误也要写成 SSE:
writeSse(res, { error: getErrorMessage(error) });
writeSse(res, '[DONE]');
前端也必须识别:
if (payload.error) {
setError(payload.error);
setLoading(false);
return;
}
这能避免用户界面一直处于等待状态。
15. 可以继续扩展什么
15.1 一键优化全文
现在项目输出的是逐条建议。下一步可以加一个按钮:“一键生成优化后简历”。
实现思路:
- 新增后端路由
/api/rewrite。 - Prompt 改成“根据建议重写完整简历”。
- 前端展示可复制的优化版简历。
15.2 导出分析报告
可以把分析结果导出成 Markdown、PDF 或 Word。
适合展示:
- 匹配度
- 优势
- 差距
- 改写建议
- 缺失关键词
15.3 历史记录对比
现在历史记录只存在 localStorage。
可以扩展成:
- 点击历史记录查看详情。
- 对比两次分析的匹配度变化。
- 保存用户修改后的简历版本。
15.4 更强的 JSON 解析
当前项目等完整 JSON 后再解析。
如果想做得更强,可以引入增量 JSON 解析或让模型按 NDJSON 输出。
但对入门项目来说,当前做法更简单、稳定、好理解。
16. 面试中怎么讲这个项目
可以这样概括:
我做了一个基于 React、Express 和 DeepSeek 的简历匹配分析工具。用户输入简历和 JD 后,后端通过 OpenAI-compatible SDK 调用 DeepSeek,并使用 SSE 将模型生成内容实时推送到前端。前端用 fetch 读取 ReadableStream,自行解析 SSE 分帧并逐段渲染。模型输出被 Prompt 约束为 JSON,前端解析后展示匹配度、优势、差距、关键词和改写建议,同时用 localStorage 保存历史结果。
可以写进简历的技术亮点:
- 基于 React + Vite + Express 实现简历匹配分析工具,支持简历与 JD 的结构化对比。
- 接入 DeepSeek OpenAI-compatible API,使用环境变量保护 API Key,避免前端泄露密钥。
- 使用 SSE 实现 AI 结果流式输出,后端通过
text/event-stream和res.write()持续推送内容。 - 前端基于
fetch + ReadableStream + TextDecoder消费流式响应,并处理 SSE 分帧、结束标记和错误事件。 - 通过 Prompt 工程约束模型输出 JSON,前端将匹配度、优势、差距、关键词和改写建议拆分渲染。
- 使用
localStorage保存历史分析结果,实现轻量级本地历史记录。
面试官可能追问:
-
为什么不用前端直接调 DeepSeek?
- 因为 API Key 会暴露,必须放在后端。
-
为什么不用普通 fetch?
- 普通 fetch 通常要等完整响应,SSE 可以边生成边展示。
-
为什么不用 EventSource?
- EventSource 更适合 GET,这里要 POST 简历和 JD,所以用 fetch 读取流。
-
为什么 JSON 流式输出需要特殊处理?
- JSON 不完整时不能解析,必须等完整输出后才能
JSON.parse。
- JSON 不完整时不能解析,必须等完整输出后才能
-
如果模型输出不是合法 JSON 怎么办?
- 当前前端用
try...catch兜底,生产环境可以加服务端校验、重试或修复 JSON 的步骤。
- 当前前端用
17. 学习顺序建议
如果你是第一次看这个项目,建议按这个顺序学:
- 先运行项目,确认后端
/health和前端页面都能打开。 - 看
frontend/src/components/ResumeInput.jsx,理解用户输入怎么提交。 - 看
frontend/src/hooks/useSSE.js,理解前端如何读取流。 - 看
backend/routes/analyze.js,理解后端如何调用 DeepSeek 并写 SSE。 - 看
backend/prompts/templates.js,理解模型为什么能输出固定字段。 - 看
frontend/src/App.jsx,理解 JSON 解析、结果渲染和历史记录。 - 最后尝试改一个功能,比如新增一个“复制优化建议”按钮。
学这个项目时,不要只背代码。重点是理解这条主线:
输入文本 -> 组织 Prompt -> 调用模型 -> 流式返回 -> 前端拼接 -> JSON 解析 -> UI 渲染
把这条线讲清楚,这个项目就真正掌握了。