👀

Intersection Observer API のパフォーマンス:複数インスタンス vs 共有インスタンス

2022/05/30に公開

はじめに

きっかけは以下のかまぼこさんのツイートです。

https://twitter.com/bokoko33/status/1530015155186466816

僕からの回答は以下です。

https://twitter.com/ixkaito/status/1530564413018628096

この記事ではこちらについてもう少し詳しく解説いたします。なお、Intersection Observer API の解説は割愛しますので、詳しくはこちらをご確認ください。

検証

コンポーネントごとにオブザーバー

スクロールに応じて要素をアニメーションさせる場合、React で素直に実装すると、おそらくコンポーネントごとに Intersection Observer のインスタンスを生成して、それぞれの要素を監視して状態を変化させることになるかと思います。具体的なコードを見たほうがわかりやすいと思いますのでサンプルコードを示します。

コンポーネントごとにオブザーバー
import { InView } from 'react-intersection-observer'

const Page = () => {
  return (
    <ul>
      {[...Array(1000)].map((_, i) => {
        return (
          <InView key={i}>
            {({ inView, ref }) => {
              return (
                <li ref={ref} className={inView ? 'bg-red-50' : ''}>
                  {i + 1}
                </li>
              )
            }}
          </InView>
        )
      })}
    </ul>
  )
}

export default Page

コンポーネントを自作してもいいのですが、react-intersection-observer という人気ライブラリの InView コンポーネントを使って手抜きします。

上記コードではコンポーネントを 1000 回呼び出して、画面内に表示されているかどうかで背景色を切り替えています。

共有オブザーバー

次は Intersection Observer インスタンスは 1 つのみで、複数要素に対して監視・処理を行う実装です。

共有オブザーバー
import { useEffect, useRef } from 'react'

const Page = () => {
  const targets = useRef([])
  const addToTargets = (el) => {
    if (el && !targets.current?.includes(el)) {
      targets.current.push(el)
    }
  }

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          entry.target.classList.add('bg-red-50')
        } else {
          entry.target.classList.remove('bg-red-50')
        }
      })
    })
    targets.current.forEach((target) => {
      observer.observe(target)
    })
  }, [targets])

  return (
    <ul>
      {[...Array(1000)].map((_, i) => {
        return (
          <li key={i} ref={addToTargets}>
            {i + 1}
          </li>
        )
      })}
    </ul>
  )
}

export default Page

こちらも同様に 1000 個の要素に対して、それぞれが画面内に表示されているかどうかを監視して背景色を切り替えます。

実際にブラウザで確認してみます

メニューの左側がコンポーネントごとに Intersection Observer インスタンス、右が共有インスタンスです。

GIF 動画デモ

見てのとおり、明らかに左側のほうが遅いです。ページに遷移した直後もそうですが、スクロールしたときの反応もです。

開発者ツールのパフォーマンスでも確認してみます。

開発者ツールのパフォーマンス計測

それぞれの最初の山はページに切り替わった直後、あとの 2 つの山はスクロールしたとき。

結論?

こうして見ると、やっぱり Intersection Observer インスタンスを共有したほうがパフォーマンスがいいように見えますが、結論を出すにはまだちょっと早いです。何の処理に時間がかかっているのかもう少し詳しく見てみましょう。

パフォーマンスのもっと詳細な処理の確認

よく見ると、どうやら Intersection Observer の処理ではなく、コンポーネントの処理に時間がかかっているようです。ここで、もしかしたら Intersection Observer インスタンスが複数でも、それぞれをコンポーネントにするのをやめたら結果が変わるかもしれないという可能性が出てきました。

追加検証

コンポーネントにはしないが、要素ごとにオブザーバー

共有オブザーバーのコードを少し変更して、要素ごとに Intersection Observer インスタンスを生成してみます。

要素ごとにオブザーバー
import { useEffect, useRef } from 'react'

const Page = () => {
  const targets = useRef([])
  const addToTargets = (el) => {
    if (el && !targets.current?.includes(el)) {
      targets.current.push(el)
    }
  }

  useEffect(() => {
    targets.current.forEach((target) => {
      const observer = new IntersectionObserver((entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            entry.target.classList.add('bg-red-50')
          } else {
            entry.target.classList.remove('bg-red-50')
          }
        })
      })
      observer.observe(target)
    })
  }, [targets])

  return (
    <ul>
      {[...Array(1000)].map((_, i) => {
        return (
          <li key={i} ref={addToTargets}>
            {i + 1}
          </li>
        )
      })}
    </ul>
  )
}

export default Page

ブラウザで確認してみます

GIF 動画デモ

見てのとおり、それぞれがコンポーネントの場合は相変わらず遅いですが、Intersection Observer のインスタンスを共有するか要素ごとに生成するかでは、体感的にはほとんど差はないです。パフォーマンスでも見てみましょう。

共有インスタンスとそれぞれにインスタンスのパフォーマンス

やはりパフォーマンス計測でもほとんど差がないです。

本当の結論

Intersection Observer インスタンスを共有するか、要素ごとに生成するかでは、パフォーマンス差はほぼないというのが僕の結論です。

React のお作法に則ってコンポーネントに分けると、パフォーマンス差は生じます。しかしそれは Intersection Observer の問題ではなく、React 側の問題です。

補足

追加検証 2

上記「コンポーネントごとにオブザーバー」のデモでは、1 画面に数百の要素を表示させていましたが、1 画面に 1–2 要素にした場合どうなるのかについても検証してみました。結果として、ページに切り替えた瞬間はやはり遅いです。スクロールするとせいぜい 2–3 コンポーネントの処理しかないので、体感的にはほとんど気にならないぐらいにはなります。実際に確認したい方は文末の検証用ソースコードとデモをご確認ください。

Render props

検証と説明では react-intersection-observerrender props を利用しましたが、実はこれが一番パフォーマンスが悪いです。他の方法を利用してコンポーネントを実装すれば、パフォーマンス差はかなり少なくなり、後述本番ビルドならほとんど変わらないないぐらいになると思います。

本番ビルド

実は開発環境では体感的に顕著なパフォーマンス差があったのですが、本番ビルドでは React のコードが最適化され、体感的な差はかなり少ないです。さらに、検証では要素が多かったので、一般的なウェブサイトであれば、コンポーネントを使った実装でもほとんどパフォーマンス差を感じないかもしれません。

おわりに

検証に使ったソースコードとデモを置いておきます。

ソースコード

https://github.com/ixkaito/intersection-observer-performance-test

デモ

https://intersection-observer-performance-test.vercel.app/

参考

https://www.bennadel.com/blog/3954-intersectionobserver-api-performance-many-vs-shared-in-angular-11-0-5.htm

Discussion