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

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

相关推荐
风骏时光牛马1 小时前
Swift 基于MVVM架构实现完整列表数据展示与交互功能实战案例
前端
就叫_这个吧2 小时前
JavaScript基础数据类型、运算符、数组、函数的定义及DOM方式应用
开发语言·前端·javascript
作业逆流成河2 小时前
别再一次性重构枚举了:如何把一个真实后台项目的状态字典,渐进式迁移到enum-plus?
前端·javascript·开源
暗不需求2 小时前
React 性能优化秘籍:深入理解 `useMemo` 与 `useCallback`
前端·react.js·面试
专注VB编程开发20年2 小时前
我制作excel工作簿的选项卡,发给deep seek, 昨天修改了一天
前端·vue.js·excel
light blue bird2 小时前
工序路径主子表单工序组装图表组件
前端·数据库·信息可视化·.net·web端·razor page
linlinlove22 小时前
前端uniapp、后端thinkphp股票系统开发功能展示、代码披露、HQChart
前端·uni-app·echarts·thinkphp·hqchart·配资·deepseek选股票
万少2 小时前
Claude Code 任务结束会自己喊你:一个 Stop Hook 搞定提示音
前端·后端·代码规范
ZC跨境爬虫2 小时前
跟着 MDN 学CSS day_30:(玩转列表样式,从基础到进阶)
前端·css·html·tensorflow·媒体