代码之家  ›  专栏  ›  技术社区  ›  gman

如何正确地将ResizeObserver与requestAnimationFrame一起使用

  •  0
  • gman  · 技术社区  · 1 年前

    许多示例显示使用 ResizeObserver 像这样的东西

    const canvas = document.querySelector('canvas');
    const ctx = canvas.getContext('2d');
    
    function draw() {
       const { width, height } = canvas;
    
       ctx.clearRect(0, 0, width, height);
       ctx.save();
       ctx.translate(width / 2, height / 2);
       const size = Math.min(width, height);
       ctx.beginPath();
       ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
       ctx.fill();
       ctx.restore();
    }
    
    const observer = new ResizeObserver(entries => {
      for (const entry of entries) {
        const canvas = entry.target;
        canvas.width = entry.contentBoxSize[0].inlineSize;
        canvas.height = entry.contentBoxSize[0].blockSize;
        draw();
      }
    })
    observer.observe(canvas);
    html, body {
     height: 100%;
     margin: 0;
    }
    canvas {
     width: 100%;
     height: 100%;
     display: block;
    }
    <canvas></canvas>

    这是有效的。这个 ResizeObserver 将在启动时激发一次,然后在画布的显示大小发生变化时激发

    但是,如果你切换到 requestAnimationFrame 循环你会看到一个问题

    const canvas = document.querySelector('canvas');
    const ctx = canvas.getContext('2d');
    
    function draw() {
       const { width, height } = canvas;
    
       ctx.clearRect(0, 0, width, height);
       ctx.save();
       ctx.translate(width / 2, height / 2);
       const size = Math.min(width, height);
       ctx.beginPath();
       ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
       ctx.fill();
       ctx.rotate(performance.now() * 0.001);
       ctx.strokeStyle = 'red';
       ctx.strokeRect(-5, -5, size / 4, 10);
       ctx.restore();
    }
    
    function rAFLoop() {
      draw();
      requestAnimationFrame(rAFLoop);
    }
    requestAnimationFrame(rAFLoop);
    
    const observer = new ResizeObserver(entries => {
      for (const entry of entries) {
        const canvas = entry.target;
        canvas.width = entry.contentBoxSize[0].inlineSize;
        canvas.height = entry.contentBoxSize[0].blockSize;
      }
    })
    observer.observe(canvas);
    html,正文{
    高度:100%;
    裕度:0;
    }
    帆布{
    宽度:100%;
    高度:100%;
    显示:块;
    }
    <画布></画布>

    最重要的是,我所做的就是接电话退出 ResizeObserver 并将其称为 requestAnimationFrame 循环(并添加了一点动作)

    如果你现在调整窗口的大小,你会看到圆圈在闪烁

    我该如何解决这个问题?

    1 回复  |  直到 1 年前
        1
  •  2
  •   gman    1 年前

    这里的问题是规范上说 ResizeObserver 发生回调 请求后动画帧 回调。这意味着上述示例中的操作顺序为

    1. requestAnimationFrame回调
    2. 绘制圆
    3. 调整观察器回调大小
    4. 设置画布大小(设置大小将清除画布)
    5. 浏览器合成页。

    有几种解决方案,它们都有问题或半涉及

    解决方案1:绘制 ResizeObserver

    这会奏效的。不幸的是,这意味着你在一帧中画了两次。如果你的画很重,那么这会导致调整大小的感觉迟钝

    解决方案2:不要使用 ResizeObserver

    您可以通过多种方式查找画布的显示大小。(1) canvas.clientWidth , canvas.clientHeight (返回整数),(2) canvas.getBoundingClientRect() (返回有理数)

    例如:

    const canvas = document.querySelector('canvas');
    const ctx = canvas.getContext('2d');
    
    function draw() {
       const { width, height } = canvas;
    
       ctx.clearRect(0, 0, width, height);
       ctx.save();
       ctx.translate(width / 2, height / 2);
       const size = Math.min(width, height);
       ctx.beginPath();
       ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
       ctx.fill();
       ctx.rotate(performance.now() * 0.001);
       ctx.strokeStyle = 'red';
       ctx.strokeRect(-5, -5, size / 4, 10);
       ctx.restore();
    }
    
    function rAFLoop() {
      // Only set canvas.width and canvas.height
      // if they need to be set because setting them is a
      // heavy operation
      if (canvas.width !== canvas.clientWidth ||
          canvas.height !== canvas.clientHeight) {
        canvas.width = canvas.clientWidth;
        canvas.height = canvas.clientHeight;
      }
    
      draw();
      requestAnimationFrame(rAFLoop);
    }
    requestAnimationFrame(rAFLoop);
    html, body {
     height: 100%;
     margin: 0;
    }
    canvas {
     width: 100%;
     height: 100%;
     display: block;
    }
    <canvas></canvas>

    调整窗口大小,你会看到它不再闪烁

    这个解决方案会起作用,但那些方法( canvas.clientWidth/height 和/或 getBoundingClientRect )只返回CSS像素,不返回设备像素。你可以将任意一个数字乘以 devicePixelRatio 但这也不会一直给你正确的答案。您可以在以下答案中了解原因: https://stackoverflow.com/a/72611819/128511

    解决方案3:如果画布的大小要更改,请检查rAF,如果要更改,则不渲染,而是在 ResizeObserver 回调。

    我们可以猜测画布是否要调整大小,如果 getBoundingClientRect 更改大小。尽管我们无法正确地从转换 getBoundingClientRect 对于设备像素,如果画布已经调整了大小,则该值将与我们上次调用它时相比发生变化。

    const canvas = document.querySelector('canvas');
    const ctx = canvas.getContext('2d');
    
    function draw() {
       const { width, height } = canvas;
    
       ctx.clearRect(0, 0, width, height);
       ctx.save();
       ctx.translate(width / 2, height / 2);
       const size = Math.min(width, height);
       ctx.beginPath();
       ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
       ctx.fill();
       ctx.rotate(performance.now() * 0.001);
       ctx.strokeStyle = 'red';
       ctx.strokeRect(-5, -5, size / 4, 10);
       ctx.restore();
    }
    
    let lastSize = {};
    
    function rAFLoop() {
      const rect = canvas.getBoundingClientRect();
      const willResize = lastSize.width !== rect.width || lastSize.height !== rect.height;
      if (willResize) {
        // don't draw since we'll draw in the resizeObserver callback
        // but do record our size
        lastSize = rect;
      } else {
        draw();
      }
      requestAnimationFrame(rAFLoop);
    }
    requestAnimationFrame(rAFLoop);
    
    const observer = new ResizeObserver(entries => {
      for (const entry of entries) {
        const canvas = entry.target;
        canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
        canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
        draw();
      }
    })
    observer.observe(canvas, { box: 'device-pixel-content-box' });
    html,正文{
    高度:100%;
    裕度:0;
    }
    帆布{
    宽度:100%;
    高度:100%;
    显示:块;
    }
    <画布></画布>

    使用此解决方案,我们每帧只渲染一次,并获得实际的设备像素大小

    注意:第三个解决方案改变了它所观察到的 'content-box' ,默认为 'device-pixel-content-box' 。如果你不进行此更改,那么,如果用户放大或缩小,你将不会得到调整大小的回调,因为从POV到网页,元素的大小在缩放时不会改变。它仍然只是一定数量的CSS像素。

    解决方案4:将大小记录在 ResizeObserver 回调,在中调整画布大小 requestAnimationFrame 如果尺寸发生变化

    这个解决方案意味着,给定这个答案顶部的操作顺序,您将为1帧呈现错误的大小。通常这并不重要。事实上,运行下面的示例并调整窗口大小,您可能不会看到问题

    const canvas = document.querySelector('canvas');
    const ctx = canvas.getContext('2d');
    
    function draw() {
       const { width, height } = canvas;
    
       ctx.clearRect(0, 0, width, height);
       ctx.save();
       ctx.translate(width / 2, height / 2);
       const size = Math.min(width, height);
       ctx.beginPath();
       ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
       ctx.fill();
       ctx.rotate(performance.now() * 0.001);
       ctx.strokeStyle = 'red';
       ctx.strokeRect(-5, -5, size / 4, 10);
       ctx.restore();
    }
    
    const desiredSize = { width: 300, height: 150 };  // initial size of canvas
    
    function rAFLoop() {
      const { width, height } = desiredSize;
      
      // only resize if we really need to since setting canvas.width/height
      // can be a heavy operation
      if (canvas.width !== width || canvas.height !== height) {
        canvas.width = width;
        canvas.height = height;
      }
    
      draw();
      requestAnimationFrame(rAFLoop);
    }
    requestAnimationFrame(rAFLoop);
    
    const observer = new ResizeObserver(entries => {
      for (const entry of entries) {
        const canvas = entry.target;
        desiredSize.width = entry.devicePixelContentBoxSize[0].inlineSize;
        desiredSize.height = entry.devicePixelContentBoxSize[0].blockSize;
      }
    })
    observer.observe(canvas, { box: 'device-pixel-content-box' });
    html,正文{
    高度:100%;
    裕度:0;
    }
    帆布{
    宽度:100%;
    高度:100%;
    显示:块;
    }
    <画布></画布>

    如果调整窗口大小,您可能不会看到此解决方案的任何问题。另一方面,假设你有突然的大变化。例如,假设您有一个编辑器,并且可以选择显示/隐藏窗格,这样画布区域的大小就会急剧变化。然后,对于一帧,你会看到一个错误大小的图像。我们可以演示

    const canvas = document.querySelector('canvas');
    const ctx = canvas.getContext('2d');
    
    function draw() {
       const { width, height } = canvas;
    
       ctx.clearRect(0, 0, width, height);
       ctx.fillStyle = 'red';
       ctx.save();
       ctx.translate(width / 2, height / 2);
       const size = Math.min(width, height);
       ctx.beginPath();
       ctx.arc(0, 0, size / 3, 0, Math.PI * 2);
       ctx.fill();
       ctx.stokeStyle = 'red';
       ctx.lineWidth = 20;
       ctx.strokeRect(-size / 2.5, -size / 2.5, size / 1.25, size / 1.25);
       ctx.lineWidth = 3;
       ctx.rotate(performance.now() * 0.001);
       ctx.strokeStyle = '#000';
       ctx.strokeRect(-5, -5, size / 4, 10);
       ctx.restore();
    }
    
    const desiredSize = { width: 300, height: 150 };  // initial size of canvas
    
    function rAFLoop() {
      const { width, height } = desiredSize;
      
      // only resize if we really need to since setting canvas.width/height
      // can be a heavy operation
      if (canvas.width !== width || canvas.height !== height) {
        canvas.width = width;
        canvas.height = height;
      }
      draw();
      requestAnimationFrame(rAFLoop);
    }
    requestAnimationFrame(rAFLoop);
    
    const observer = new ResizeObserver(entries => {
      for (const entry of entries) {
        const canvas = entry.target;
        desiredSize.width = entry.devicePixelContentBoxSize[0].inlineSize;
        desiredSize.height = entry.devicePixelContentBoxSize[0].blockSize;
      }
    })
    observer.observe(canvas, { box: 'device-pixel-content-box' });
    
    const uiElem = document.querySelector('#ui');
    setInterval(() => {
      ui.style.display = ui.style.display === '' ? 'none' : '';
    }, 1000);
    html, body {
     height: 100%;
     margin: 0;
    }
    canvas {
     width: 100%;
     height: 100%;
     display: block;
    }
    #outer {
     display: flex;
     width: 100%;
     height: 100%;
    }
    #outer>* {
     flex: 1 1 50%;
     min-width: 0;
    }
    #ui {
      display: flex;
      justify-content: center;
      align-items: center;
      background-color: #888;
    }
    <div id="outer">
      <div><canvas></canvas></div>
      <div id="ui"><div>ui pane</div></div>
    </div>

    如果你仔细观察上面的例子,当ui窗格被隐藏或显示时,你会看到一个框架的大小错误

    您选择的解决方案取决于您的需求。如果您的图形没有那么重,可以绘制两次,一次在rAF中,另一次在调整大小观察者中。或者,如果你不希望用户经常调整大小(注意:这不仅仅是调整窗口的大小,很多网页都有带滑块的窗格,可以让你调整存在所有相同问题的区域的大小)

    如果你不关心像素的完美度,那么第二个解决方案也很好。例如,大多数3d游戏,AFAIK,并不关心像素的完美度。他们已经在渲染双线性过滤纹理,并且经常渲染到较低的分辨率和重新缩放。

    如果你真的关心像素的完美度,那么第三种解决方案是可行的。

    注意:截止到2014-01-18,Safari仍然不支持 devicePixelContentBoxSize