ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WebUI开发实战:从本地服务搭建到SSE实时状态推送

WebUI开发实战:从本地服务搭建到SSE实时状态推送

简介:本资源是一套完整的WebUI前端开发实践项目包,面向Web初学者与前端入门开发者,聚焦HTML核心结构搭建及与CSS、JavaScript的协同实现。资源以HTML为基石,涵盖语义化标签应用、多媒体嵌入、表单交互等典型场景,并通过配套CSS样式与JS逻辑体现现代WebUI的分层开发思想。压缩包共156个文件,含26个HTML页面(含基础模板与功能示例)、21个JS脚本(含事件处理与DOM操作)、20个CSS文件(含响应式布局与CSS3动效)、以及29个JPG和17个PNG素材资源,另有Gradle构建配置、Java Web相关class/jsp/war等扩展文件,整体6.54MB,结构完整,适合作为教学案例或本地调试环境快速启动。目前已有352人学习下载,提供可直接运行的静态站点骨架、常见UI组件实现片段、构建工具配置模板及二进制校验文件,便于理解工程化流程与前后端衔接要点。

1. WebUI 不是浏览器标签页,而是你本地服务的「操作台」:它不发请求、不连云端、不依赖部署,只把 HTML/CSS/JS 当成胶水,把 Python 后端逻辑焊死在用户眼前

很多人第一次看到 WebUI 项目时,下意识点开index.html——然后页面一片空白,控制台报错Failed to load resource: net::ERR_FILE_NOT_FOUND。这不是代码坏了,是你误把它当成了静态网页。真正的 WebUI 是一个本地启动的 HTTP 服务进程,它用 Python(常见是 Flask 或 FastAPI)起一个微型服务器,把 HTML 文件作为前端资源托管,再通过 AJAX 或 Fetch 调用同域下的/api/xxx接口,而这些接口背后直连本地模型、文件系统或硬件设备。它解决的不是“怎么展示页面”,而是“如何让非程序员也能调参、拖文件、看实时日志、切模型版本”——比如某高校图像处理 Demo 中,导师不用写一行 JS 就能给本科生开放「上传图片→选去噪算法→调滑块→下载结果」的完整链路;又比如某跨平台系统里,嵌入式工程师在树莓派上跑着 WebUI,用手机浏览器就能重启服务、查看温度曲线、下发固件包。它适合三类人:需要快速验证算法逻辑的 Python 开发者、要交付可交互 demo 给协作方的算法工程师、以及必须绕过命令行给终端用户做轻量级操作界面的产品原型阶段。核心就一条:WebUI 的价值不在“网页多好看”,而在“把后端能力封装成按钮和输入框,且所有动作都发生在本机”。


2. WebUI 的骨架:从零手搭一个最小可运行实例,看清 Flask + HTML + AJAX 如何咬合

WebUI 不是黑匣子。它的最小闭环只有三块:Python 启动服务、HTML 提供界面、JavaScript 发送请求并渲染结果。下面这个例子,不依赖任何框架模板,纯手工拼出可运行的最小单元——你复制粘贴就能跑通,且每一步都暴露关键决策点。

2.1 初始化服务端:用 Flask 暴露一个/hello接口,返回 JSON

# app.py from flask import Flask, request, jsonify import os app = Flask(__name__) @app.route('/hello', methods=['POST']) def hello_endpoint(): # 1. 严格校验请求体是否为 JSON if not request.is_json: return jsonify({"error": "Content-Type must be application/json"}), 400 # 2. 解析 JSON 并提取 name 字段 data = request.get_json() name = data.get("name", "Anonymous") # 3. 模拟耗时操作(如加载模型、读取大文件) import time time.sleep(0.3) # 防止响应太快,掩盖前端 loading 状态问题 # 4. 返回结构化响应,含状态码和业务字段 return jsonify({ "status": "success", "message": f"Hello, {name}! Time: {int(time.time())}" }) if __name__ == '__main__': # 关键参数:host='0.0.0.0' 允许局域网访问;debug=False 生产禁用 app.run(host='0.0.0.0', port=5000, debug=False)

提示:app.run()的debug=True仅用于开发阶段。开启后 Flask 会自动重载代码,但会暴露 Werkzeug 调试器——一旦部署到公网,攻击者可通过调试器执行任意 Python 代码。生产环境必须设为False,并用gunicorn或uvicorn替代。

2.2 编写前端页面:HTML + 内联 CSS + 原生 JavaScript,零构建工具

<!-- index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>WebUI 最小实例</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 2rem; max-width: 600px; margin: 0 auto; } .form-group { margin-bottom: 1rem; } label { display: block; margin-bottom: 0.5rem; font-weight: 500; } input[type="text"] { width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; } button { background: #007bff; color: white; border: none; padding: 0.6rem 1.2rem; border-radius: 4px; cursor: pointer; } button:disabled { background: #6c757d; cursor: not-allowed; } #result { margin-top: 1.5rem; padding: 1rem; background: #f8f9fa; border-radius: 4px; white-space: pre-wrap; } .loading { color: #007bff; font-style: italic; } </style> </head> <body> <h1>WebUI Hello 实例</h1> <div class="form-group"> <label for="nameInput">请输入姓名:</label> <input type="text" id="nameInput" placeholder="例如:张工"> </div> <button id="submitBtn">发送请求</button> <div id="result"></div> <script> document.getElementById('submitBtn').addEventListener('click', async function() { const nameInput = document.getElementById('nameInput'); const resultDiv = document.getElementById('result'); const submitBtn = document.getElementById('submitBtn'); // 1. 输入校验:前端兜底,避免空请求 if (!nameInput.value.trim()) { resultDiv.textContent = '❌ 错误:姓名不能为空'; return; } // 2. 按钮置灰 + 显示 loading,防止重复点击 submitBtn.disabled = true; resultDiv.innerHTML = '<span class="loading">⏳ 正在请求中...</span>'; try { // 3. 发送 POST 请求,注意 URL 必须与 Flask 路由一致 const response = await fetch('http://localhost:5000/hello', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ name: nameInput.value.trim() }) }); // 4. 检查 HTTP 状态码(非 2xx 视为失败) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } // 5. 解析 JSON 响应 const data = await response.json(); // 6. 渲染成功结果 resultDiv.innerHTML = `<strong>✅ 成功响应:</strong>\n${JSON.stringify(data, null, 2)}`; } catch (error) { // 7. 统一错误处理:网络失败、解析失败、服务宕机都走这里 console.error('请求失败:', error); resultDiv.innerHTML = `<strong>❌ 请求失败:</strong>\n${error.message}`; } finally { // 8. 恢复按钮状态,无论成功失败 submitBtn.disabled = false; } }); </script> </body> </html>

参数说明:

  • fetch()的 URL 使用http://localhost:5000/hello,而非相对路径/hello,是因为 Flask 默认不启用 CORS,跨域请求会被浏览器拦截。若需支持其他域名访问(如http://192.168.1.100:8080),必须在 Flask 中显式添加flask-cors扩展;
  • JSON.stringify()包裹请求体,确保后端request.is_json校验通过;
  • response.json()是异步操作,必须用await,否则返回 Promise 对象而非实际数据;
  • white-space: pre-wrap保证 JSON 格式化缩进在<div>中正常显示,避免全挤成一行。

2.3 启动与验证:两行命令确认服务活了、页面通了

# 终端 1:启动后端服务(保持运行) $ python app.py * Running on http://0.0.0.0:5000 * Debug mode: off # 终端 2:用 Python 快速验证接口(无需浏览器) $ curl -X POST http://localhost:5000/hello \ -H "Content-Type: application/json" \ -d '{"name": "李工"}' {"status": "success", "message": "Hello, 李工! Time: 1718234567"}

为什么不用open index.html?
直接双击打开index.html时,浏览器地址栏显示file:///.../index.html,此时fetch()请求目标http://localhost:5000/hello属于跨域(协议filevshttp),浏览器直接拦截。正确做法是:用python -m http.server 8000在当前目录起一个静态文件服务器,然后访问http://localhost:8000/index.html——此时页面协议为http,与后端同源,AJAX 才能通。这是新手翻车率最高的第一步,务必卡死。


3. WebUI 的血肉:集成真实业务模块——以「本地图片处理」为例,打通文件上传、后端处理、结果回传全流程

最小实例只是骨架。真实 WebUI 的价值在于接入具体业务逻辑。我们以「用户上传一张 JPG,后端用 OpenCV 做灰度转换,返回处理后图片」为例,补全文件 IO、二进制传输、MIME 类型处理等硬核环节。这一步踩坑最多,也是区分玩具和可用系统的分水岭。

3.1 前端改造:支持文件选择、预览、上传进度与二进制构造

<!-- 在 index.html 的 <body> 内追加以下代码 --> <div class="form-group"> <label for="imageUpload">上传 JPG 图片:</label> <input type="file" id="imageUpload" accept="image/jpeg" /> <div id="previewContainer" style="margin-top: 1rem; display: none;"> <h3>上传预览:</h3> <img id="previewImg" style="max-width: 100%; border: 1px solid #ddd; border-radius: 4px;" /> </div> </div> <button id="processBtn" disabled>开始处理</button> <div id="progressBar" style="margin-top: 1rem; height: 6px; background: #e9ecef; border-radius: 3px; display: none;"> <div id="progressFill" style="height: 100%; background: #28a745; width: 0%; border-radius: 3px;"></div> </div> <div id="processResult" style="margin-top: 1rem;"></div> <script> // 文件选择监听 document.getElementById('imageUpload').addEventListener('change', function(e) { const file = e.target.files[0]; if (!file) return; // 1. 校验文件类型(前端二次保险) if (!file.type.match('image/jpeg')) { alert('❌ 仅支持 JPG 格式'); return; } // 2. 生成预览图(使用 FileReader API) const reader = new FileReader(); reader.onload = function(e) { const img = document.getElementById('previewImg'); img.src = e.target.result; document.getElementById('previewContainer').style.display = 'block'; document.getElementById('processBtn').disabled = false; }; reader.readAsDataURL(file); // 注意:readAsDataURL 生成 base64,仅用于预览 }); // 处理按钮点击 document.getElementById('processBtn').addEventListener('click', async function() { const fileInput = document.getElementById('imageUpload'); const file = fileInput.files[0]; const resultDiv = document.getElementById('processResult'); const progressFill = document.getElementById('progressFill'); const progressBar = document.getElementById('progressBar'); if (!file) return; // 1. 重置状态 resultDiv.innerHTML = ''; progressBar.style.display = 'block'; progressFill.style.width = '0%'; try { // 2. 构造 FormData 对象(关键!不能用 JSON.stringify) const formData = new FormData(); formData.append('image', file); // 字段名必须与后端 request.files['image'] 一致 // 3. 发送 POST 请求,注意:不设 Content-Type,让浏览器自动设置 multipart/form-data 及 boundary const response = await fetch('http://localhost:5000/process', { method: 'POST', body: formData, // ⚠️ 不能加 headers: {'Content-Type': 'multipart/form-data'},否则会破坏 boundary }); if (!response.ok) throw new Error(`HTTP ${response.status}`); // 4. 读取响应流(后端返回的是 image/jpeg 二进制) const blob = await response.blob(); const imageUrl = URL.createObjectURL(blob); // 5. 渲染结果图 resultDiv.innerHTML = ` <h3>✅ 处理完成(灰度图):</h3> <img src="${imageUrl}" style="max-width: 100%; border: 1px solid #28a745; border-radius: 4px;" /> <p><small>原始尺寸:${file.name} (${(file.size / 1024).toFixed(1)} KB)</small></p> `; // 6. 清理内存中的 object URL(重要!防内存泄漏) setTimeout(() => URL.revokeObjectURL(imageUrl), 5000); } catch (error) { console.error('处理失败:', error); resultDiv.innerHTML = `<strong>❌ 处理失败:</strong>${error.message}`; } finally { progressBar.style.display = 'none'; } }); </script>

关键逻辑说明:

  • FormData是浏览器原生对象,专为文件上传设计。它会自动拼接boundary字符串,并将文件二进制流按 MIME 规范编码,后端才能用request.files正确解析;
  • 若手动设置Content-Type: multipart/form-data,浏览器会忽略自动生成的boundary,导致后端收到乱码;
  • response.blob()用于接收二进制响应(如图片),response.json()仅适用于 JSON 文本;
  • URL.createObjectURL()创建临时内存 URL,必须配合URL.revokeObjectURL()释放,否则大量上传会吃光浏览器内存。

3.2 后端增强:接收文件、调用 OpenCV、返回处理后图片

# 在 app.py 中追加以下路由 import cv2 import numpy as np from io import BytesIO from flask import send_file @app.route('/process', methods=['POST']) def process_image(): # 1. 检查是否有文件上传 if 'image' not in request.files: return jsonify({"error": "No image file provided"}), 400 file = request.files['image'] # 2. 校验文件名(防御空文件名或恶意路径) if file.filename == '': return jsonify({"error": "Empty filename"}), 400 # 3. 限制文件大小(防止内存爆掉) file_content = file.read() if len(file_content) > 10 * 1024 * 1024: # 10MB return jsonify({"error": "File too large (>10MB)"}), 400 try: # 4. 用 OpenCV 从内存字节流解码 JPG nparr = np.frombuffer(file_content, np.uint8) img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) if img is None: return jsonify({"error": "Invalid JPG format"}), 400 # 5. 执行灰度转换(真实业务逻辑入口) gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 6. 编码为 JPG 字节流(压缩质量 95,平衡体积与画质) success, encoded_img = cv2.imencode('.jpg', gray_img, [cv2.IMWRITE_JPEG_QUALITY, 95]) if not success: return jsonify({"error": "Image encoding failed"}), 500 # 7. 返回二进制流,设置正确的 MIME 类型 return send_file( BytesIO(encoded_img.tobytes()), mimetype='image/jpeg', as_attachment=False, download_name='processed.jpg' ) except Exception as e: return jsonify({"error": f"Processing failed: {str(e)}"}), 500

参数说明与边界处理:

  • cv2.imdecode()直接从内存np.frombuffer()解析,避免写临时文件,提升速度且安全(无路径遍历风险);
  • cv2.IMWRITE_JPEG_QUALITY设为 95 是经验阈值:低于 85 会出现明显块状伪影,高于 95 体积增长快但人眼难辨;
  • send_file()的as_attachment=False表示浏览器内联显示图片,而非弹出下载框;download_name是建议保存的文件名,不影响传输;
  • 所有异常统一捕获并返回 JSON 错误,保证前端catch分支能稳定处理。

3.3 依赖安装与环境隔离:用 requirements.txt 锁定 OpenCV 版本

# requirements.txt Flask==2.3.3 opencv-python-headless==4.8.1.78 # 注意:使用 headless 版本,避免 GUI 依赖(如 tkinter、gtk)引发 Linux 服务器部署失败
# 创建虚拟环境(强烈推荐,避免污染全局 Python) $ python -m venv webui_env $ source webui_env/bin/activate # Linux/macOS # webui_env\Scripts\activate # Windows # 安装依赖 $ pip install -r requirements.txt # 验证 OpenCV 是否可用 $ python -c "import cv2; print(cv2.__version__)" 4.8.1.78

为什么不用pip install opencv-python?
普通版opencv-python会安装 GUI 模块(如cv2.imshow()),在无桌面环境的服务器或 Docker 容器中会因缺少 X11 库而报错libGL.so.1: cannot open shared object file。headless版本剥离了所有 GUI 依赖,仅保留图像处理核心,是 WebUI 部署的黄金标准。


4. 避坑:WebUI 开发中五个高频翻车现场,附现象、根因与可抄作业的修复方案

WebUI 看似简单,实则处处是坑。这些不是理论陷阱,而是我在线上环境被反复毒打后记下的血泪经验。每一条都对应一次线上故障、一次用户投诉、一次凌晨三点的紧急回滚。

4.1 现象:前端 fetch 报错TypeError: Failed to fetch,控制台无详细信息

原因:浏览器静默拦截了跨域请求,但未在 DevTools Network 面板显示具体错误(尤其在file://协议下)。根本原因是前端页面协议(file)与后端服务协议(http)不一致,触发 CORS 策略。
解决:

  • 开发阶段:绝对禁止双击打开index.html。改用python -m http.server 8000启动静态服务器,访问http://localhost:8000/index.html;
  • 生产部署:在 Flask 中集成flask-cors,显式允许来源:
    from flask_cors import CORS CORS(app, origins=["http://localhost:3000", "http://192.168.1.100:8080"]) # 指定可信域名

4.2 现象:上传 JPG 后后端request.files为空,request.form却有数据

原因:前端fetch()错误地设置了Content-Type: application/json,导致浏览器将FormData当作普通 JSON 发送,后端无法识别 multipart 边界。
解决:

  • 删除 fetch 配置中的headers字段,让浏览器自动设置Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...;
  • 若必须加 header(如认证 token),只能加Authorization等非Content-Type字段。

4.3 现象:OpenCV 处理大图时 Python 进程崩溃,日志显示Killed

原因:Linux 系统 OOM Killer 检测到进程内存超限(如 4GB),强制终止。OpenCV 解码 20MP 图片可能瞬时占用 1.5GB 内存,叠加 Python GC 延迟,极易触发。
解决:

  • 在app.py开头添加内存限制(需psutil):
    import psutil import os def limit_memory(max_mb=2048): process = psutil.Process(os.getpid()) process.rlimit(psutil.RLIMIT_AS, (max_mb * 1024 * 1024, -1)) limit_memory(2048) # 限制虚拟内存 2GB
  • 或更稳妥:上传前在前端用 Canvas 压缩尺寸(canvas.toBlob()),再传给后端。

4.4 现象:处理后的图片在浏览器显示为损坏图标,Network 面板显示200 OK但响应体为空

原因:后端send_file()返回的BytesIO对象被多次读取。BytesIO是流对象,tobytes()调用后内部指针移到末尾,再次读取返回空字节。
解决:

  • 每次send_file()必须传入新创建的BytesIO实例:
    # ✅ 正确:每次新建 return send_file(BytesIO(encoded_img.tobytes()), mimetype='image/jpeg') # ❌ 错误:复用同一对象 bio = BytesIO(encoded_img.tobytes()) return send_file(bio, mimetype='image/jpeg') # 第二次调用会失败

4.5 现象:修改app.py后刷新页面,返回旧版本结果,疑似缓存

原因:Flask 开发模式下debug=True会启用自动重载,但前端 HTML/CSS/JS 文件仍被浏览器强缓存(尤其是index.html的Cache-Control: max-age=31536000)。
解决:

  • 开发阶段,在 HTML<head>中强制禁用缓存:
    <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> <meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Expires" content="0" />
  • 或更彻底:在app.py中为静态文件加版本号(如index.html?v=1.0.1),每次修改手动更新。

5. WebUI 的呼吸感:用 SSE 实现服务端推送,让「处理中」状态不再靠轮询猜

轮询(Polling)是新手最常用的实时反馈方案:前端每隔 1 秒发一次GET /status,后端返回{ "progress": 65, "status": "processing" }。但它有硬伤:大量无效请求压垮服务、延迟不可控、状态更新不及时。真正的工业级 WebUI 用Server-Sent Events(SSE)——单条长连接,服务端想推就推,浏览器原生支持,零依赖。

5.1 后端实现:Flask 流式响应,按需推送 JSON 片段

# 在 app.py 中追加 SSE 路由 from flask import Response, stream_with_context import time import json import threading # 模拟一个全局任务状态存储(实际项目用 Redis 或数据库) task_status = {} @app.route('/sse/<task_id>') def sse_stream(task_id): def event_stream(): # 1. 设置响应头,声明为 text/event-stream yield "event: connect\n" yield f"data: {json.dumps({'status': 'connected', 'task_id': task_id})}\n\n" # 2. 模拟后台任务执行(真实场景中,此逻辑应在独立线程或 Celery 中运行) def background_task(): total_steps = 10 for i in range(1, total_steps + 1): time.sleep(0.5) # 模拟每步耗时 progress = int((i / total_steps) * 100) status_msg = f"Step {i}/{total_steps}" # 3. 更新全局状态(供其他接口查询) task_status[task_id] = { "progress": progress, "status": "processing", "message": status_msg } # 4. 推送进度事件(浏览器监听 'progress' 事件) yield f"event: progress\n" yield f"data: {json.dumps({'progress': progress, 'message': status_msg})}\n\n" # 5. 任务完成,推送最终事件 task_status[task_id] = { "progress": 100, "status": "completed", "message": "All done!" } yield f"event: complete\n" yield f"data: {json.dumps({'progress': 100, 'message': 'All done!'})}\n\n" # 6. 用生成器包装,确保流式输出 return stream_with_context(background_task()) # 7. 返回 Response,指定 MIME 类型 return Response(event_stream(), mimetype='text/event-stream')

关键参数说明:

  • event: progress定义事件类型,前端用addEventListener('progress', ...)监听,避免所有消息混在一起;
  • 每个事件块以\n\n结尾,这是 SSE 协议强制要求;
  • stream_with_context()是 Flask 专用装饰器,确保生成器在请求生命周期内持续运行,不会因上下文丢失中断;
  • task_status字典模拟状态中心,真实项目必须替换为 Redis(支持多进程共享)。

5.2 前端监听:用 EventSource 建立长连接,优雅处理断连与重连

<!-- 在 index.html 中追加 SSE 监听逻辑 --> <div class="form-group"> <button id="startSseBtn">启动 SSE 任务</button> <div id="sseStatus" style="margin-top: 0.5rem; font-size: 0.9em;"></div> <div id="sseProgress" style="margin-top: 0.5rem; height: 8px; background: #e9ecef; border-radius: 4px; overflow: hidden;"> <div id="sseProgressFill" style="height: 100%; background: #007bff; width: 0%; transition: width 0.3s ease;"></div> </div> </div> <script> let eventSource = null; let currentTaskId = null; document.getElementById('startSseBtn').addEventListener('click', function() { // 1. 生成唯一任务 ID(实际项目可由后端生成) currentTaskId = 'task_' + Date.now(); // 2. 创建 EventSource 连接 eventSource = new EventSource(`/sse/${currentTaskId}`); // 3. 监听连接建立事件 eventSource.addEventListener('connect', function(event) { document.getElementById('sseStatus').textContent = '✅ 已连接,任务启动中...'; document.getElementById('startSseBtn').disabled = true; }); // 4. 监听进度事件 eventSource.addEventListener('progress', function(event) { const data = JSON.parse(event.data); document.getElementById('sseStatus').textContent = `⏳ ${data.message} (${data.progress}%)`; document.getElementById('sseProgressFill').style.width = `${data.progress}%`; }); // 5. 监听完成事件 eventSource.addEventListener('complete', function(event) { const data = JSON.parse(event.data); document.getElementById('sseStatus').textContent = `✅ ${data.message}`; document.getElementById('sseProgressFill').style.width = '100%'; eventSource.close(); // 主动关闭连接 document.getElementById('startSseBtn').disabled = false; }); // 6. 监听错误(网络断开、服务宕机) eventSource.addEventListener('error', function(event) { console.error('SSE 连接错误:', event); document.getElementById('sseStatus').textContent = '❌ 连接中断,正在重试...'; // 自动重连(EventSource 默认重连,但可自定义策略) if (eventSource && eventSource.readyState === EventSource.CLOSED) { setTimeout(() => { if (!eventSource || eventSource.readyState === EventSource.CLOSED) { eventSource = new EventSource(`/sse/${currentTaskId}`); } }, 3000); } }); }); </script>

为什么不用 WebSocket?
WebSocket 是双向全双工,但 WebUI 场景中 95% 的需求是「服务端推状态,前端只管收」。SSE 更轻量:基于 HTTP,天然支持跨域、代理、CDN;浏览器兼容性更好(IE 除外);服务端实现更简单(无握手、无帧解析)。除非你需要前端主动发指令(如暂停任务),否则 SSE 是更优解。

5.3 验证与压测:用 curl 模拟 SSE 连接,确认流式输出格式

# 在终端执行,观察实时推送(Ctrl+C 中断) $ curl -N http://localhost:5000/sse/task_1718234567 event: connect data: {"status": "connected", "task_id": "task_1718234567"} event: progress data: {"progress": 10, "message": "Step 1/10"} event: progress data: {"progress": 20, "message": "Step 2/10"} # ... 持续输出

格式验证要点:

  • 每行以event:或data:开头,后跟冒号和空格;
  • data:行可有多行,以空行结束;
  • event:行定义事件类型,data:行是有效载荷;
  • 空行分隔不同事件块。符合此格式,浏览器EventSource才能正确解析。

从那以后我每次写 WebUI,只要涉及「长时间运行任务的状态反馈」,都强制走一遍 SSE 方案——哪怕只是模拟 3 秒延时,也要把event: progress和event: complete的骨架先搭出来。因为轮询是技术债,而 SSE 是一次写对、长期省心的基础设施。它不炫技,但能让用户清楚知道「系统没卡死,它正在努力」,这种确定性,比任何 fancy 动画都重要。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进