Intersection Observer API のパフォーマンス:複数インスタンス vs 共有インスタンス
はじめに
きっかけは以下のかまぼこさんのツイートです。
僕からの回答は以下です。
この記事ではこちらについてもう少し詳しく解説いたします。なお、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 インスタンス、右が共有インスタンスです。
見てのとおり、明らかに左側のほうが遅いです。ページに遷移した直後もそうですが、スクロールしたときの反応もです。
開発者ツールのパフォーマンスでも確認してみます。
それぞれの最初の山はページに切り替わった直後、あとの 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
ブラウザで確認してみます
見てのとおり、それぞれがコンポーネントの場合は相変わらず遅いですが、Intersection Observer のインスタンスを共有するか要素ごとに生成するかでは、体感的にはほとんど差はないです。パフォーマンスでも見てみましょう。
やはりパフォーマンス計測でもほとんど差がないです。
本当の結論
Intersection Observer インスタンスを共有するか、要素ごとに生成するかでは、パフォーマンス差はほぼないというのが僕の結論です。
React のお作法に則ってコンポーネントに分けると、パフォーマンス差は生じます。しかしそれは Intersection Observer の問題ではなく、React 側の問題です。
補足
追加検証 2
上記「コンポーネントごとにオブザーバー」のデモでは、1 画面に数百の要素を表示させていましたが、1 画面に 1–2 要素にした場合どうなるのかについても検証してみました。結果として、ページに切り替えた瞬間はやはり遅いです。スクロールするとせいぜい 2–3 コンポーネントの処理しかないので、体感的にはほとんど気にならないぐらいにはなります。実際に確認したい方は文末の検証用ソースコードとデモをご確認ください。
Render props
検証と説明では react-intersection-observer
の render props を利用しましたが、実はこれが一番パフォーマンスが悪いです。他の方法を利用してコンポーネントを実装すれば、パフォーマンス差はかなり少なくなり、後述本番ビルドならほとんど変わらないないぐらいになると思います。
本番ビルド
実は開発環境では体感的に顕著なパフォーマンス差があったのですが、本番ビルドでは React のコードが最適化され、体感的な差はかなり少ないです。さらに、検証では要素が多かったので、一般的なウェブサイトであれば、コンポーネントを使った実装でもほとんどパフォーマンス差を感じないかもしれません。
おわりに
検証に使ったソースコードとデモを置いておきます。
ソースコード
デモ
参考
Discussion