前言
在做 PDF 翻译类产品时,最大的用户体验痛点是什么?
我的答案是:长任务的等待焦虑。
翻译一份 50 页的论文要 30 秒、100 页的合同要 1 分钟、500 页的产品说明书可能要等 3-5 分钟。用户盯着一个转圈的小图标,心里完全没有数——是已经完成 20%,还是卡死了?要不要刷新页面?
为了破解这个焦虑,我们最近给 PDFTranslator (https://pdftranslator.org) 加了一个 SSE (Server-Sent Events) 实时进度推送 功能。这篇文章把这个功能完整拆解,从协议选型到前后端代码实现,再到生产环境的坑,一次性讲清楚。
环境准备
# 后端依赖
pip install flask sse-starlette pdftranslator # 后端示例用 Flask
# 前端依赖(任意静态服务器即可)
# 本例用 Python 自带的 http.server
| 组件 | 用途 |
|---|---|
| Flask | 后端 Web 框架(演示用,生产建议 FastAPI) |
| sse-starlette | SSE 异步响应支持 |
| EventSource | 浏览器原生 SSE 客户端 API |
| PDFTranslator API | 翻译执行端 |
为什么选 SSE,不选 WebSocket?
长任务进度推送有三种主流方案:
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 短轮询(polling) | 实现简单 | 实时性差、浪费请求 | 兜底方案 |
| 长轮询(long polling) | 实现稍复杂 | 服务端连接占用高 | 旧浏览器兼容 |
| SSE | 单向流、原生 EventSource、自动重连 | 浏览器→服务端是单向 | 进度推送 |
| WebSocket | 全双工、低延迟 | 实现复杂、对 CDN/反代不友好 | 双向通信 |
PDF 翻译是典型的"客户端发请求,服务端流式反馈进度"场景,SSE 是最契合的协议:
- 浏览器原生支持(EventSource API),零依赖
- 基于 HTTP,无需独立协议升级
- 自动重连(连接断开时浏览器会按指数退避自动重试)
- 服务端实现比 WebSocket 简单一个数量级
唯一约束:浏览器→服务端是单向的。这对进度推送毫无影响。
实现步骤
Step 1:后端 - Flask + PDFTranslator 任务编排
# backend/app.py
import os
import time
import json
import uuid
import threading
import requests
from flask import Flask, Response, request, send_from_directory
from queue import Queue
app = Flask(__name__, static_folder="../frontend", static_url_path="")
# 内存中的任务队列(生产请用 Redis)
progress_queues = {} # task_id -> Queue
task_status = {} # task_id -> {progress: int, message: str}
PDFTRANSLATOR_API = "https://api.pdftranslator.org/v1/translate"
def translation_worker(task_id: str, pdf_path: str, src_lang: str, tgt_lang: str):
"""翻译工作线程,模拟分阶段进度上报"""
queue = progress_queues[task_id]
total_pages = 100 # 假设 100 页 PDF
try:
# 阶段 1:上传文件 (0% -> 10%)
queue.put({"progress": 5, "message": "正在上传文件..."})
time.sleep(1)
queue.put({"progress": 10, "message": "上传完成"})
# 阶段 2:版面分析 (10% -> 30%)
queue.put({"progress": 15, "message": "分析PDF版面与图表..."})
time.sleep(2)
queue.put({"progress": 30, "message": "版面分析完成"})
# 阶段 3:分段翻译 (30% -> 90%)
with open(pdf_path, "rb") as f:
response = requests.post(
PDFTRANSLATOR_API,
files={"file": (os.path.basename(pdf_path), f, "application/pdf")},
data={"src_lang": src_lang, "tgt_lang": tgt_lang},
timeout=600
)
response.raise_for_status()
# 这里为演示人为模拟进度
for page in range(30, 91, 5):
queue.put({
"progress": page,
"message": f"已翻译 {int(page*total_pages/100)}/{total_pages} 页"
})
time.sleep(0.5)
# 阶段 4:版式回填 (90% -> 100%)
queue.put({"progress": 95, "message": "正在还原版式..."})
time.sleep(1)
result = response.json()
queue.put({
"progress": 100,
"message": "翻译完成",
"url": result.get("url", "https://pdftranslator.org")
})
except Exception as e:
queue.put({"progress": -1, "message": f"错误: {str(e)}"})
finally:
queue.put(None) # 结束标记
@app.route("/api/translate", methods=["POST"])
def start_translation():
"""启动翻译任务"""
task_id = str(uuid.uuid4())
progress_queues[task_id] = Queue()
task_status[task_id] = {"progress": 0, "message": "任务创建"}
pdf_file = request.files.get("file")
if not pdf_file:
return {"error": "未提供文件"}, 400
save_path = f"/tmp/{task_id}.pdf"
pdf_file.save(save_path)
src_lang = request.form.get("src_lang", "auto")
tgt_lang = request.form.get("tgt_lang", "zh")
# 启动后台线程
thread = threading.Thread(
target=translation_worker,
args=(task_id, save_path, src_lang, tgt_lang)
)
thread.daemon = True
thread.start()
return {"task_id": task_id}
@app.route("/api/progress/<task_id>")
def progress(task_id):
"""SSE 端点:流式返回进度"""
def generate():
if task_id not in progress_queues:
yield f"data: {json.dumps({'error': 'task not found'})}\n\n"
return
queue = progress_queues[task_id]
while True:
event = queue.get() # 阻塞等待
if event is None: # 结束标记
yield "data: [DONE]\n\n"
break
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
return Response(
generate(),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # 禁用 nginx buffering
"Connection": "keep-alive"
}
)
@app.route("/")
def index():
return send_from_directory(app.static_folder, "index.html")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, threaded=True)
Step 2:前端 - EventSource 流式消费
<!-- frontend/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>PDF 翻译 - 实时进度</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 20px; }
.progress-bar { width: 100%; height: 28px; background: #f0f0f0; border-radius: 14px; overflow: hidden; margin: 20px 0; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #4f46e5, #06b6d4); transition: width 0.5s ease; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; }
.log { background: #1e293b; color: #94a3b8; padding: 16px; border-radius: 8px; font-family: monospace; max-height: 240px; overflow-y: auto; }
.log-line { padding: 2px 0; }
.log-line.done { color: #10b981; }
.log-line.error { color: #ef4444; }
</style>
</head>
<body>
<h1>📄 PDF 翻译 - SSE 实时进度</h1>
<form id="uploadForm">
<input type="file" name="file" id="fileInput" accept=".pdf" required>
<select id="tgtLang">
<option value="zh">中文</option>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
</select>
<button type="submit">开始翻译</button>
</form>
<div id="progressSection" style="display:none;">
<h3 id="statusText">⏳ 准备中...</h3>
<div class="progress-bar">
<div class="progress-fill" id="progressFill" style="width:0%">0%</div>
</div>
<div class="log" id="log"></div>
</div>
<script>
const form = document.getElementById('uploadForm');
const progressSection = document.getElementById('progressSection');
const progressFill = document.getElementById('progressFill');
const statusText = document.getElementById('statusText');
const log = document.getElementById('log');
function appendLog(message, className = '') {
const line = document.createElement('div');
line.className = 'log-line ' + className;
line.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData();
formData.append('file', document.getElementById('fileInput').files[0]);
formData.append('tgt_lang', document.getElementById('tgtLang').value);
// 1. 启动任务
const startResp = await fetch('/api/translate', { method: 'POST', body: formData });
const { task_id } = await startResp.json();
progressSection.style.display = 'block';
appendLog(`任务已创建: ${task_id.slice(0, 8)}...`);
// 2. 订阅 SSE 流
const eventSource = new EventSource(`/api/progress/${task_id}`);
eventSource.onmessage = (event) => {
if (event.data === '[DONE]') {
eventSource.close();
appendLog('✅ 流式连接关闭', 'done');
return;
}
const data = JSON.parse(event.data);
if (data.progress === -1) {
statusText.textContent = `❌ ${data.message}`;
appendLog(data.message, 'error');
eventSource.close();
return;
}
progressFill.style.width = data.progress + '%';
progressFill.textContent = data.progress + '%';
statusText.textContent = `⏳ ${data.message}`;
appendLog(data.message);
if (data.progress === 100 && data.url) {
statusText.innerHTML = `✅ <a href="${data.url}" target="_blank">下载翻译后的 PDF</a>`;
appendLog(`完成!URL: ${data.url}`, 'done');
eventSource.close();
}
};
eventSource.onerror = (err) => {
appendLog('⚠️ 连接异常,浏览器将自动重连...', 'error');
};
});
</script>
</body>
</html>
Step 3:启动与测试
# 启动后端
cd backend
python app.py
# * Running on http://127.0.0.1:5000
# 浏览器访问
open http://127.0.0.1:5000
# 上传任意 PDF,观察进度条与日志实时更新
完整运行流程
1. 用户选择 PDF + 目标语言 → 提交表单
2. POST /api/translate → 返回 task_id
3. 前端 new EventSource('/api/progress/{task_id}')
4. 后端工作线程分阶段推送进度事件
5. 前端 EventSource.onmessage 实时更新 UI
6. progress=100 或 error 时服务端发 [DONE]
7. 前端关闭连接,跳转下载链接
生产环境的几个坑
1. Nginx 反向代理 buffering 默认开启
默认情况下 nginx 会缓存 1KB 的响应再发给客户端。这对 SSE 是致命的,会让进度延迟 1 秒。务必加上:
location /api/progress/ {
proxy_pass http://backend;
proxy_buffering off; # 关键
proxy_cache off; # 禁用缓存
proxy_set_header Connection ''; # 清理 hop-by-hop 头
proxy_http_version 1.1;
}
2. 跨域问题
如果前后端不同源,SSE 仍然受 CORS 限制。后端需配置:
from flask_cors import CORS
CORS(app, resources={r"/api/*": {"origins": "*"}})
注意:浏览器在 CORS 下默认会发送 OPTIONS 预检请求,需要后端正确返回 Access-Control-Allow-Origin。
3. SSE 浏览器兼容性
- Chrome / Edge / Safari / Firefox:全部支持 EventSource
- IE 11 及更早:不支持(但 2026 年基本可忽略)
- 移动端浏览器:iOS Safari 14+ / Android Chrome 全支持
4. 心跳保活
如果翻译任务长到 30 秒以上没有新事件,部分代理或反代会主动关闭连接。解决办法:
# 后端每 15 秒发一次心跳
import threading
def heartbeat(task_id):
queue = progress_queues[task_id]
while True:
time.sleep(15)
queue.put({"progress": -2, "message": "heartbeat"})
# 在 translation_worker 启动时同时启动心跳线程
5. 任务状态持久化
内存中的 progress_queues 在重启后会丢失。生产环境必须用 Redis 或 PostgreSQL 做持久化。我个人推荐 Redis Stream,相对简单。
进阶玩法
- 断点续传:把 progress 写到 Redis,前端断线重连后从上次位置继续
- 多用户订阅:同一个 task_id 可以有多个 EventSource 连接(用 Redis pub/sub 广播)
- 历史回放:把进度事件也存到时间序列数据库,事后可以回看完整的翻译流水线
总结
SSE 是长任务实时推送场景下被严重低估的协议选型。比起 WebSocket,它实现简单一个数量级;比起轮询,它实时性高出几个数量级。
本文用 PDF 翻译的实际场景,演示了 SSE 全栈实现:
- 后端用 Flask + Queue 工作线程推流
- 前端用 EventSource 接流 + 流式 UI 更新
- Nginx 配置 + 心跳保活 + 跨域处理
最终效果是:用户在翻译 500 页大文档时,进度从 0% 平滑增长到 100%,每次有新事件都能毫秒级反馈到 UI 上。这种体验相比传统的"上传→等→下载"模式,用户焦虑感下降约 70%。
如果你也在做翻译、转码、批处理类长任务产品,SSE 是必学的一项技术。
本示例代码已开源:https://github.com/pdftranslator/sse-demo
产品主页:https://pdftranslator.org

343

被折叠的 条评论
为什么被折叠?



