Vue3 + Flask 全栈实战:从零构建在线二维码解析器
上篇讲了图像预处理提升二维码识别率的底层原理,这篇把整个流程搬上 Web——用 Vue 3 + Flask 搭一个前后端分离的在线二维码解析工具。支持拖拽上传和 URL 输入两种方式,外加 Redis 缓存和 API 限流。
最终效果 一个单页面 Web 应用:
拖拽/点击上传二维码图片,或输入图片 URL
点击解析,后端跑 10 种图像预处理管线,返回识别结果
支持一键复制、文件预览、拖拽视觉反馈
紫色渐变毛玻璃 UI,移动端适配
完整代码:qrCodeAnalyzerWeb
技术栈选型
层
技术
版本
为什么选它
后端框架
Flask
3.0
轻量,适合中小型 API 服务
API 文档
Flask-RESTX
1.3
自动生成 Swagger UI
限流
Flask-Limiter
3.5
防止 API 被刷
缓存
Redis
7.x
缓存解码结果,1 小时过期
解码引擎
pyzxing
1.1
Java ZXing 的 Python 封装
图像处理
Pillow
12.2
10 种预处理管线
前端框架
Vue 3
3.5
Composition API + TypeScript
构建工具
Vite
6.3
极快的 HMR,原生 ESM
部署
Docker + Gunicorn
—
生产环境标准化
项目结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 qrCodeAnalyzerWeb/ ├── backend/ │ ├── app.py # Flask 应用工厂 + 蓝图注册 │ ├── config.py # 多环境配置(开发/生产) │ ├── requirements.txt │ ├── Dockerfile │ ├── docker-compose.yml # web + redis │ └── src/ │ ├── routes/qrcode.py # API 路由(/api/decode, /api/decode_url) │ ├── services/decoder.py # QRCodeDecoder:三层解码策略 │ ├── schemas/response.py # APIResponse 统一响应格式 │ └── utils/ │ ├── image.py # 10 种图像预处理 │ ├── cache.py # Redis 缓存(MD5 键) │ ├── security.py # 文件校验 + 路径安全 │ ├── url.py # URL 下载 │ └── logger.py # colorlog 彩色日志 │ └── frontend/ ├── vite.config.ts # Vite 代理 /api → localhost:5000 ├── package.json └── src/ ├── App.vue # 根组件 ├── style.css # 全局样式:渐变背景、CSS 变量、毛玻璃 └── components/ └── QRCodeDecoder.vue # 核心业务组件(单文件)
一、后端:Flask 应用工厂 + 蓝图架构 1.1 为什么用工厂函数而不是直接 app = Flask(__name__)? 工厂函数让配置和扩展初始化与 app 实例解耦。测试时可以传入不同的 config,生产环境可以换限流后端(开发用内存、生产用 Redis):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 from flask import Flaskfrom flask_cors import CORSfrom flask_limiter import Limiterfrom flask_restx import Apidef create_app (config_name='default' ): app = Flask(__name__) app.config.from_object(config[config_name]) CORS(app) cache_manager = CacheManager(app.config['REDIS_URL' ]) if app.config['DEBUG' ]: limiter = Limiter(app, default_limits=["100 per hour" ], storage_uri="memory://" ) else : limiter = Limiter(app, default_limits=["100 per hour" ], storage_uri=app.config['REDIS_URL' ]) api = Api(app, doc='/swagger/' , title='QR Code Decoder API' ) from src.routes.qrcode import qrcode_bp app.register_blueprint(qrcode_bp, url_prefix='/api' ) return app
1.2 多环境配置 1 2 3 4 5 6 7 8 9 10 11 12 class Config : SECRET_KEY = os.environ.get('SECRET_KEY' , 'dev-secret-key' ) MAX_CONTENT_LENGTH = 16 * 1024 * 1024 ALLOWED_EXTENSIONS = {'png' , 'jpg' , 'jpeg' , 'gif' , 'bmp' , 'webp' } REDIS_URL = os.environ.get('REDIS_URL' , 'redis://localhost:6379/0' ) class DevelopmentConfig (Config ): DEBUG = True class ProductionConfig (Config ): DEBUG = False
1.3 API 路由设计 把二维码解析拆成两个端点——一个处理上传文件,一个处理 URL:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 from flask import Blueprint, requestfrom src.services.decoder import QRCodeDecoderfrom src.schemas.response import APIResponsefrom src.utils.cache import CacheManagerfrom src.utils.security import allowed_fileimport hashlibqrcode_bp = Blueprint('qrcode' , __name__) decoder = QRCodeDecoder() @qrcode_bp.route('/decode' , methods=['POST' ] ) def decode_qr_endpoint (): if 'file' not in request.files: return APIResponse.error_response('请上传图片文件' , 400 ).to_dict() file = request.files['file' ] if not allowed_file(file.filename): return APIResponse.error_response('不支持的文件格式' , 400 ).to_dict() file_bytes = file.read() cache_key = hashlib.md5(file_bytes).hexdigest() cached = cache_manager.get(cache_key) if cached: return APIResponse.success_response(cached).to_dict() result = decoder.decode_from_bytes(file_bytes) if result: cache_manager.set (cache_key, result) return APIResponse.success_response(result).to_dict() else : return APIResponse.error_response('无法解析二维码' , 400 ).to_dict() @qrcode_bp.route('/decode_url' , methods=['POST' ] ) def decode_qr_from_url (): data = request.get_json() url = data.get('url' , '' ) cache_key = hashlib.md5(url.encode()).hexdigest() cached = cache_manager.get(cache_key) if cached: return APIResponse.success_response(cached).to_dict() result = decoder.decode_from_url(url) if result: cache_manager.set (cache_key, result) return APIResponse.success_response(result).to_dict() return APIResponse.error_response('解析失败' , 400 ).to_dict()
二、解码引擎:三层策略管线 这是整个项目的核心。QRCodeDecoder 实现了从简单到复杂的三层解码策略:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 第 1 层:标准解码(pyzxing 直出) └── 失败 → 进入第 2 层 第 2 层:10 种图像预处理(依次尝试,命中即返回) ├── 原始图像 ├── 灰度化 ├── 高对比度(enhance 2.0) ├── 二值化(阈值 128) ├── 二值化(阈值 64) ├── 二值化(阈值 192) ├── 高斯模糊 + 二值化 ├── 锐化 ├── 颜色反转 └── 尺寸放大 2 倍 └── 全部失败 → 进入第 3 层 第 3 层:兜底策略 ├── RGBA 模式转换 └── 四角裁剪(左上/右上/左下/右下分别尝试)
代码实现(精简版):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 from pyzxing import BarCodeReaderfrom src.utils.image import preprocess_imageimport uuid, osclass QRCodeDecoder : def __init__ (self ): self .reader = BarCodeReader() def decode_full (self, image_path ): img = Image.open (image_path) result = self .reader.decode(image_path) if result: return self ._format_result(result) for name, processed_img in preprocess_image(img): temp = f"temp_{uuid.uuid4().hex [:8 ]} .png" try : processed_img.save(temp) result = self .reader.decode(temp) if result: return self ._format_result(result) finally : if os.path.exists(temp): os.remove(temp) if img.mode != 'RGBA' : rgba = img.convert('RGBA' ) temp = f"temp_{uuid.uuid4().hex [:8 ]} .png" try : rgba.save(temp) result = self .reader.decode(temp) if result: return self ._format_result(result) finally : if os.path.exists(temp): os.remove(temp) w, h = img.size for box in [(0 ,0 ,w//2 ,h//2 ), (w//2 ,0 ,w,h//2 ), (0 ,h//2 ,w//2 ,h), (w//2 ,h//2 ,w,h)]: cropped = img.crop(box) temp = f"temp_{uuid.uuid4().hex [:8 ]} .png" try : cropped.save(temp) result = self .reader.decode(temp) if result: return self ._format_result(result) finally : if os.path.exists(temp): os.remove(temp) return None def _format_result (self, result ): raw = result[0 ].get('raw' , '' ) btype = result[0 ].get('format' , 'QRCODE' ) for enc in ['utf-8' , 'gbk' , 'gb2312' ]: try : decoded = raw.decode(enc) if isinstance (raw, bytes ) else raw return {'result' : decoded, 'type' : btype} except (UnicodeDecodeError, AttributeError): continue return {'result' : raw.hex (), 'type' : btype}
关键设计 :临时文件用 uuid4().hex[:8] 命名并在 finally 块中确保删除,杜绝文件泄漏。
三、统一响应格式 前后端通信最重要的约定就是响应格式。用 Python 泛型数据类定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 from dataclasses import dataclass, asdictfrom typing import Optional , Generic , TypeVarT = TypeVar('T' ) @dataclass class APIResponse (Generic [T]): success: bool data: Optional [T] = None error: Optional [str ] = None code: int = 200 @classmethod def success_response (cls, data, code=200 ): return cls(success=True , data=data, code=code) @classmethod def error_response (cls, error, code=400 ): return cls(success=False , error=error, code=code) def to_dict (self ): return asdict(self )
无论成功还是失败,前端收到的都是同一个结构:
1 2 3 4 5 { "success" : true , "data" : { "result" : "https://..." , "type" : "QRCODE" } , "error" : null , "code" : 200 } { "success" : false , "data" : null , "error" : "无法解析二维码" , "code" : 400 }
前端只需判断 data.success,不需要区分 HTTP 状态码。
四、Redis 缓存:减少重复计算 同一张图片可能被多次上传。对图片字节做 MD5 哈希作为缓存键,1 小时过期:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import redisimport hashlibimport jsonfrom datetime import timedeltaclass CacheManager : def __init__ (self, redis_url ): try : self .client = redis.from_url(redis_url) self .client.ping() except Exception: self .client = None def get (self, key ): if not self .client: return None data = self .client.get(key) return json.loads(data) if data else None def set (self, key, value, expire=timedelta(hours=1 ) ): if not self .client: return False return self .client.setex(key, expire, json.dumps(value))
优雅降级 :Redis 挂了不影响核心功能,只是缓存失效。
五、前端:Vue 3 Composition API + TypeScript 5.1 单组件架构 整个前端只有 一个业务组件 QRCodeDecoder.vue(约 300 行),包含上传、解析、结果展示三大块。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 <script setup lang="ts"> import { ref } from 'vue' interface DecodeResult { success: boolean data?: { result: string; type: string } error?: string code?: number } const selectedFile = ref<File | null>(null) const isLoading = ref(false) const result = ref<DecodeResult | null>(null) const isDragOver = ref(false) const copied = ref(false) async function decodeQRCode() { if (!selectedFile.value) return isLoading.value = true result.value = null const formData = new FormData() formData.append('file', selectedFile.value) try { const response = await fetch('/api/decode', { method: 'POST', body: formData, }) const data: DecodeResult = await response.json() result.value = data } catch (err) { result.value = { success: false, error: '网络请求失败' } } finally { isLoading.value = false } } function handleDrop(e: DragEvent) { e.preventDefault() isDragOver.value = false const file = e.dataTransfer?.files?.[0] if (file?.type.startsWith('image/')) { selectedFile.value = file } } </script>
5.2 Vite 代理配置 前端请求 /api/decode,Vite 开发服务器代理到 Flask 后端:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig ({ plugins : [vue ()], server : { proxy : { '/api' : { target : 'http://localhost:5000' , changeOrigin : true , } } } })
注意:这里不需要 rewrite,因为 Flask 蓝图注册时已经是 url_prefix='/api',路径天然匹配。
5.3 UI 设计:毛玻璃效果 1 2 3 4 5 6 7 8 .result-card { background : rgba (255 , 255 , 255 , 0.9 ); backdrop-filter : blur (12px ); border : 1px solid rgba (255 , 255 , 255 , 0.2 ); border-radius : 16px ; box-shadow : 0 8px 32px rgba (31 , 38 , 135 , 0.15 ); }
5.4 文件预览 选中文件后显示文件名和大小,可删除重新选择:
1 2 3 4 5 <div v-if="selectedFile" class="file-preview"> <span>{{ selectedFile.name }}</span> <span>{{ (selectedFile.size / 1024).toFixed(1) }} KB</span> <button @click="reset">✕</button> </div>
六、生产部署:Docker + Gunicorn 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 services: web: build: .. ports: - "5000:5000" environment: - FLASK_ENV=production - REDIS_URL=redis://redis:6379/0 depends_on: - redis redis: image: redis:7-alpine volumes: - redis-data:/data volumes: redis-data:
Gunicorn 启动命令:
1 gunicorn -w 4 -k gevent --bind 0.0.0.0:5000 "app:create_app('production')"
4 个 gevent worker,协程模式处理 I/O 密集型的图像处理任务。
七、从中学到的几个关键决策 7.1 为什么不用 Axios? 原生 fetch 在发送 FormData 时不需要额外配置,且在现代浏览器中支持率已经 97%+。对于一个单接口的单页应用,多引入一个依赖不值得。
7.2 为什么后端不做 WebSocket 推送? 二维码解析是同步操作(几百毫秒到一两秒),用常规 HTTP 请求-响应模型完全够用。WebSocket 的复杂度(心跳、重连、状态管理)在延迟收益为零的情况下纯属过度设计。
7.3 缓存粒度为什么是图片内容而不是文件名? 两个用户上传文件名不同但内容相同的图片(比如同一个二维码转发多次),缓存命中率更高。MD5(内容) > MD5(文件名)。
八、总结 这个项目麻雀虽小,五脏俱全:
后端 :工厂函数 → 蓝图 → 三层解码策略 → Redis 缓存 → 限流 → Swagger 文档
前端 :Composition API → TypeScript 类型约束 → Vite 代理 → 毛玻璃 UI
部署 :Docker Compose → Gunicorn gevent → Redis 持久化
从零搭起来大概 3-4 小时,但每一步的决策背后都有实际工程考量。