Worker 池而不是单 Worker:最多 2 个 Worker 较稳 2个会不会爆内存。 去掉大 PNG dataURL 中转 是什么意思,不是已经没有png 图片了吗?这些都仔细说明,把重着色底图改成 Blob/ImageBitmap/ArrayBuffer 传给 Worker,避免 20MB base64 字符串 对小白仔细说明,并列出代码
可以,逐个拆开说。
1. “最多 2 个 Worker”会不会爆内存?
有可能,但比“开很多 Worker”稳得多。关键不是 Worker 数量本身,而是每个 Worker 里同时拿着多大的图片数据。
一个 Worker 做重着色时,通常会占这些内存:
原图像素 RGBA 内存 = 宽 * 高 * 4 bytes 例如 3000 x 3000 = 9,000,000 * 4 ≈ 36MB
如果过程中还有:
原始图 ImageBitmap + OffscreenCanvas 像素缓冲 + getImageData 返回的 Uint8ClampedArray + 输出 Blob / ArrayBuffer
那单个任务可能临时吃掉几十 MB 到一两百 MB。
所以“最多 2 个 Worker”的意思是:
同时最多只让 2 张底图在后台处理 其他任务排队
不是创建无限 Worker,也不是每张图都新开 Worker。
更稳的策略:
const workerCount = Math.min( 2, navigator.hardwareConcurrency || 1 );
如果想更保守,低内存设备只开 1 个:
const memoryGB = navigator.deviceMemory || 4; const workerCount = memoryGB <= 4 ? 1 : Math.min(2, navigator.hardwareConcurrency || 1);
结论:
2 个 Worker 不一定爆内存,但要配合任务排队、复用 Worker、处理完释放大对象。移动端或低配机器建议自动降到 1 个。
2. “去掉大 PNG dataURL 中转”是什么意思?不是已经没有 PNG 图片了吗?
这里说的“PNG dataURL”不一定是你项目里有一个 .png 文件。
它可能是代码运行时临时生成的,比如:
const dataUrl = canvas.toDataURL("image/png");
这个东西长这样:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
也就是说:
Canvas / 图片 / SVG / 任意图像 ↓ 转成 PNG 编码 ↓ 再转成 base64 字符串 ↓ 作为 dataURL 传来传去
所以即使你“没有 PNG 文件”,只要代码里用了:
canvas.toDataURL("image/png")
或者类似逻辑,它还是会在内存里造出一个“PNG dataURL”。
3. 为什么 20MB base64 字符串很糟?
假设真正的图片二进制是 20MB。
base64 会膨胀大约 33%:
20MB 二进制 → 约 26.6MB base64 文本
但 JS 字符串通常还可能按 UTF-16 存储,大约 2 bytes 一个字符:
26.6MB 文本 → 可能接近 53MB JS 字符串内存
更糟的是,中间经常会产生多份拷贝:
canvas 像素内存 + PNG 编码结果 + base64 字符串 + postMessage 拷贝 + Worker 里再解码 + Worker 里的 canvas / imageData
所以你看到的“20MB base64 字符串”,实际峰值内存可能远不止 20MB。
4. 更好的做法是什么?
不要这样:
const dataUrl = canvas.toDataURL("image/png"); worker.postMessage({ image: dataUrl });
改成传这些:
Blob ImageBitmap ArrayBuffer
推荐优先级:
ImageBitmap:适合传给 Worker 直接画到 OffscreenCanvas ArrayBuffer:适合传原始二进制,可 transfer,避免复制 Blob:适合保存/传递图片文件数据,通常比 dataURL 轻
5. 推荐方案:主线程把底图转成 ImageBitmap,传给 Worker
主线程:
const worker = new Worker("/workers/recolor-worker.js", { type: "module" }); async function recolorBaseImage(imageUrl, color) { const response = await fetch(imageUrl); const blob = await response.blob(); const imageBitmap = await createImageBitmap(blob); return new Promise((resolve, reject) => { const id = crypto.randomUUID(); worker.onmessage = (event) => { const { id: resultId, blob, error } = event.data; if (resultId !== id) return; if (error) { reject(new Error(error)); return; } resolve(blob); }; worker.postMessage( { id, imageBitmap, color }, [imageBitmap] ); }); }
注意这一句:
worker.postMessage(message, [imageBitmap]);
第二个参数 [imageBitmap] 表示“转移所有权”。
简单理解:
不是复制一份图片给 Worker 而是把这块图像资源交给 Worker 主线程这边不再持有它
这对内存更友好。
Worker 里:
self.onmessage = async (event) => { const { id, imageBitmap, color } = event.data; try { const canvas = new OffscreenCanvas( imageBitmap.width, imageBitmap.height ); const ctx = canvas.getContext("2d", { willReadFrequently: true }); ctx.drawImage(imageBitmap, 0, 0); imageBitmap.close(); const imageData = ctx.getImageData( 0, 0, canvas.width, canvas.height ); const data = imageData.data; const target = hexToRgb(color); for (let i = 0; i < data.length; i += 4) { const alpha = data[i + 3]; if (alpha === 0) continue; const gray = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114; const strength = gray / 255; data[i] = target.r * strength; data[i + 1] = target.g * strength; data[i + 2] = target.b * strength; } ctx.putImageData(imageData, 0, 0); const blob = await canvas.convertToBlob({ type: "image/webp", quality: 0.92 }); self.postMessage({ id, blob }); } catch (error) { self.postMessage({ id, error: error.message }); } }; function hexToRgb(hex) { const value = hex.replace("#", ""); return { r: parseInt(value.slice(0, 2), 16), g: parseInt(value.slice(2, 4), 16), b: parseInt(value.slice(4, 6), 16) }; }
这里没有 toDataURL("image/png"),输出是:
canvas.convertToBlob(...)
这比 base64 dataURL 友好很多。
6. 如果要 Worker 池,代码长这样
主线程 Worker 池:
class WorkerPool { constructor(workerUrl, size) { this.workers = Array.from({ length: size }, () => ({ worker: new Worker(workerUrl, { type: "module" }), busy: false })); this.queue = []; } run(payload, transferList = []) { return new Promise((resolve, reject) => { this.queue.push({ payload, transferList, resolve, reject }); this.schedule(); }); } schedule() { const idle = this.workers.find((item) => !item.busy); const task = this.queue.shift(); if (!idle || !task) return; idle.busy = true; const id = crypto.randomUUID(); const handleMessage = (event) => { if (event.data.id !== id) return; idle.worker.removeEventListener("message", handleMessage); idle.worker.removeEventListener("error", handleError); idle.busy = false; if (event.data.error) { task.reject(new Error(event.data.error)); } else { task.resolve(event.data); } this.schedule(); }; const handleError = (error) => { idle.worker.removeEventListener("message", handleMessage); idle.worker.removeEventListener("error", handleError); idle.busy = false; task.reject(error); this.schedule(); }; idle.worker.addEventListener("message", handleMessage); idle.worker.addEventListener("error", handleError); idle.worker.postMessage( { id, ...task.payload }, task.transferList ); } }
使用:
const memoryGB = navigator.deviceMemory || 4; const workerCount = memoryGB <= 4 ? 1 : Math.min(2, navigator.hardwareConcurrency || 1); const recolorPool = new WorkerPool( "/workers/recolor-worker.js", workerCount ); async function recolor(imageUrl, color) { const response = await fetch(imageUrl); const blob = await response.blob(); const imageBitmap = await createImageBitmap(blob); const result = await recolorPool.run( { imageBitmap, color }, [imageBitmap] ); return result.blob; }
7. 用 ArrayBuffer 传也可以
主线程:
async function recolorByArrayBuffer(imageUrl, color) { const response = await fetch(imageUrl); const buffer = await response.arrayBuffer(); return recolorPool.run( { buffer, mimeType: response.headers.get("content-type") || "image/png", color }, [buffer] ); }
Worker:
self.onmessage = async (event) => { const { id, buffer, mimeType, color } = event.data; try { const blob = new Blob([buffer], { type: mimeType }); const imageBitmap = await createImageBitmap(blob); // 后面逻辑和前面的 ImageBitmap 版本一样 } catch (error) { self.postMessage({ id, error: error.message }); } };
ArrayBuffer 的好处是可以 transfer:
worker.postMessage({ buffer }, [buffer]);
这表示这块二进制内存从主线程转交给 Worker,避免复制。
一句话总结
“去掉大 PNG dataURL 中转”不是说项目里不能有 PNG 文件,而是说不要把图片通过 canvas.toDataURL("image/png") 变成巨大的 base64 字符串再传给 Worker。更稳的方式是:主线程拿到 Blob / ArrayBuffer / ImageBitmap,用 postMessage 的 transfer list 交给 Worker,Worker 里用 OffscreenCanvas 处理,最后用 convertToBlob() 输出。Worker 池最多 2 个一般是稳定上限,低内存设备自动降到 1 个更安全。