前端串行合成流程 + 每张图上传接口

  1. 我按你给的账号 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 较稳,因为单张画布约 39175840 4 ≈ 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 个。

  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"。

  1. 为什么 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。
  1. 更好的做法是什么?

不要这样:

const dataUrl = canvas.toDataURL("image/png");

worker.postMessage({

image: dataUrl

});

改成传这些:

Blob

ImageBitmap

ArrayBuffer

推荐优先级:

ImageBitmap:适合传给 Worker 直接画到 OffscreenCanvas

ArrayBuffer:适合传原始二进制,可 transfer,避免复制

Blob:适合保存/传递图片文件数据,通常比 dataURL 轻

  1. 推荐方案:主线程把底图转成 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 友好很多。

  1. 如果要 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;

}

  1. 用 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 个更安全。

相关推荐
mldong32 分钟前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排1 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60612 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角3 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!3 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰3 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi5 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen5 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马6 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒6 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端