前言
做在线工具站这段时间,被问得最多的一个问题是:你的图片处理为什么不上传服务器?
起因其实很朴素。平时经常要压个图、转个格式、从 PDF 里抠个表格,每次都要临时找网站。有的要注册,有的限制次数,有的传上去要等半天------更关键的是,你永远不知道自己的文件会不会就留在别人服务器上了。
但这些事技术上并不难,为什么非得经过别人的服务器?
于是自己做了一个在线工具箱(38 款工具),核心设计原则只有一条:能在浏览器本地跑完的,绝不上传。
目前 38 款工具里,37 款的处理逻辑完全在浏览器本地,文件不离开用户设备。唯一例外是 PDF 智能解析(要跑 AI 识别,只能走服务端)。
这篇文章记录几个核心功能的实现方案和踩过的坑,代码都可以直接复用。
一、整体架构
text
Vue 3 + Vite
├── 图片处理 → Canvas / createImageBitmap
├── AI 抠图 → @imgly/background-removal(底层 ONNX Runtime Web)
├── 格式转换 → Canvas + HEIC 解码库(按需加载)
├── 批量打包 → JSZip
├── 文档处理 → pdf-lib / mammoth / SheetJS
└── 缓存加速 → Service Worker(PWA)
选本地处理主要考虑三点:
- 隐私:文件不落服务器,也就没有泄露和留存的问题
- 速度:省掉上传下载的来回,小文件秒级完成
- 成本:计算全在用户机器上,服务器几乎零负载
二、核心实现
2.1 浏览器本地跑 AI 抠图
智能抠图和证件照换底色用的是 @imgly/background-removal,底层是 ONNX Runtime Web,模型在浏览器里通过 WASM 做本地推理。
javascript
import { removeBackground, preload } from '@imgly/background-removal'
// 组件挂载后,利用空闲时间预热模型
onMounted(() => {
const idle = window.requestIdleCallback || (cb => setTimeout(cb, 2000))
idle(() => {
preload({ model: 'medium' }).catch(() => {
// 预热失败不阻塞主流程,用户真用到时再加载
})
})
})
// 执行抠图
async function handleRemoveBackground(file, onProgress) {
const blob = await removeBackground(file, {
model: 'medium', // small / medium / large
output: { format: 'image/png' },
progress: (key, current, total) => {
onProgress?.(Math.round((current / total) * 100))
}
})
return blob
}
模型会自动下载并缓存在 IndexedDB 里,第二次用就不用重新下了。
2.2 用 Service Worker 预热模型缓存
这是整个项目里效果最明显的一个优化。
模型文件加起来有十几 MB,如果用户点了"智能抠图"才开始下载,会盯着进度条等半天,体验很差。既然体积躲不掉,那就把等待挪到用户还没感知到的时候------用 SW 在后台悄悄预热。
javascript
// sw.js
const MODEL_CACHE = 'imgly-models-v1'
const MODEL_BASE = 'https://staticimgly.com/@imgly/background-removal-data/1.7.0/dist/'
self.addEventListener('install', (event) => {
event.waitUntil(preloadModels())
})
async function preloadModels() {
try {
const cache = await caches.open(MODEL_CACHE)
// 先拿资源清单,再把模型文件逐个缓存
const res = await fetch(`${MODEL_BASE}resources.json`)
const { models } = await res.json()
const urls = Object.values(models)
.flatMap(m => Object.values(m))
.map(path => `${MODEL_BASE}${path}`)
await cache.addAll(urls)
console.log('[SW] 模型缓存完成', urls.length, '个文件')
} catch (e) {
// 预热失败不能影响主流程
console.warn('[SW] 模型预热失败', e)
}
}
// 命中缓存优先,没命中再走网络
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('staticimgly.com')) {
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
)
}
})
配合主进程里的 preload(),用户真正点开抠图页面时,模型大概率已经在缓存里了。
2.3 Canvas 图片压缩
图片压缩、格式转换、裁剪这些都基于 Canvas,核心是一个通用的处理函数:
javascript
export async function compressImage(file, { quality = 0.8, maxWidth = 1920 } = {}) {
const bitmap = await createImageBitmap(file)
// 大图先降采样,避免 canvas 过大导致内存暴涨
const scale = Math.min(1, maxWidth / bitmap.width)
const canvas = document.createElement('canvas')
canvas.width = Math.round(bitmap.width * scale)
canvas.height = Math.round(bitmap.height * scale)
const ctx = canvas.getContext('2d')
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
bitmap.close() // 关键:及时释放,否则大图会一直占内存
return new Promise((resolve) => {
canvas.toBlob(resolve, 'image/jpeg', quality)
})
}
注意 bitmap.close() 这行,后面会讲到它有多重要。
2.4 批量处理 + JSZip 打包
支持最多 20 张批量处理,处理完打包成 zip 下载。
这里踩了个坑 :一开始图省事直接 Promise.all(files.map(process)),结果 20 张大图同时进 Canvas,低端设备直接内存崩溃、页面卡死。改成并发控制 + 分批处理才稳住:
javascript
import JSZip from 'jszip'
async function batchProcessAndZip(files, processFn, onProgress) {
const zip = new JSZip()
const CONCURRENCY = 3 // 同时只处理 3 张
for (let i = 0; i < files.length; i += CONCURRENCY) {
const batch = files.slice(i, i + CONCURRENCY)
const results = await Promise.all(
batch.map(async (file) => {
const blob = await processFn(file)
return { name: file.name, blob }
})
)
results.forEach(r => zip.file(r.name, r.blob))
// 让出主线程,避免长任务阻塞 UI
await new Promise(r => setTimeout(r, 0))
onProgress?.(Math.min(i + CONCURRENCY, files.length), files.length)
}
return zip.generateAsync({ type: 'blob' })
}
并发数控制在 3,既能利用多核,又不会把内存打满。setTimeout(r, 0) 是让出主线程,防止批量处理时页面假死。
2.5 HEIC 格式支持
格式转换支持 JPG / PNG / WebP / BMP / HEIC / TIFF / ICO 七种。HEIC 是 iPhone 拍的照片格式,浏览器原生不支持,需要单独的解码库,体积不小,做了动态导入:
javascript
async function decodeHeic(file) {
// 按需加载,不进主包
const heic2any = (await import('heic2any')).default
return heic2any({ blob: file, toType: 'image/jpeg', quality: 0.9 })
}
三、踩过的坑
坑 1:模型体积导致的首屏等待
前面说了,靠 SW 预热 + preload() 解决。另外一定要加进度提示------同样是等 10 秒,有进度条和没进度条,用户感受完全不同。
坑 2:大图处理内存爆炸
20 张 4000×3000 的照片同时进 Canvas,内存轻松上 G。三个措施:
createImageBitmap处理后立刻.close()释放- 超过阈值的图先降采样
- 并发数控制在 3,批次之间让出主线程
坑 3:主包体积失控
Element Plus 全量引入 + 各种处理库,主包一度飙到 400KB+。目前做了动态导入拆分,还在继续优化按需引入。
顺带一个低级错误:首页一张 1847×1847 的插画 PNG 有 764KB,一直没压缩。图片上线前一定要过一遍压缩工具,这个成本极低但很容易忘。
坑 4:SPA 的 SEO
单页应用最大的问题:爬虫拿到的是 JS 执行前的空壳 HTML,百度基本抓不到内页内容。
目前首页做了预渲染,内页在改成 SSG(静态站点生成)。如果一开始就打算做内容型站点,这块一定要提前规划,事后改造挺折腾的。
四、总结
做下来最大的体会是:浏览器现在能做的事,比大多数人以为的多得多。
AI 模型推理、图片编解码、文档解析,这些几年前必须走服务端的活,现在在浏览器里都能跑,而且体验更好、成本更低、隐私更有保障。
当然本地方案也有边界------重计算任务(比如大模型的 PDF 智能解析)还是得靠服务端,需要按场景权衡。
项目地址:https://tuyaya.top (38 款工具,免费,大部分功能无需注册)
如果你也在做类似的东西,欢迎在评论区交流踩坑经验。