不用上传服务器!用 Vue3 + ONNX Runtime Web 在浏览器本地实现智能抠图

前言

做在线工具站这段时间,被问得最多的一个问题是:你的图片处理为什么不上传服务器?

起因其实很朴素。平时经常要压个图、转个格式、从 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。三个措施:

  1. createImageBitmap 处理后立刻 .close() 释放
  2. 超过阈值的图先降采样
  3. 并发数控制在 3,批次之间让出主线程

坑 3:主包体积失控

Element Plus 全量引入 + 各种处理库,主包一度飙到 400KB+。目前做了动态导入拆分,还在继续优化按需引入。

顺带一个低级错误:首页一张 1847×1847 的插画 PNG 有 764KB,一直没压缩。图片上线前一定要过一遍压缩工具,这个成本极低但很容易忘。

坑 4:SPA 的 SEO

单页应用最大的问题:爬虫拿到的是 JS 执行前的空壳 HTML,百度基本抓不到内页内容。

目前首页做了预渲染,内页在改成 SSG(静态站点生成)。如果一开始就打算做内容型站点,这块一定要提前规划,事后改造挺折腾的。

四、总结

做下来最大的体会是:浏览器现在能做的事,比大多数人以为的多得多。

AI 模型推理、图片编解码、文档解析,这些几年前必须走服务端的活,现在在浏览器里都能跑,而且体验更好、成本更低、隐私更有保障。

当然本地方案也有边界------重计算任务(比如大模型的 PDF 智能解析)还是得靠服务端,需要按场景权衡。

项目地址:https://tuyaya.top (38 款工具,免费,大部分功能无需注册)

如果你也在做类似的东西,欢迎在评论区交流踩坑经验。

相关推荐
一切皆是因缘际会15 分钟前
多时空虚拟中心—— 全域同源基座与脑机谐振应用架构
ai·系统架构·脑机接口·虚拟全球网络控制中心·元宇宙世界底层架构·原生多时空架构
月月大王的3D日记25 分钟前
Three.js 入门系列(9):从零搭一座“闹鬼小屋”
前端·javascript
TingTing30 分钟前
Vue3动态组件库建设
前端
2601_9622184740 分钟前
万象生鲜系统跨仓调拨算法助力生鲜企业供应链数字化协同运营
大数据·运维·微服务·云原生·架构
码上暴富1 小时前
Cursor / VS Code 自定义文件颜色
前端·vscode
效率工作实验室1 小时前
AI Agent开发平台有哪些?2026年平台对比
ai·agent开发平台·ai智能体开发平台·智能体平台对比·agent平台选型
开开心心就好1 小时前
电子教鞭工具支持画框写字插图片功能齐全
android·开发语言·前端·javascript·人工智能·pdf·html
2601_962218472 小时前
万象生鲜系统多仓协同同步技术支撑生鲜企业多仓模式数字化转型
大数据·运维·微服务·云原生·架构
mooooooooooye2 小时前
SSH 工具越用越乱,Xterminal 先清掉这三类失效连接
运维·ssh·github