如何在 HTML5 Canvas 上绘制大图像时不挂几秒钟

How to not hang for a few seconds while drawing a large image on HTML5 Canvas

我正在 HTML5 Canvas 上绘制大图像(大小约为 20 MB)并从中创建一个小缩略图。我就是这样做的:

const img = new Image();
img.src = '20mb-image.jpg';
img.onload = () => {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    ctx.canvas.width = 240;
    ctx.canvas.height = 240;
    ctx.drawImage(img, 0, 0, 240, 240);
    const base64 = encodeURIComponent(canvas.toDataURL('image/webp', 0.5));
    // Do something with the base64
};

执行此操作时,页面会挂起大约 5 秒钟,然后才能在 canvas 上完全绘制图像,这是可以理解的,因为它是一个非常大的图像。因此,我试图找出在这种情况下是否可以使用 web workers。我找到了函数 transferControlToOffscreen(),但似乎支持有限,甚至被视为 MDN 上的实验性功能。

我想知道是否有不同的方法可以在 canvas 上绘制大图像而不挂起页面。

另外,在写这道题的时候,我想到了一个解决办法,就是从N×N的网格中分段绘制图像。

createImageBitmap() 应该为您提供此功能,但似乎只有 Chrome 才真正并行地对图像进行解码...

此方法将创建一个 ImageBitmap,可随时在 GPU 中由 canvas 绘制。所以一旦你得到这个,你可以在 canvas 上绘制它,几乎没有开销。
以某种方式期望从 canvas、HTML 视频元素、ImageData、ImageBitmap 甚至 HTML 图像等来源创建 ImageBitmap,大部分过程将是同步完成,因为源的位图实际上可以在调用后立即同步更改。
但是,对于 Blob 源,源的位图不会改变,浏览器可以毫无问题地并行处理所有内容。
这正是 Chromium 浏览器所做的。不幸的是,Safari 同步执行所有操作,而 Firefox 做了一些很奇怪的事情,他们显然锁定了 UI 线程而不是 CPU 线程...

// ignite an rAF loop to avoid false signals from "instant refresh ticks"
const rafloop = () => requestAnimationFrame(rafloop);
rafloop();
// start after some time, when the page is well ready
setTimeout(doTest, 250);

const counters = {
  tasks: 0,
  paintingFrames: 0
};
let stopped = false;
const {port1, port2} = new MessageChannel();
const postTask = (cb) => {
  port1.addEventListener("message", () => cb(), {once: true});
  port1.start();
  port2.postMessage("");
};

function startTaskCounter() {
  postTask(check);

  function check() {
    counters.tasks++;
    if (!stopped) postTask(check);
  }
}

function startPaintingFrameCounter() {
  requestAnimationFrame(check);

  function check() {
    counters.paintingFrames++;
    if (!stopped) requestAnimationFrame(check);
  }
}

async function doTest() {
  const resp = await fetch("https://upload.wikimedia.org/wikipedia/commons/c/cf/Black_hole_-_Messier_87.jpg?r=" + Math.random());
  const blob = await resp.blob();

  startPaintingFrameCounter();
  startTaskCounter();

  const t1 = performance.now();
  const bmp = await createImageBitmap(blob);
  const t2 = performance.now();
  console.log(`decoded in ${t2 - t1}ms`)

  const ctx = document.createElement('canvas').getContext('2d');
  ctx.drawImage(bmp, 0, 0);

  const t3 = performance.now();
  console.log(`Drawn in ${t3 - t2}ms`)

  console.log(counters);
  stopped = true;
}

然而希望还没有破灭,因为似乎现在的浏览器都“支持”了web-Workers的这个方法,所以我们实际上可以从一个生成这个位图,并且仍然在主要中使用它等待更好地支持 OffscreenCanvas API 的线程。
当然,Safari 不会让我们的生活变得轻松,我们必须对其进行特殊处理,因为它不能重用传输的位图。 (请注意,他们甚至不允许我们从 StackSnippets 中正确获取,但我对此无能为力)。

const support_bitmap_transfer = testSupportBitmapTransfer();
const getImageBitmapAsync = async(url) => {
  // to reuse the same worker every time, we store it as property of the function
  const worker = getImageBitmapAsync.worker ??=
    new Worker(URL.createObjectURL(new Blob([`
onmessage = async ({data: {url, canTransfer}, ports}) => {
  try {
    const resp = await fetch(url);
    const blob = await resp.blob();
    const bmp = await createImageBitmap(blob);
    ports[0].postMessage(bmp, canTransfer ? [bmp] : []);
  }
  catch(err) {
    setTimeout(() => { throw err });
  }
};
  `], {type: "text/javascript"})));
  // we use a MessageChannel to build a "Promising" Worker
  const {port1, port2} = new MessageChannel();
  const canTransfer = await support_bitmap_transfer;
  worker.postMessage({url, canTransfer}, [port2]);
  return new Promise((res, rej) => {
    port1.onmessage = ({data}) => res(data);
    worker.onerror = (evt) => rej(evt.message);
  });
};


// [demo only]
// ignite an rAF loop to avoid false signals from "instant refresh ticks"
const rafloop = () => requestAnimationFrame(rafloop);
rafloop();
// start after some time, when the page is well ready
setTimeout(() => doTest().catch(() => stopped = true), 250);

const counters = {
  tasks: 0,
  paintingFrames: 0
};
let stopped = false;
const {port1, port2} = new MessageChannel();
const postTask = (cb) => {
  port1.addEventListener("message", () => cb(), { once: true });
  port1.start();
  port2.postMessage("");
};

function startTaskCounter() {
  postTask(check);

  function check() {
    counters.tasks++;
    if (!stopped) postTask(check);
  }
}

function startPaintingFrameCounter() {
  requestAnimationFrame(check);

  function check() {
    counters.paintingFrames++;
    if (!stopped) requestAnimationFrame(check);
  }
}

async function doTest() {
  const url = "https://upload.wikimedia.org/wikipedia/commons/c/cf/Black_hole_-_Messier_87.jpg?r=" + Math.random();

  startPaintingFrameCounter();
  startTaskCounter();

  const t1 = performance.now();
  // Basically you'll only need this line
  const bmp = await getImageBitmapAsync(url);

  const t2 = performance.now();
  console.log(`decoded in ${t2 - t1}ms`)

  const ctx = document.createElement("canvas").getContext("2d");
  ctx.drawImage(bmp, 0, 0);

  const t3 = performance.now();
  console.log(`Drawn in ${t3 - t2}ms`)

  console.log(counters);
  stopped = true;
}
// Safari doesn't support drawing back ImageBitmap's that have been transferred
// not transferring these is overkill for the other ones
// so we need to test for it.
// thanks once again Safari for doing things your way...
async function testSupportBitmapTransfer() {
  const bmp = await createImageBitmap(new ImageData(5, 5));
  const {port1, port2} = new MessageChannel();
  const transferred = new Promise((res) => port2.onmessage = ({data}) => res(data));
  port1.postMessage(bmp, [bmp]);
  try{
    document.createElement("canvas")
      .getContext("2d")
      .drawImage(await transferred);
    return true;
  }
  catch(err) {
    return false;
  }
}

或者没有所有测量绒毛和 Safari 特殊处理:

const getImageBitmapAsync = async(url) => {
  // to reuse the same worker every time, we store it as property of the function
  const worker = getImageBitmapAsync.worker ??=
    new Worker(URL.createObjectURL(new Blob([`
onmessage = async ({data: {url}, ports}) => {
  try {
    const resp = await fetch(url);
    const blob = await resp.blob();
    const bmp = await createImageBitmap(blob);
    ports[0].postMessage(bmp, [bmp]);
  }
  catch(err) {
    setTimeout(() => { throw err });
  }
};
  `], {type: "text/javascript"})));
  // we use a MessageChannel to build a "Promising" Worker
  const {port1, port2} = new MessageChannel();
  worker.postMessage({url}, [port2]);
  return new Promise((res, rej) => {
    port1.onmessage = ({data}) => res(data);
    worker.onerror = (evt) => rej(evt.message);
  });
};

(async () => {
  const url = "https://upload.wikimedia.org/wikipedia/commons/c/cf/Black_hole_-_Messier_87.jpg?r=" + Math.random();
  const bmp = await getImageBitmapAsync(url);
  const canvas = document.querySelector("canvas");
  const ctx = canvas.getContext("2d");
  ctx.drawImage(bmp, 0, 0, canvas.width, canvas.height);
})();
<canvas width=250 height=146></canvas>

但是请注意,仅仅启动一个 Web Worker 本身就是一项繁重的操作,而且仅将它用于一个图像可能是一种完全矫枉过正的做法。所以如果你需要调整多张图片的大小,一定要复用这个Worker。