纯Vibe零代码:一个纯前端证件照修改器的设计与实现
|
这篇文章记录的是「证件照修改器」的设计与实现过程。
线上版本已经发布:https://idphoto.hex.ac.cn/。纯 Vibe 零代码,欢迎体验。

它的核心原则很简单:图片只在用户浏览器中处理,不上传服务器;应用支持离线使用。
为什么做成纯前端 PWA
证件照工具天然涉及个人照片,隐私敏感度很高。如果后端参与处理,哪怕服务端不保存图片,用户仍然需要信任传输链路、服务器实现和日志策略。对一个只想快速改尺寸、换底色、压缩文件大小的用户来说,这种信任成本其实偏高。
另一方面,很多原生 App 或在线服务会把“换底色”“高清导出”“无水印保存”放到付费墙后面。工具本身并不复杂,但用户需要为一次临时处理安装 App、授权相册、注册账号,甚至充值会员。这和“临时、快速、私密地处理一张证件照”的需求并不匹配。
所以这个项目选择纯前端路线:
- 图片读取、裁剪、压缩、格式转换全部在浏览器中完成。
- 人像抠图使用浏览器端 MediaPipe 模型推理。
- PWA 缓存应用壳、WASM、模型文件,首次加载完成后可以离线继续使用。
- 没有后端 API,没有图片上传,也不需要账号。
这让工具的使用边界更清楚:用户打开页面后,浏览器就是完整运行环境。
产品能力
当前版本主要支持:
- 上传 JPG / PNG 图片。
- 生成 1 寸、2 寸或自定义尺寸图片。
- 支持像素尺寸,也支持毫米 + DPI 换算。
- 手动拖拽、缩放裁剪。
- 智能人像抠图换背景。
- 支持白、蓝、红、灰、自定义背景色。
- 支持透明背景 PNG。
- 支持 JPEG / PNG 导出。
- JPEG 可按最大 KB 做质量搜索压缩。
- 可离线使用。
- 支持简体中文、繁体中文、英语。
使用入口被设计在裁剪区域本身:用户打开页面后,点击中间的「上传图片」区域即可开始。
整体架构
项目采用 Vite + React + TypeScript,核心逻辑基本都集中在浏览器端。
flowchart LR
User["用户选择图片"] --> Browser["浏览器本地运行"]
Browser --> Canvas["Canvas 裁剪 / 重采样"]
Browser --> Segmenter["MediaPipe 人像分割"]
Segmenter --> Mask["生成前景 Mask"]
Canvas --> Compose["背景合成"]
Mask --> Compose
Compose --> Compress["JPEG 压缩 / PNG 输出"]
Compress --> Download["本地下载图片"]
SW["Service Worker"] --> AppCache["App Cache"]
SW --> ModelCache["Model Cache"]
AppCache --> Browser
ModelCache --> Segmenter
浏览器端分成几层:
- UI 层:React 管理尺寸、格式、背景模式、压缩参数、语言切换。
- 图像处理层:Canvas 负责裁剪、缩放、背景合成和导出。
- 人像分割层:MediaPipe Tasks Vision 在浏览器中加载 WASM 和模型文件。
- PWA 层:Service Worker 管理 App Shell 和模型资源缓存。
- 恢复层:如果旧 Service Worker 导致资源 MIME 异常,HTML 内联脚本会清理旧缓存并恢复页面。
图像处理流程
图片上传后,应用先用 createImageBitmap 读取图片,避免把图片发到任何服务器。裁剪区本质上是一个固定比例的视口,用户拖动和缩放时只更新偏移量和缩放比例。
导出时会把用户看到的裁剪区域映射回原图坐标:
- 根据目标尺寸比例确定裁剪框。
- 根据当前缩放、偏移计算原图采样区域。
- 使用 Canvas
drawImage重采样到目标宽高。 - 如果启用换背景,先生成前景图层,再用人像 mask 做
destination-in合成。 - 根据目标格式输出 PNG 或 JPEG。
JPEG 压缩使用二分搜索质量参数,尽量在不改变尺寸的情况下压到指定 KB 以下。PNG 是无损编码,浏览器原生能力有限,因此如果 PNG 超过目标大小,应用会明确提示,而不是偷偷改变用户指定的尺寸。
智能换背景
换背景是这个工具里最有挑战的部分。它不是简单取某个颜色做透明,而是使用浏览器端人像分割模型。
实现上使用 @mediapipe/tasks-vision:
- WASM 文件来自 npm 包。
- 人像分割模型放在
public/mediapipe/models。 - 首次使用时浏览器加载模型。
- 同一张图片会复用 mask,切换背景色或输出尺寸时不重复推理。
为了减少边缘残留,mask 会做几步处理:
- 优先选择 person / foreground 通道。
- 用阈值把低置信度背景剔除。
- 根据「边缘净化」强度做轻微收缩。
- 再做羽化,避免边缘过硬。
这个方案不需要后端,也不依赖云端 AI 服务。缺点是模型大小和首次缓存成本都在浏览器端承担,所以 PWA 缓存设计很重要。
PWA 缓存设计
最初版本把 App Shell、JS/CSS、WASM、模型都放进一个缓存里。这样可以工作,但有两个问题:
- 每次 UI 发版都会导致模型资源跟着重新缓存。
- 旧版本 Service Worker 如果处理不当,可能把 HTML 兜底响应返回给 JS 模块,触发 MIME 错误。
现在缓存被拆成两组:
id-photo-app-v10:HTML、JS、CSS、manifest、icon。id-photo-model-v1:MediaPipe WASM 和模型文件。
这样 UI 发版只更新 app cache;模型不变时不会反复下载 30MB 级别资源。
Service Worker 对不同请求采用不同策略:
- 页面导航:network-first,失败后返回缓存的
index.html。 - JS / CSS / WASM:cache-first,但必须校验 MIME 类型。
- MediaPipe 模型资源:独立模型缓存,避免被 UI 发版清理。
同时启用了 navigationPreload,让页面导航请求和 Service Worker 启动并行,降低冷启动时的等待。
解决白屏和旧缓存问题
PWA 项目很容易遇到一个经典问题:发布新版本后,旧的 index.html 还引用旧 hash 的 JS 文件,而服务器已经只有新的 hash 文件。某些开发服务器或 fallback 规则会把不存在的 JS 请求返回成 index.html,浏览器就会报:
Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html"
这个项目做了两层保护:
- Service Worker 不会再把 HTML 返回给 JS / CSS / WASM 请求。
- HTML 内联恢复脚本会监听 module script 加载失败,自动注销旧 Service Worker、删除旧缓存,并刷新到恢复地址。
这样用户不需要打开 DevTools 清缓存,应用可以自我恢复。
首屏与一屏布局
作为 PWA,它更像一个工具应用,而不是普通网页。因此布局目标是:桌面端尽量一屏完成主要操作。
当前布局采用:
- Header:LOGO、名称、描述、语言切换。
- 左侧:尺寸、单位、格式、背景、压缩参数。
- 中间:上传 / 裁剪区域。
- 右侧:输出预览和下载。
- Footer:技术支持和源码入口。
桌面端固定 100vh,页面整体不滚动,左右面板内部滚动。裁剪区高度受容器限制,不再跟随浏览器高度无限变大。移动端则保留自然滚动,保证小屏可操作。
构建与资源同步
MediaPipe 的 WASM 文件来自 npm 包,而模型文件放在 public 目录。为了避免手动复制资源导致 CI/CD 漏文件,项目增加了构建前资源同步脚本:
npm run sync:assets
构建命令会自动执行它:
npm run build
脚本会做两件事:
- 从
node_modules/@mediapipe/tasks-vision/wasm同步 WASM 文件到public/mediapipe/wasm。 - 检查
selfie_segmenter.tflite模型是否存在且大小合理。
这样换机器、CI/CD 构建、重新安装依赖后都能更稳。
如何使用
线上地址:
使用步骤:
- 打开页面。
- 点击中间「上传图片」区域。
- 选择 1 寸、2 寸或自定义尺寸。
- 必要时切换像素 / 毫米 + DPI。
- 拖动照片调整裁剪位置,使用缩放滑杆调整主体大小。
- 选择背景:原背景、智能换色或透明。
- 设置输出格式和最大 KB。
- 点击「生成图片」。
- 检查预览后下载。
所有处理都在浏览器中完成。首次打开时应用会缓存离线资源,完成后即使断网也可以继续使用。
小结
这个证件照修改器表面上是一个图片工具,底层其实是一个完整的浏览器端应用实践:
- 用 Canvas 完成图像处理。
- 用 MediaPipe 在浏览器中做人像分割。
- 用 PWA 让工具离线可用。
- 用缓存拆分降低大模型资源的重复下载。
- 用 MIME 校验和恢复脚本处理 PWA 常见白屏问题。
- 用一屏式布局让它更像工具,而不是网页。
对于隐私敏感、小而完整的工具型应用,纯前端 PWA 是一个很值得尝试的方向。
该文章由 ChatGPT 5.5 生成,人工做了细微调整。