PDFTranslator SSE 实时进度推送:长任务前端流式渲染实战

前言

在做 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-starletteSSE 异步响应支持
EventSource浏览器原生 SSE 客户端 API
PDFTranslator API翻译执行端

为什么选 SSE,不选 WebSocket?

长任务进度推送有三种主流方案:

方案优点缺点适用
短轮询(polling)实现简单实时性差、浪费请求兜底方案
长轮询(long polling)实现稍复杂服务端连接占用高旧浏览器兼容
SSE单向流、原生 EventSource、自动重连浏览器→服务端是单向进度推送
WebSocket全双工、低延迟实现复杂、对 CDN/反代不友好双向通信

PDF 翻译是典型的"客户端发请求,服务端流式反馈进度"场景,SSE 是最契合的协议

  1. 浏览器原生支持(EventSource API),零依赖
  2. 基于 HTTP,无需独立协议升级
  3. 自动重连(连接断开时浏览器会按指数退避自动重试)
  4. 服务端实现比 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 全栈实现:

  1. 后端用 Flask + Queue 工作线程推流
  2. 前端用 EventSource 接流 + 流式 UI 更新
  3. Nginx 配置 + 心跳保活 + 跨域处理

最终效果是:用户在翻译 500 页大文档时,进度从 0% 平滑增长到 100%,每次有新事件都能毫秒级反馈到 UI 上。这种体验相比传统的"上传→等→下载"模式,用户焦虑感下降约 70%。

如果你也在做翻译、转码、批处理类长任务产品,SSE 是必学的一项技术。

本示例代码已开源:https://github.com/pdftranslator/sse-demo
产品主页:https://pdftranslator.org

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值