从 DeepSeek 文本对话到流式输出
本文把「非流式调用 → 浏览器里解析流式 → 用 Node 做 BFF → 前端改用 EventSource」串成一条主线
你将学到什么
- 在浏览器里用
fetch调用 DeepSeek 的 Chat Completions(与 OpenAI 兼容)。 - 为什么要开
stream,以及流式响应在控制台里长什么样。 - 用 ReadableStream + TextDecoder + 行缓冲 解析
data:开头的 SSE 分片。 - 为什么 EventSource 很难直接对接「POST + Authorization」的大模型接口,以及如何用 零依赖
server.js做中转。 - 前端如何用 EventSource 消费自家 BFF 下发的 SSE,并顺带了解 SSE 的基本格式。
最后效果

懒得本地建立代码,也可以直接clone代码,index-direct.html和index-stream.html能直接拖到浏览器,看效果。index.html拖入浏览器之前,需要首先node server.js,然后也能看到效果。哦,前提去申请一个deepseek的key。
准备工作
- 打开 DeepSeek 开放平台,按需充值并创建 API Key,妥善保存(不要写进公开仓库)。
- 下文示例里,直连 DeepSeek 的页面会把 Key 放在浏览器侧(仅适合本地学习);走代理后,Key 只放在服务端
.env.local。
一、非流式:一次性拿到完整回复
复杂问题之前,先用「一问一答、整包返回」把链路跑通:向 https://api.deepseek.com/chat/completions 发 POST,stream 关闭(或省略),再从 choices[0].message.content 取文本。
下面是一段最小 HTML(body 里放展示区域 + type="module" 脚本即可),新建文件,然后丢到浏览器就行!
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<body>
<div id="reply"></div>
<script type="module">
const API_KEY = "sk-你自己的,没有的话去申请 https://platform.deepseek.com/usage";
// DeepSeek 的「对话补全」接口地址(与 OpenAI Chat Completions 格式兼容)
const endpoint = "https://api.deepseek.com/chat/completions";
// HTTP 请求头:声明 JSON 正文,并用 Bearer Token 携带 API Key
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
};
// 请求体:指定模型、对话消息列表;isStream: false 表示要一次性返回完整结果,而不是流式 SSE
const payload = {
// 模型类型
model: "deepseek-chat",
messages: [
// role 字段是一个枚举字段,可选的值分别是 system、user 和 assistant,依次表示该条消息是系统消息(也就是我们一般俗称的提示词)、用户消息和 AI 应答消息
{ role: "system", content: "You are a helpful assistant." }, // 系统提示,约束助手行为
{ role: "user", content: "你好 Deepseek" }, // 用户本轮输入
],
isStream: false,
};
// 向 DeepSeek 发起 POST,把 payload 序列化成 JSON 字符串作为 body
const response = await fetch(endpoint, {
method: "POST",
headers: headers,
body: JSON.stringify(payload),
});
// 把响应体解析为 JSON;接口成功时 choices[0].message.content 即助手回复正文
const data = await response.json();
// 把大模型返回的文本显示就行了
document.getElementById("reply").textContent =
data.choices[0].message.content;
</script>
</body>
</body>
</html>
二、为什么要流式:体感更好,协议长什么样
简单问题整包返回没问题;问题一长,用户会长时间盯着空白。把请求里的 stream 设为 true,模型就会边生成边吐字,前端边读边展示。
流式时,控制台里常见一行以 data: 开头,后面跟一段 JSON;结束标记 一般是 data: [DONE] (注意是 [DONE],大小写与官方一致)。下面是一条真实形态示例(单行 JSON,便于你对照日志):
shell
data: {"id":"07b44fd1-5339-4ea5-a3e5-e62464fabe3d","object":"chat.completion.chunk","created":1776131664,"model":"deepseek-chat","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache_new_kvcache_20260410","choices":[{"index":0,"delta":{"content":""},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":972,"total_tokens":982,"prompt_tokens_details":{"cached_tokens":0},"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":10}}
三、浏览器里用 fetch + ReadableStream 解析 SSE(Vue CDN 单页)
这一版页面做了几件事:
- 用 Vue 3(CDN ESM) 做一个最小界面:Key、问题、是否流式、提交按钮。
- 流式时:
response.body.getReader()+TextDecoder,按行切分;只处理以data:开头的行;能JSON.parse就读choices[0].delta.content做增量;解析失败就把半行塞回缓冲区,等下一段数据补齐。 - 非流式:
response.json()一次取全量。
下面给出完整单页 HTML(可直接本地打开试用;Key 仅保存在本机 localStorage,不要把带 Key 的页面部署到公网):
html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DeepSeek 流式(Vue CDN 单页)</title>
</head>
<body>
<div id="app"></div>
<script type="module">
import {
createApp,
ref,
} from "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js";
createApp({
setup() {
const apiKey = ref(
typeof localStorage !== "undefined"
? localStorage.getItem("deepseek_api_key") || ""
: "",
);
const question = ref("讲一个关于中国龙的故事");
const content = ref("");
const isStream = ref(true);
const loading = ref(false);
const error = ref("");
function saveKey() {
try {
localStorage.setItem("deepseek_api_key", apiKey.value.trim());
} catch (_) {}
}
async function update() {
const key = apiKey.value.trim();
if (!key) {
error.value = "请填写 API Key(仅保存在本机 localStorage)";
return;
}
if (!question.value.trim()) {
error.value = "请输入问题";
return;
}
error.value = "";
loading.value = true;
content.value = isStream.value ? "" : "思考中...";
saveKey();
const endpoint = "https://api.deepseek.com/chat/completions";
const headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + key,
};
try {
const response = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify({
model: "deepseek-chat",
messages: [{ role: "user", content: question.value.trim() }],
stream: isStream.value,
}),
});
if (!response.ok) {
const errText = await response.text();
throw new Error(response.status + " " + errText.slice(0, 200));
}
if (isStream.value) {
content.value = "";
const reader = response.body?.getReader();
if (!reader) {
throw new Error("响应不支持 ReadableisStream");
}
const decoder = new TextDecoder();
let sseBuffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
sseBuffer += decoder.decode(value, { isStream: true });
const parts = sseBuffer.split("\n");
sseBuffer = parts.pop() ?? "";
for (const rawLine of parts) {
const line = rawLine.trim();
if (!line || line.startsWith(":")) continue;
if (!line.startsWith("data:")) continue;
console.log(line);
const payload = line.slice(5).trim();
if (payload === "[DONE]") {
loading.value = false;
return;
}
try {
const data = JSON.parse(payload);
const delta = data?.choices?.[0]?.delta?.content;
if (delta) content.value += delta;
} catch {
sseBuffer = rawLine + "\n" + sseBuffer;
}
}
}
if (sseBuffer.trim()) {
const line = sseBuffer.trim();
if (line.startsWith("data:")) {
const payload = line.slice(5).trim();
if (payload && payload !== "[DONE]") {
try {
const data = JSON.parse(payload);
const delta = data?.choices?.[0]?.delta?.content;
if (delta) content.value += delta;
} catch (_) {}
}
}
}
} else {
const data = await response.json();
const text = data?.choices?.[0]?.message?.content;
content.value = text ?? JSON.stringify(data);
}
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
if (!isStream.value) content.value = "";
} finally {
loading.value = false;
}
}
return {
apiKey,
question,
content,
isStream,
loading,
error,
update,
};
},
template: `
<div class="wrap">
<h1>DeepSeek 对话(流式 / 非流式)</h1>
<p class="hint">
单文件演示:API Key 存于浏览器 localStorage。请勿把含 Key 的页面上传到公网。
</p>
<div class="row">
<label for="k">Key</label>
<input id="k" type="password" v-model="apiKey" placeholder="sk-..." autocomplete="off" />
</div>
<div class="row">
<label for="q">问题</label>
<input id="q" class="input-q" type="text" v-model="question" />
</div>
<div class="row">
<label><input type="checkbox" v-model="isStream" :disabled="loading" /> 流式输出 (SSE)</label>
<button type="button" :disabled="loading" @click="update">{{ loading ? '请求中...' : '提交' }}</button>
</div>
<p v-if="error" class="err">{{ error }}</p>
<div class="output">{{ content || (loading && isStream ? '...' : '') }}</div>
</div>
`,
}).mount("#app");
</script>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background: #0f1419;
color: #e6edf3;
min-height: 100vh;
}
.wrap {
max-width: 52rem;
margin: 0 auto;
padding: 1rem 1.25rem 2rem;
}
h1 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.75rem;
color: #8b949e;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
label {
font-size: 0.85rem;
color: #8b949e;
}
input[type="text"],
input[type="password"] {
flex: 1;
min-width: 12rem;
padding: 0.45rem 0.6rem;
border-radius: 6px;
border: 1px solid #30363d;
background: #161b22;
color: #e6edf3;
font-size: 0.85rem;
}
input.input-q {
width: 100%;
min-width: 100%;
}
button {
padding: 0.45rem 1rem;
border-radius: 6px;
border: 1px solid #388bfd;
background: #21262d;
color: #58a6ff;
font-size: 0.85rem;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hint {
font-size: 0.75rem;
color: #6e7681;
margin: 0 0 1rem;
line-height: 1.45;
}
.output {
margin-top: 0.75rem;
padding: 1rem;
border-radius: 8px;
border: 1px solid #30363d;
background: #161b22;
min-height: 12rem;
white-space: pre-wrap;
word-break: break-word;
text-align: left;
font-size: 0.9rem;
line-height: 1.55;
}
.err {
color: #f85149;
margin-top: 0.5rem;
font-size: 0.85rem;
text-align: left;
}
</style>
</body>
</html>
到这里可以记住一句话:流式开关在请求体里的 stream 字段 ;而浏览器侧「读流」的套路,基本就是 ReadableStream 读片 + 文本解码 + 行缓冲 + 解析 data: JSON。
四、SSE 与「为什么不能在前端直接用 EventSource 调大模型」
DeepSeek 以及大量兼容 OpenAI 的平台,流式输出本质上是标准的 Server-Sent Events(SSE):文本协议、单向(服务端 → 浏览器)、比 WebSocket 轻。
但 EventSource 按规范只支持 GET ,且不方便携带我们常用的 Authorization: Bearer ... ;而大模型对话接口又通常是 POST + JSON body 。所以:不是浏览器不能玩 SSE,而是不能「直接用 EventSource」去怼官方大模型域名。
常见工程化解法是做一层 BFF(Backend For Frontend) :由 Node 持有密钥,替浏览器去 POST 上游,再把上游 SSE 裁剪/转写成浏览器更好消费的 SSE(或 JSON 行)。
五、零 npm 的 Node 代理:server.js
这里用 Node 22+ 内置 http / fs / path / fetch ,不引入 express、dotenv 等依赖,文件即服务。
- 从项目根目录读取
.env.local或.env(简单解析KEY=value)。 GET /stream?question=...:上游stream: true,把增量以 SSE 写回(示例里对纯文本 delta 做了JSON.stringify,避免正文换行弄坏 SSE)。GET /complete?question=...:上游stream: false,返回{ "content": "..." },给「非流式」前端一条同源捷径。
在同级目录创建 .env.local,写入一行(示例):
VITE_DEEPSEEK_API_KEY=sk-你自己的
然后执行:
node server.js
完整服务端代码如下(文件名 server.js):
js
"use strict";
/**
* 零依赖代理:Node 22+(内置 fetch)
* 启动:node server.js
* 环境变量:在项目根目录放置 .env.local 或 .env,写入
* VITE_DEEPSEEK_API_KEY=sk-...
* 可选:PORT=3000、DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions
*
* 调用示例:
* curl -N "http://localhost:3000/stream?question=你好"
*/
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = __dirname;
function loadDotEnv() {
for (const name of [".env.local", ".env"]) {
const file = path.join(ROOT, name);
if (!fs.existsSync(file)) continue;
const text = fs.readFileSync(file, "utf8");
for (const line of text.split(/\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let val = trimmed.slice(eq + 1).trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = val;
}
}
}
loadDotEnv();
const PORT = Number(process.env.PORT) || 3000;
const API_KEY =
process.env.VITE_DEEPSEEK_API_KEY || process.env.DEEPSEEK_API_KEY || "";
const UPSTREAM =
process.env.DEEPSEEK_API_URL || "https://api.deepseek.com/chat/completions";
if (!API_KEY) {
console.error(
"缺少 API Key:请在 .env.local 或 .env 中设置 VITE_DEEPSEEK_API_KEY(或 DEEPSEEK_API_KEY)",
);
process.exit(1);
}
const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
/**
* 将上游 OpenAI 兼容 SSE 行解析为 delta 文本,并写给客户端
* @param {import('node:http').ServerResponse} res
* @param {ReadableStreamDefaultReader<Uint8Array>} reader
*/
async function pipeUpstreamSseToClient(res, reader) {
const decoder = new TextDecoder();
let carry = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
carry += decoder.decode(value, { stream: true });
let nl;
while ((nl = carry.indexOf("\n")) !== -1) {
const rawLine = carry.slice(0, nl);
carry = carry.slice(nl + 1);
const line = rawLine.trim();
if (!line || line.startsWith(":")) continue;
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") {
res.write("event: end\n");
res.write("data: [DONE]\n\n");
return;
}
try {
const data = JSON.parse(payload);
const delta = data?.choices?.[0]?.delta?.content;
if (delta) {
// 用 JSON 包裹一段文本,避免 delta 内含换行破坏 SSE
res.write(`data: ${JSON.stringify(delta)}\n\n`);
}
} catch {
carry = `${rawLine}\n${carry}`;
break;
}
}
}
res.write("event: end\n");
res.write("data: [DONE]\n\n");
}
const server = http.createServer(async (req, res) => {
const host = req.headers.host || `127.0.0.1:${PORT}`;
let url;
try {
url = new URL(req.url || "/", `http://${host}`);
} catch {
res.writeHead(400, {
"Content-Type": "text/plain; charset=utf-8",
...CORS,
});
res.end("bad url");
return;
}
if (req.method === "OPTIONS") {
res.writeHead(204, CORS);
res.end();
return;
}
if (req.method === "GET" && url.pathname === "/") {
res.writeHead(200, {
"Content-Type": "text/plain; charset=utf-8",
...CORS,
});
res.end(
`DeepSeek 代理已就绪。\n\n流式:GET /stream?question=你的问题\n非流式:GET /complete?question=你的问题\n示例:http://localhost:${PORT}/stream?question=你好\n`,
);
return;
}
if (req.method === "GET" && url.pathname === "/complete") {
const question = (url.searchParams.get("question") || "").trim();
if (!question) {
res.writeHead(400, {
"Content-Type": "application/json; charset=utf-8",
...CORS,
});
res.end(JSON.stringify({ error: "缺少参数:question" }));
return;
}
try {
const upstream = await fetch(UPSTREAM, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: [{ role: "user", content: question }],
stream: false,
}),
});
const text = await upstream.text();
if (!upstream.ok) {
res.writeHead(upstream.status, {
"Content-Type": "application/json; charset=utf-8",
...CORS,
});
res.end(
JSON.stringify({ error: "upstream", body: text.slice(0, 800) }),
);
return;
}
let data;
try {
data = JSON.parse(text);
} catch {
res.writeHead(502, {
"Content-Type": "application/json; charset=utf-8",
...CORS,
});
res.end(JSON.stringify({ error: "上游返回非 JSON" }));
return;
}
const content = data?.choices?.[0]?.message?.content ?? "";
res.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
...CORS,
});
res.end(JSON.stringify({ content }));
} catch (e) {
res.writeHead(500, {
"Content-Type": "application/json; charset=utf-8",
...CORS,
});
res.end(
JSON.stringify({ error: e instanceof Error ? e.message : String(e) }),
);
}
return;
}
if (req.method === "GET" && url.pathname === "/stream") {
const question = (url.searchParams.get("question") || "").trim();
if (!question) {
res.writeHead(400, {
"Content-Type": "text/plain; charset=utf-8",
...CORS,
});
res.end("缺少参数:question");
return;
}
res.writeHead(200, {
...CORS,
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
const ac = new AbortController();
const onClose = () => ac.abort();
res.on("close", onClose);
try {
const upstream = await fetch(UPSTREAM, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: [{ role: "user", content: question }],
stream: true,
}),
signal: ac.signal,
});
if (!upstream.ok || !upstream.body) {
const t = await upstream.text().catch(() => "");
res.write(
`data: ${JSON.stringify({
error: `upstream ${upstream.status}`,
body: t.slice(0, 800),
})}\n\n`,
);
return;
}
await pipeUpstreamSseToClient(res, upstream.body.getReader());
} catch (e) {
if (e?.name === "AbortError") {
return;
}
console.error(e);
res.write(
`data: ${JSON.stringify({ error: e instanceof Error ? e.message : String(e) })}\n\n`,
);
} finally {
res.off("close", onClose);
if (!res.writableEnded) res.end();
}
return;
}
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8", ...CORS });
res.end("not found");
});
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log(
`Stream: curl -N "http://localhost:${PORT}/stream?question=你好"`,
);
console.log(
`Complete: curl "http://localhost:${PORT}/complete?question=你好"`,
);
});
六、前端改用 EventSource:更轻的一层消费
当密钥已经只在服务端时,浏览器不再需要输入 Key。流式场景下,用 EventSource 连接自家代理,例如:
http://127.0.0.1:3000/stream?question=...(question 请做 URL 编码)
下面是与当前仓库一致的 index.html 版本:流式走 EventSource,非流式走 GET /complete;并把代理根地址记在 localStorage 里,方便反复调试。
html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DeepSeek 流式(Vue CDN 单页)</title>
</head>
<body>
<div id="app"></div>
<script type="module">
import {
createApp,
ref,
onUnmounted,
} from "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js";
createApp({
setup() {
/** 本地 node server.js 地址(与 server 监听端口一致) */
const proxyBase = ref("http://127.0.0.1:3000");
const question = ref("讲一个关于中国龙的故事");
const content = ref("");
const isStream = ref(true);
const loading = ref(false);
const error = ref("");
/** @type {EventSource | null} */
let eventSource = null;
function closeEventSource() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
onUnmounted(() => {
closeEventSource();
});
function saveProxyBase() {
try {
localStorage.setItem(
"deepseek_proxy_base",
proxyBase.value.trim(),
);
} catch (_) {}
}
async function update() {
if (!question.value.trim()) {
error.value = "请输入问题";
return;
}
const base = proxyBase.value.trim().replace(/\/$/, "");
if (!base) {
error.value = "请填写代理地址";
return;
}
error.value = "";
closeEventSource();
saveProxyBase();
const q = encodeURIComponent(question.value.trim());
if (isStream.value) {
loading.value = true;
content.value = "";
const url = `${base}/stream?question=${q}`;
const es = new EventSource(url);
eventSource = es;
es.addEventListener("message", (e) => {
if (e.data === "[DONE]") return;
try {
const parsed = JSON.parse(e.data);
if (
parsed &&
typeof parsed === "object" &&
parsed !== null &&
"error" in parsed
) {
error.value =
typeof parsed.error === "string"
? parsed.error
: JSON.stringify(parsed.error);
closeEventSource();
loading.value = false;
return;
}
if (typeof parsed === "string") {
content.value += parsed;
}
} catch {
error.value = "SSE 解析失败:" + e.data;
closeEventSource();
loading.value = false;
}
});
es.addEventListener("end", () => {
closeEventSource();
loading.value = false;
});
es.onerror = () => {
if (!error.value) {
error.value =
"EventSource 连接失败(请确认已运行 node server.js,且代理地址、端口正确)";
}
closeEventSource();
loading.value = false;
};
return;
}
loading.value = true;
content.value = "思考中...";
try {
const res = await fetch(`${base}/complete?question=${q}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(
typeof data.error === "string"
? data.error
: res.status + " " + JSON.stringify(data).slice(0, 200),
);
}
if (data && typeof data.content === "string") {
content.value = data.content;
} else {
content.value = JSON.stringify(data);
}
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
content.value = "";
} finally {
loading.value = false;
}
}
if (typeof localStorage !== "undefined") {
const saved = localStorage.getItem("deepseek_proxy_base");
if (saved) proxyBase.value = saved;
}
return {
proxyBase,
question,
content,
isStream,
loading,
error,
update,
};
},
template: `
<div class="wrap">
<h1>DeepSeek 对话(经本地 server.js)</h1>
<p class="hint">
先在本项目目录运行 <code>node server.js</code>(Key 写在服务端 .env.local)。流式走
<code>GET /stream</code>(EventSource),非流式走 <code>GET /complete</code>。
</p>
<div class="row">
<label for="proxy">代理</label>
<input id="proxy" type="text" v-model="proxyBase" placeholder="http://127.0.0.1:3000" autocomplete="off" />
</div>
<div class="row">
<label for="q">问题</label>
<input id="q" class="input-q" type="text" v-model="question" />
</div>
<div class="row">
<label><input type="checkbox" v-model="isStream" :disabled="loading" /> 流式输出 (SSE)</label>
<button type="button" :disabled="loading" @click="update">{{ loading ? '请求中...' : '提交' }}</button>
</div>
<p v-if="error" class="err">{{ error }}</p>
<div class="output">{{ content || (loading && isStream ? '...' : '') }}</div>
</div>
`,
}).mount("#app");
</script>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background: #0f1419;
color: #e6edf3;
min-height: 100vh;
}
.wrap {
max-width: 52rem;
margin: 0 auto;
padding: 1rem 1.25rem 2rem;
}
h1 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.75rem;
color: #8b949e;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
label {
font-size: 0.85rem;
color: #8b949e;
}
input[type="text"],
input[type="password"] {
flex: 1;
min-width: 12rem;
padding: 0.45rem 0.6rem;
border-radius: 6px;
border: 1px solid #30363d;
background: #161b22;
color: #e6edf3;
font-size: 0.85rem;
}
input.input-q {
width: 100%;
min-width: 100%;
}
button {
padding: 0.45rem 1rem;
border-radius: 6px;
border: 1px solid #388bfd;
background: #21262d;
color: #58a6ff;
font-size: 0.85rem;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hint {
font-size: 0.75rem;
color: #6e7681;
margin: 0 0 1rem;
line-height: 1.45;
}
.hint code {
font-size: 0.85em;
padding: 0.12em 0.4em;
border-radius: 4px;
background: #21262d;
color: #79c0ff;
}
.output {
margin-top: 0.75rem;
padding: 1rem;
border-radius: 8px;
border: 1px solid #30363d;
background: #161b22;
min-height: 12rem;
white-space: pre-wrap;
word-break: break-word;
text-align: left;
font-size: 0.9rem;
line-height: 1.55;
}
.err {
color: #f85149;
margin-top: 0.5rem;
font-size: 0.85rem;
text-align: left;
}
</style>
</body>
</html>
看效果:
node server.js启动服务- index.html直接在浏览器打开就行
七、和「手写 ReadableStream」相比:EventSource 在写什么
有了 BFF 之后,浏览器侧可以收敛成「连接 + 监听消息 + 结束关闭」的写法。下面是一段示意 (注意:真实页面里 e.data 往往是 JSON 字符串化的片段 ,需要 JSON.parse 后再拼接,见上一节完整 index.html;这里保留原文写法不动):
js
const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
eventSource.addEventListener("message", function(e: any) {
content.value += e.data;
});
eventSource.addEventListener('end', () => {
eventSource.close();
});
除了代码更短之外,EventSource 还自带自动重连语义 (适合长连接场景;生产环境仍要结合幂等、去重与产品体验谨慎使用)。标准里也提到 Last-Event-ID 等能力,用于断线续传时减少重复流量(是否启用取决于你的 BFF 设计)。
八、附录:SSE 是什么、数据长什么样
SSE(Server-Sent Events) :服务端主动向浏览器推送事件流,单向、基于 HTTP,通常比 WebSocket 更轻。
前端最小用法示例(与具体业务路径无关,仅演示 API):
js
// 建立 SSE 连接
const evtSource = new EventSource('/api/sse');
// 监听服务器发来的消息
evtSource.onmessage = (e) => {
console.log('收到消息:', e.data);
};
// 监听错误
evtSource.onerror = (err) => {
console.error('SSE 出错', err);
};
// 关闭连接
evtSource.onmessage = (e) => {
if (e.data === 'done') {
evtSource.close(); // 关闭 SSE 连接
return;
}
console.log(e.data);
};
服务端写入时,需要满足 SSE 的基本形态:Content-Type: text/event-stream ,消息以 data: 开头,并以 空行(\n\n) 结束一条事件。下面用注释标了几种常见形态(注意:注释行只是说明,真实协议里注释行以 : 开头,这里保留原文示例不动):
shell
data: 你好任意巴拉巴拉\n\n
# json串
data: {"name":"小明","age":20}\n\n
# 自定义结束 前端获取就行
data: done\n\n
# 发空行表示心跳
data: \n\n
Node 里设置响应头并周期性 write 的伪代码如下(仅帮助理解,不是可直接运行的完整服务):
js
// 伪代码
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// 每隔 1 秒发一条
setInterval(() => {
res.write(`data: ${new Date()}\n\n`);
}, 1000);
结束标记(例如 done)可以自定义,但团队内最好统一约定;本文 BFF 示例则使用 event: end + data: [DONE] 的组合来通知前端收尾。
小结
- 直连 :
fetch+stream+ ReadableStream 解析data:行,灵活但代码多,且 Key 在浏览器。 - BFF :Node 持有 Key,浏览器用
EventSource/fetch访问同源或可控跨域接口,职责更清晰。 - 安全 :Key 进
.env.local、.gitignore忽略本地环境文件;页面不要上传公网。
祝调试顺利。