`display: none` と `visibility: hidden` の深層:ブラウザエンジンから見た「存在の消去」と「透明化」の境界線
フロントエンドの現場で最も頻繁に遭遇するUIの状態切り替え。要素を画面から消す際、反射的に `display: none` を書くか、それとも `visibility: hidden` を選ぶか。その選択が、ブラウザのメモリ空間、そして数ミリ秒を争うレンダリングパイプラインにどのような爪痕を残しているか、立ち止まって考えたことはあるだろうか。
単に「消えるか、隠れるか」という表面的な挙動の違いだけでコードを書いていると、複雑なWebアプリケーションのスケール時に、予期せぬレイアウトシフト(CLS)や、不要なペイントコストによるジャンク(カクつき)に足元をすくわれることになる。
今回は、BlinkやGeckoといったモダンなブラウザエンジンが、DOM、CSSOM、そして何より「レンダーツリー(Render Tree)」の構築段階において、これら2つのプロパティをどのように扱い、メモリとCPUのサイクルを消費しているのか。その深淵を覗いてみよう。
—
1. レンダーツリーの構築:DOMとCSSOMの結婚式場での出来事
ブラウザがHTMLをパースしてDOMツリーを作り、CSSをパースしてCSSOMツリーを作る。ここまでは基本の「き」だ。問題はその二つが結合し、実際に画面へピクセルを描画するための前段階である「レンダーツリー(Render Tree)」を構築するフェーズにある。
ここに、`display: none` と `visibility: hidden` の運命を分ける決定的な分岐点が存在する。
`display: none`:そもそも「存在しないもの」として扱われる
要素に `display: none` が指定されている場合、ブラウザのレイアウトエンジンは、その要素とその子孫要素のすべてをレンダーツリーの構築対象から完全に除外する。
CSSOMの計算段階で、このプロパティに遭遇したエンジンは「あ、こいつは画面上に関係ないな」と判断し、メモリ上のレンダーツリーにノードを生成しない。つまり、DOMツリーには存在していても、レンダーツリー上では「最初から無かったこと」にされるのだ。
これにより、メモリのフットプリントがわずかに削減されるだけでなく、後述するレイアウト計算の対象からも外れるため、ブラウザにとって極めて「優しい」状態が保たれる。
`visibility: hidden`:見えないけれど、そこに「居座る」
一方、`visibility: hidden` はどうか。
こちらは、レンダーツリーの構築において「不可視だが、レイアウト上の空間は占有する」という、何とも奇妙な存在として扱われる。
ブラウザはDOMノードに対応するレンダーオブジェクトをしっかりと生成する。ボックスモデルの計算(幅、高さ、マージン、パディング)は通常通り行われ、他の兄弟要素や親要素はその存在を考慮してレイアウトを組む。ただ、最後のペイント(描画)フェーズにおいてのみ、「このノードのピクセルは画面に描画しない(透明にする)」というフラグが立てられるだけなのだ。
—
2. リフロー(Reflow)とリペイント(Repaint)の十字路
パフォーマンスチューニングの文脈で最も議論されるのが、これら2つのプロパティを変更した際に発生するブラウザの再計算コストだ。
| 特性 / プロパティ | `display: none` | `visibility: hidden` |
| :— | :— | :— |
| レンダーツリーへの登録 | 除外される(ノードなし) | 登録される(ノードあり) |
| レイアウト計算 (Reflow) | 発生する(表示/非表示の切り替え時) | 発生しない |
| ペイント (Repaint) | 発生する | 発生する |
| イベントの伝播 (Pointer Events) | 無効(ヒットテストの対象外) | 原則無効(`pointer-events: auto` で復活可能) |
`display: none` の切り替えコスト:重いリフローの代償
`display: none` から `block` や `flex` へ(あるいはその逆へ)状態を遷移させると、ブラウザはレンダーツリーの再構築(Attachment)を余儀なくされる。
周囲の要素のサイズや位置がガラリと変わるため、ドキュメントのルートから再レイアウト計算(Reflow / Layout)が走り、その後にペイント、コンポジット(合成)という重いパイプラインが全速力で駆け抜ける。
もしこれを大量のリストアイテムに対してアニメーション的に、あるいは頻繁に行うと、メインスレッドがブロックされ、フレームレートが著しく低下する。これが「レイアウトスラッシング(Layout Thrashing)」の温床となる。
`visibility: hidden` の切り替えコスト:ペイントだけの軽快さ
対して `visibility` プロパティの切り替え(`hidden` ⇔ `visible`)は、レイアウト(Reflow)を一切誘発しない。
レンダーツリーの構造自体は1ミリも変化せず、単に描画レイヤーのピクセルデータが無効化される(あるいは復活する)だけなので、コストは圧倒的に低い。もし「レイアウトを崩さずに、要素をパッと消したり出したりしたい」のであれば、ブラウザのエンジンレベルで見れば `visibility` の方が圧倒的にエコなのだ。
—
3. アクセシビリティ(a11y)とイベントハンドリングの深い罠
上級エンジニアとして見落としてはならないのが、スクリーンリーダー(支援技術)とイベントの挙動だ。
- `display: none`: レンダーツリーから消えるため、アクセシビリティツリー(Accessibility Tree)からも完全に消去される。スクリーンリーダーはこれを無視し、Tabキーによるフォーカス移動も不可能になり、マウスイベント(clickなど)も一切発火しない。
- `visibility: hidden`: レンダーツリーに存在するため、アクセシビリティツリーにも残る場合がある(スクリーンリーダーの種類や環境、`aria-hidden` の併用状況による)。さらに最も危険なのが、親に `visibility: hidden` を指定しつつ、子要素に `visibility: visible` を指定した場合の挙動だ。子はしっかりと表示され、クリックイベントも拾う。
また、`visibility: hidden` が適用された要素は、デフォルトでは `pointer-events` も無効化されるが、子要素や要素自体に明示的に `pointer-events: auto` を再定義すると、「目に見えないのにクリックできる不可視の罠(ヒットテストの対象になる)」を作り出すことができる。UIのインタラクション設計において、これは強力な武器になるが、デバッグを地獄絵図に変える諸刃の剣でもある。
—
4. 実戦的アーキテクチャ:どう使い分けるべきか
では、我々フロントエンド・アーキテクトは、この知識をどう実務のコードに落とし込むべきか。
ケーススタディ:タブ切り替えとアコーディオンUI
例えば、タブパネルの切り替え。もしタブ内のコンテンツがDOMツリー自体に重いコンポーネント(複雑なSVGグラフや、膨大な子要素を持つフォーム)を抱えている場合、初期表示ですべてをレンダーツリーに乗せるのはメモリと初期レンダリング時間の無駄遣いだ。
ここでは、初期ロード時のコストを抑えるために `display: none`(またはJSによる条件付きレンダリング)を採用すべきだ。
逆に、ドロップダウンメニューやツールチップ、あるいは「マウスオーバーで瞬時にポップアップさせたい要素」において、開閉のたびにレイアウト計算が走り、周囲のコンテンツがガタガタ動く(レイアウトシフトが起きる)のは最悪のUXだ。ここでは `visibility: hidden`(あるいは `opacity` との組み合わせ)を使い、レイアウトをあらかじめ確保しておくのが定石となる。
パフォーマンス最適化のコードパターン
以下に、高負荷なUIコンポーネントにおける、ブラウザの描画パイプラインを意識したスタイリングとクラス切り替えのモダンなアプローチを示す。
/
レイアウトを維持しつつ、瞬時に隠す(リフローを回避)
モーダルの背景や、フェードインを控えた瞬時表示のUIに最適
/
.is-hidden-cheap {
visibility: hidden;
opacity: 0;
/ トランジションを効かせる場合は visibility も遅延させる必要がある /
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.is-visible-cheap {
visibility: visible;
opacity: 1;
}
/
完全にDOMの存在を消し去る(初期ロードや大きくレイアウトが変わる場合)
ブラウザにレンダーツリーの構築をさせないためのアプローチ
/
.is-display-none {
display: none;
}
/
- 高頻度で呼び出されるトグル処理の最適化例
- 意図しないレイアウトスラッシングを防ぐため、スタイルの読み書きを分離する
/
function toggleElementVisibility(element, shouldHide) {
// requestAnimationFrameを利用し、ブラウザの描画サイクルに処理を同期させる
window.requestAnimationFrame(() => {
if (shouldHide) {
// visibility を使うことで、この瞬間のレイアウト再計算(Reflow)を完全にバイパスする
element.style.visibility = ‘hidden’;
element.style.opacity = ‘0’;
} else {
element.style.visibility = ‘visible’;
element.style.opacity = ‘1’;
}
});
}
—
5. 結びにかえて
「要素を消す」という一見してプリミティブな操作であっても、その裏側ではブラウザのレンダリングエンジンがメモリ、CSSOM、レンダーツリー、そしてレイアウトとペイントのパイプラインをフル回転させている。
`display: none` は「存在の抹消(構造の変更とリフローを伴う重い処理)」。
`visibility: hidden` は「不可視化のヴェール(構造を維持したまま描画だけをスキップする軽い処理)」。
この本質的なアーキテクチャの違いを理解していれば、レビューの場で「なぜここは `display` ではなく `visibility` なのか?」と問われた際にも、ブラウザエンジンの挙動を盾に取って、説得力のあるエンジニアリングの意思決定を語ることができるはずだ。
パフォーマンスの最適化は、こうしたミクロなブラウザの挙動の積み重ねの先にある。泥臭く、しかし美しいエンジニアリングを、次のプロダクトでも存分に発揮してほしい。

コメント