- 我按你给的账号 xh_ds / Aa12345678. 登录并实际走了一遍:新增设计 -> 倒数第二个 zmnsales0509 -> 添加素材图 -> 保存当前颜色 -> 同步全部颜色 -> 保存。结论很明确:
实测耗时
阶段 次数 实测
前端本地生成/排队/重着色/合成前准备 3 色 约 18.1s
Worker 合成 WebP 3 张 3.09s / 2.79s / 2.73s,合计 8.6s
/api/ecommerce/file/upload 上传效果图 3 次 4.10s / 3.97s / 4.20s,合计 12.27s
/api/ecommerce/finishedProductInfo/save 保存接口 1 次 0.26s
点击“保存”到保存接口返回 1 次 约 30.6s
所以:最终保存接口不慢,真正慢的是“前端串行合成流程 + 每张图上传接口”。
如果单看“Worker 合成 vs 上传接口”,上传接口更慢:每张 WebP 不到 1MB,上传仍然约 4s。但全流程里,前端本地生成/调度也占了更大一块,因为它是串行的。
是否多线程
不是多线程并行。现在只是用了单个 Web Worker 后台线程,不是 Worker 池。代码里 RenderWorkerManager 是单例,并且 activeTaskToken 保证一次只跑一个任务,队列串行派发:
worker-manager.ts (line 94)、worker-manager.ts (line 238)。
保存循环里也在 await compositeEffectImageByPositionWithWorker(…),等当前颜色合成+上传完再处理下一个:
template-designer.vue (line 13763)。
为什么改 WebP 仍没明显变快
WebP 只是降低了上传体积,本次 3 张分别约 929KB / 900KB / 830KB,但上传接口仍固定消耗约 4s/次。
合成前仍有大尺寸 PNG dataURL:3917 x 5840 的 toDataURL(‘image/png’) 每次生成约 20MB 字符串,3 次耗时约 1.6s,还会带来内存压力:
template-designer.vue (line 3069)。
3 个颜色是串行:合成一张 -> 上传一张 -> 再合成下一张 -> 再上传。没有“合成下一张时并行上传上一张”。
优化优先级
先查后端 /file/upload:小于 1MB 的 WebP 上传 4s 偏高,重点看后端是否在同步生成缩略图、转存 COS、鉴权/网关转发慢。可以做直传 COS、批量预签名上传,或生成效果图场景跳过不必要缩略图。
前端做流水线:把“合成”和“上传”解耦,合成第 2 张时上传第 1 张,限制并发 2,不要无限并发。
Worker 池而不是单 Worker:最多 2 个 Worker 较稳,因为单张画布约 391758404 ≈ 87MB 原始像素,开太多会爆内存。
去掉大 PNG dataURL 中转:把重着色底图改成 Blob/ImageBitmap/ArrayBuffer 传给 Worker,避免 20MB base64 字符串。
Worker 预热:当前第一次保存还要加载 worker 脚本约 446ms,可在进入设计器或添加图片后预创建 Worker。
最终保存接口不用优先动:本次只有 0.26s,不是瓶颈。
“最多 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 个。
- “去掉大 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”。
- 为什么 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。
- 更好的做法是什么?
不要这样:
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 友好很多。
- 如果要 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 个更安全。