🔮

ResizeObserver の使い方と無限ループ対策

2023/01/29に公開

特徴

  • 特定要素のサイズ変更を検知できる
  • ポーリングし続けたりしているのではない
  • 標準機能だった

コード

Vue.js 2 用
export const mod_resize_observer = {
  data() {
    return {
      base_w: 1,
      base_h: 1,
    }
  },
  mounted() {
    this.ro_start()
  },
  beforeDestroy() {
    this.ro_stop()
  },
  methods: {
    ro_start() {
      this.ro_stop()
      this.$ro = new ResizeObserver((entries, observer) => {
        entries.forEach(entry => {
          this.base_w = entry.contentRect.width
          this.base_h = entry.contentRect.height
        })
      })
      this.$ro.observe(this.$el.querySelector("#target"))
    },
    ro_stop() {
      if (this.$ro) {
        this.$ro.disconnect()
        this.$ro = null
      }
    },
  },
}

beforeDestroy のタイミングで disconnect しないといけないのかどうかはよくわかってない。

CSSに渡すには?

JavaScript 側の変数がそのままCSS変数になるような実験的な仕組みもあったような気もするけどとりあえずこれがわかりやすい。

AnyComponent(:style="{'--base_w': `${base_w}px`, '--base_h': `${base_h}px`}")

内部で何かするときに数値として持っておいた方が便利かもしれないのでCSSに渡すタイミングで単位をつけるのがいいかもしれない。CSS側で calc(var(--base_w) * 1px) としてもいいけどCSS側で数値のままにしておくメリットはとくにない。

padding を知るには?

entry.contentRect.left
entry.contentRect.top

left top が padding 相当になっている。

何個も監視できる

observe で何個も監視対象を登録できる。

無限ループ対策

画面が震えている場合、無限ループに陥っている。width, height は単位が px だけど小数になっている(小数点以下5桁)。これをそのままCSSに反映すると誤差によって再度リサイズが起きる。

そこで、とりあえず Math.round(width) としたら無限ループは起きにくくなった。それでも震える場合は次のように差分と閾値で判断する。

const w = entry.contentRect.width
const h = entry.contentRect.height
const dw = Math.abs(this.base_w - w)
const dh = Math.abs(this.base_h - h)
const threshold = 1.5
if (dw > threshold || dh > threshold) {
  this.base_w = w
  this.base_h = h
}

対象が消えるとどうなる?

ありがたいことにいきなり消えるのではなくサイズが 0, 0 の状態でコールバックされる。したがって存在の有無で処理を分けることができる。たとえば関連要素を巻き込まないようにするなら 0, 0 は除外する。

const w = entry.contentRect.width
const h = entry.contentRect.height
if (w === 0 && h === 0) {
  // 対象が消えた場合
}
if (w > 0 && h > 0) {
  // 関連要素を巻き込まないようにする場合
}

テストが書きにくい件

jest で ReferenceError: ResizeObserver is not defined となるので次のようにしてモックを用意する。

window.ResizeObserver = window.ResizeObserver || jest.fn().mockImplementation(() => ({
  disconnect: jest.fn(),
  observe: jest.fn(),
  unobserve: jest.fn(),
}))

https://stackoverflow.com/questions/68679993/referenceerror-resizeobserver-is-not-defined

webpack-dev-server で ResizeObserver loop limit exceeded エラーがでる問題

ResizeObserver loop limit exceeded
    at eval (webpack-internal:///./node_modules/webpack-dev-server/client/overlay.js:249:58)
  • webpack-dev-server を新しくしてからオーバーレイ画面に上のエラーが出るようになる
  • これは問題の切り分けが非常に難しい
  • ResizeObserver を使った(自分の)プログラムが起因している
    • requestAnimationFrame も debounce もかましていない場合とする
  • しかし問題は webpack-dev-server 側にある
  • エラーは 4.13.2 で出る
    • 正確には 4.12.0 からエラーになるようになった(らしい)
    • 4.11.1 では出ない(らしい)

これに対処するには、

vue.config.js
module.exports = {
  devServer: {
    client: {
      overlay: {
        runtimeErrors: false,
      },
    },
  },
}

とする。これで webpack-dev-server 自身のエラーを表示しないようになるため結果的に ResizeObserver のエラーも抑制される。

https://github.com/webpack/webpack-dev-server/issues?q=ResizeObserver

根本的に ResizeObserver loop limit exceeded が出る問題に向き合う

このエラーはそもそもブラウザ側に問題があるのと、無視して問題ないという見方もあるようだが、とりあえず「エラーを出さないようにする」ことだけを目的に対処する。

requestAnimationFrame 編

ro_start() {
  this.ro_stop()
  this.$ro = new ResizeObserver((entries, observer) => {
    this.$animation_frame_id = requestAnimationFrame(() => {
      entries.forEach(entry => {
        this.base_w = entry.contentRect.width
        this.base_h = entry.contentRect.height
      })
    })
  })
  this.$ro.observe(this.$el.querySelector("#target"))
},

ro_stop() {
  if (this.$ro) {
    this.$ro.disconnect()
    this.$ro = null

    if (this.$animation_frame_id != null) {
      cancelAnimationFrame(this.$animation_frame_id)
      this.$animation_frame_id = null
    }
  }
},

ResizeObserver に渡すブロックの中身を requestAnimationFrame() でラップする。どうなっているのかよくわからないがこれでエラーは出なくなる。

参考:

debounce 編

ro_start() {
  this.ro_stop()
  this.$ro = new ResizeObserver(_.debounce((entries, observer) => {
    entries.forEach(entry => {
      this.base_w = entry.contentRect.width
      this.base_h = entry.contentRect.height
    })
  }, 1000 / 30))
  this.$ro.observe(this.$el.querySelector("#target"))
},

この方法でもエラーは出なくなった。体感的には 30FPS の更新でなんら問題ないだろうということで 1000 / 30 としている。

参考: https://www.webdesignleaves.com/pr/jquery/resizeObserver.html#:~:text=になります。-,Debounce の利用,-例えば、ユーザーが

Discussion