CSSとレイアウトReact

content-visibility:リストの仮想化なしで実現するオフスクリーンレンダリング

content-visibility:リストの仮想化なしで実現するオフスクリーンレンダリング

記事の下にある千件のコメントのリストは目に見えて遅くレンダリングされ、非力なスマートフォンではスクロールがカクつきます。Reactにおける標準的な答えは仮想化——react-window@tanstack/react-virtual——であり、実際に表示ウィンドウに収まっている要素と、そこに少しのバッファを加えた分だけをDOMにレンダリングします。動きはしますが、導入時にはあまり語られないコストが伴います。表示ウィンドウの外にある要素は隠されているのではなく、DOMに存在しないのです。Ctrl+Fを押しても、手動でそこまでスクロールしない限り、コメント#850のテキストは見つかりません。ページを印刷しても、印刷を実行した瞬間にたまたまレンダリングされていたものしか表示されません。スクリーンリーダーによる見出しやランドマークのナビゲーションは、ツリーに物理的に存在しないコンテンツを飛ばしてしまいます——これは仮想化のバグではなく、避けられない結果です。

content-visibilityは、まったく別の角度から同じパフォーマンス問題に立ち向かいます。要素はDOMに、常に、すべて残ったままです。ブラウザはただ、要素が表示領域に十分近づいてその処理をする意味が出てくるまで、本来行うはずだった作業——レイアウト、描画、コンポジターレイヤーの作成——を省略するだけです。

「作業を省略する」とは正確にはどういうことか

要素にcontent-visibility: autoを指定すると、ブラウザはその要素にCSS Containmentを適用します——これはContainer Queriesの記事で、コンテナのサイズを問い合わせる際の循環を避けるために使われていたのとまったく同じ仕組みです。ここではcontainmentは別の目的のために働きます。要素が画面外にある場合、ブラウザは、その内部のレイアウトや描画が自分自身の外には何も影響しないと安全に仮定できるため、中の一ピクセルすら計算することなく、その作業をまるごと後回しにできるのです。

css

.comment {
content-visibility: auto;
contain-intrinsic-size: auto 180px;
}

contain-intrinsic-sizeは見た目だけの飾りではありません——これがなければ、省略された要素は高さゼロまで縮んでしまいます。中身のレイアウトを計算していないので、ブラウザには要素のサイズを取得する手立てがないからです。これはまさに、明示的な高さのないcontainer-type: sizeで起きるのと同じ問題です——ただしここでの結果は消えるカードではなく、コメントが次々に省略モードへ出入りするたびにドキュメントの高さが変わり、スクロールバーが跳ねることです。値auto 180pxはブラウザに二つのことを同時に伝えます。要素が一度も本当にレンダリングされていない段階では、まず180pxを最初の近似値として使うこと。そしてその後は、実際に計算された本当のサイズを記憶し、以降省略されるたびにその記憶された値をプレースホルダーとして使うこと——つまり実際のコメントの高さがまちまちであっても、ブラウザは一つの固定値に頼り続けるのではなく、時間とともにどれだけのスペースを確保すべきかをより正確に推測できるようになります。

完全な仮想化では手に入らないトリック:「見つかるまで隠す(hidden until found)」

これはcontent-visibilityが、実務上だけでなく原理的にも仮想化には不可能なことをやってのける場面です。autoモードの要素は、レンダリングとしては省略されていても、そのテキストは依然としてDOMに物理的に存在しています。ブラウザはそのことを知っており、そうした要素を「隠されてはいるが検索可能(hidden but matchable)」として扱います。ユーザーがCtrl+Fを押して、省略された要素の中にあるテキストにマッチする語句を入力すると、ブラウザは一時的に省略を取り消し、その要素を実際にレンダリングし、マッチした部分をハイライトし、そこまでスクロールします——あなた側のコードは一行も必要ありません、すべて自動です。display: noneはこれをまったく行ったことがありませんでした(Ctrl+Fは単純にそれを無視します)し、仮想化されたリストがこれをできないのは、手動でそこまでスクロールしない限り、探しているテキストがそもそもDOMに存在しないからです。

実践例:仮想化用のJSを一行も使わないコメントリスト

jsx

const CommentList = ({ comments }) => (
<ul className="comment-list">
{comments.map((comment) => (
<li key={comment.id} className="comment">
<p className="comment__author">{comment.author}</p>
<p className="comment__body">{comment.body}</p>
</li>
))}
</ul>
);
export default CommentList;

css

.comment {
content-visibility: auto;
contain-intrinsic-size: auto 180px;
padding: 12px 0;
border-bottom: 1px solid #e5e5e5;
}

このコンポーネント全体は、千個の<li>をすべてDOMにレンダリングします——スクロールウィンドウのロジックも、どの要素が現在表示されているかを計算する処理も、ライブラリも一切ありません。千件のコメントのうちどれについて今この瞬間にレイアウトと描画を計算する価値があり、どれがレンダリング上「存在しない」ものとして扱われるべきかは、DOM上には完全に存在しているにもかかわらず、ブラウザが自ら判断します。

それだけでは足りないとき——そして、それでも仮想化に意味がある理由

content-visibilityが解決するのはレンダリングのコスト——レイアウトと描画です。Reactアプリケーションではしばしばそれ以上に痛みを伴うコスト、つまり千個の<li>のそれぞれが依然として実在するReactコンポーネントであり、マウントされ、ハイドレートされ、独自のフックやエフェクトを持ちうるという事実は解決しません。CSS上のレンダリングを省略しても、その千個のコンポーネントインスタンスがReactのツリーの中に存在しなくなるわけでも、それらを生成するために必要なJavaScriptのコストがなくなるわけでもありません。単純で、ほとんど静的なコンテンツブロック(コメント、長い記事の段落、インタラクティブ性のないテーブルの行)にとってはこれは重要ではありません——本当のコストはもともとレイアウトや描画にあり、コンポーネントのロジックにはなかったからです。しかし、独自のstateやサブスクリプション、コストの高いレンダーを持つ重いインタラクティブなコンポーネントで構成されたリストにとっては、Reactのツリーから未使用のインスタンスを完全に取り除く本物の仮想化が、依然としてなめらかさを保つ唯一の方法であることも珍しくありません。

落とし穴とグッドプラクティス

  • contain-intrinsic-sizeは実務上は必須であり、任意ではない。 これを省略すると、要素が省略モードに出入りするたびにスクロールが跳ね、ドキュメントの高さが不正確になります。
  • content-visibility: hiddenautoと同じではない。 hiddenは画面上の位置に関係なく常にレンダリングを省略しますが、display: noneとは異なり、計算済みレイアウトの内部状態をキャッシュとして保持します——要素を再表示すること(例えばタブの切り替え)はdisplay: none後よりも安価です。ブラウザがすべてをゼロから計算し直す必要がないからです。content-visibility: hiddenを持つ要素は、display: noneと同様にアクセシビリティツリーから正しく除外されます——頻繁に切り替えられるタブやパネルにとって、これは単なるパフォーマンステクニックというだけでなく、安全な選択肢です。
  • 重いコンポーネントを含むリストの仮想化の代替にはならない。 これはシンプルで、ほとんど静的なリストのための、最初の、より安価な手段として扱いましょう——本物の仮想化に手を伸ばすのは、プロファイラーがレイアウトそのものではなくJS/React側のコストを実際に示したときにしましょう。
  • 「hidden but matchable」に完全に依存する前にサポート状況を確認する。 content-visibility: auto自体は今日すべての主要なエンジンで広くサポートされていますが、Ctrl+Fでの「一時的な開示」の挙動はChromiumで最も洗練されている傾向があります——これはうれしいおまけとして扱い、そのうえにアクセシビリティ要件を組み立てるための保証としては扱わないようにしましょう。

まとめ

仮想化とcontent-visibilityは、一見すると同じ問題——レンダリングが遅すぎる長いリスト——を解決しているように見えますが、まったく異なる階層でそれを行っています。仮想化はDOMとReactツリーの両方から要素を完全に取り除き、その代償としてCtrl+F、印刷、そしてアクセシビリティナビゲーションの一部を失います。content-visibilityはDOMをそのまま完全に保ちつつ、見えていないものに対するコストの高いレンダリング作業だけをブラウザに省略させます——その代わり、得られる最適化は控えめになりますが、ブラウザがもともと完全なドキュメントに対して行えていたことを何も壊すことなく、無償で手に入ります。大半の長く、比較的シンプルなリストにとって、これが正しい最初の手段です——仮想化は、それでは本当に足りない場合のために予備として残しておきましょう。