スマートフォン・タブレットからインターネットサーバーオペレーション

APPW.jp
 
CSS NOTES ③

背景と合成レイヤー
::before / fixed / will-change

スクロールしている途中でだけ、ページの右に余白が出る。 要素の寸法を全部測っても該当なし。 原因は body の背景タイルでした。 包含ブロックの違い、合成レイヤーへの昇格、そして 10手かかった切り分けの記録です。

background ::before containing block will-change

この記事で扱うこと
  1. 背景は、要素の寸法に現れない 測っても見つからない理由
  2. body の背景は、どこまで敷かれるか キャンバスへの伝播
  3. ::before に逃がす — fixed と absolute 包含ブロックが変わる
  4. z-index:-1 が背面に回るための条件 重ね合わせ文脈
  5. will-change: transform は、どこに書くか 親を昇格させないと効かなかった
  6. 10手の記録 推測が5回外れた経緯
  7. まとめの早見表
[ 1 ]

背景は、要素の寸法に現れない

横方向のはみ出しを探すとき、ふつうは要素の位置と幅を測ります。

画面幅を超える要素を探す
document.querySelectorAll('body *').forEach(el => {
  const r = el.getBoundingClientRect();
  if (r.right > innerWidth) /* 犯人候補 */;
});

この方法には限界があります。背景は要素の寸法に影響しませんbackground-image がどれだけ広い範囲に敷かれても、 getBoundingClientRect() の値は変わりません。

だから測っても出てきません。 「はみ出し要素 0件」なのに右へスクロールできる—— そういう状態になったら、寸法を持たないものを疑う必要があります。 背景、影、アウトライン、変形(transform)などです。
切り分けは、消してみるのが早い。 background-image: none を一時的に入れて症状が変わるか見る。 理屈で追うより確実で、しかも数秒で済みます。
[ 2 ]

body の背景は、どこまで敷かれるか

body に背景を指定すると、それはビューポート(キャンバス)へ伝播します。 html に背景が無い場合の既定の挙動です。

塗られる範囲。 キャンバスの背景はスクロール領域全体を覆います。 ビューポート1画面ぶんではなく、文書の高さぶんです。

色ならこれで問題ありません。ところが繰り返しパターンだと話が変わります。

グリッド模様の背景
body {
  background-image:
    linear-gradient(rgba(0,229,255,.025) 1px, transparent 1px),
    linear-gradient(90deg, rgba(0,229,255,.025) 1px, transparent 1px);
  background-size: 44px 44px;
}
敷かれる範囲高さタイル数
文書全体(body 直接)3606400約1,300枚
ビューポート1画面360800約170枚

縦に長い記事では、タイルが数千枚単位になります。 そしてモバイルでは、スクロール中にアドレスバーの表示が切り替わり ビューポートの寸法が変化します。 そのたびに、この広い範囲の塗りが再計算されることになります。

この項目について
「タイル数が多いと再計算で横方向の端数が出る」という因果までは特定できていません。 確認できたのは、背景を消すと症状が消え、範囲を狭めると改善したという事実だけです。 機序の説明は推測を含みます。
[ 3 ]

::before に逃がす — fixed と absolute

背景を body から擬似要素へ移すと、敷かれる範囲を指定できます。 ただし position の選び方で結果が変わります。

包含ブロック範囲注意点
fixedビューポート1画面ぶんスクロールバー幅を含む
absolute直近の位置指定祖先その要素ぶん親に position が要る

fixed の場合

ビューポートに限定する
body::before {
  content: "";
  position: fixed;
  inset: 0;              /* 常に1画面ぶん */
  z-index: -1;
  pointer-events: none;
  background-image: ...;
}

塗る範囲が最小になります。背景は固定され、内容だけがスクロールします。 薄い模様なら体感差はほぼありません。

ただし、幅にスクロールバーが含まれます。 固定要素の包含ブロックはビューポートで、これは スクロールバーを除いた内容領域より広い。 擬似要素がバーの下まで敷かれ、その分だけ横のはみ出しとして扱われることがあります。

absolute の場合

body の箱に収める
body {
  position: relative;     /* 基準にする */
  overflow-x: clip;       /* はみ出しを切る */
}
body::before {
  position: absolute;
  inset: 0;              /* body の寸法に追従 */
  z-index: -1;
}

基準が body になるのでスクロールバーを含みません。 一方で範囲は文書全体に戻るため、 overflow-x:clip を併せないと §2 の状態に逆戻りします。 実際、clip 無しで試したときは元の症状が再現しました。

どちらを選ぶか
自分の環境では、Chrome では fixed、iPad Safari では absolute + clip がそれぞれ有効でした。両方を満たす単一の答えは見つけられていません。 最終的には次の §5 の指定を加えることで、どちらの環境でも安定しました。
[ 4 ]

z-index:-1 が背面に回るための条件

擬似要素を内容の背後に置くため z-index:-1 を使いますが、 これだけでは足りない場合があります

負の z-index の行き先。 z-index が負の要素は、その要素が属する重ね合わせ文脈の中で背面に置かれます。 文脈の基準となる要素の背景より前、内容より後ろです。

body が重ね合わせ文脈を作っていないと、擬似要素は さらに外側の文脈(ルート)へ回ります。 すると body の背景色に隠れることがあります。

文脈を作っておく
body {
  position: relative;
  z-index: 0;          /* これで重ね合わせ文脈になる */
}

z-index:0 は一見すると意味がなさそうですが、 position と組み合わさると重ね合わせ文脈を生成します。 これで z-index:-1 の行き先が body の中に確定します。

他にも文脈を作るものがあります。 opacity が1未満、transformfilterwill-change(対象プロパティによる)など。 意図せず文脈が生まれて重なり順が変わることも起きるので、 「なぜか前面に出てこない」ときは祖先の指定を疑ってください。
[ 5 ]

will-change: transform は、どこに書くか

will-change は「この要素はこれから変化する」とブラウザに伝える指定です。 transform を指定すると、多くの場合合成レイヤーへ昇格します。

昇格した要素は、描画がGPU側で完結し スクロール中の再計算から切り離されます

擬似要素に書いても効かなかった

効果なし
body::before {
  will-change: transform;   /* 変化なし */
}
解消した
body {
  will-change: transform;   /* これで安定した */
}
擬似要素だけを昇格させても、親は従来どおり再計算されます。 症状の出どころが body の描画そのものだったため、 昇格させるべきは親のほうでした。 「背景の問題だから背景を直す」という発想では届きませんでした。

代償

will-change は常時レイヤーを確保します。 body 全体が対象なので、メモリ消費は増えます。 本来は「変化の直前に付けて、終わったら外す」使い方が推奨される指定です。 常時付けっぱなしにするのは、他に手段が無いときの選択と考えたほうがよいと思います。
[ デモ ]

背景は、どこに敷かれているか

4つの方式で、塗られる範囲がどう変わるかを比べます。 緑の格子が背景、灰色の枠が文書全体です。

Interactive · painting area
塗られる範囲の比較
模式図です。タイル数は 360×800 の画面・文書高さ6400px として計算した概算。

fixed のときだけ、塗る範囲が1画面ぶんに収まります。 その代わり幅がスクロールバーを含む——このトレードオフが、 単一の正解に辿り着けなかった理由でした。

[ 6 ]

10手の記録

この症状を追うのに、10回の試行が必要でした。 推測で提示した仮説は5つ、当たったのは1つです。 同じ道を辿る人のために、外れた分も含めて残します。

01 表が画面を超えていると考えた 外れ — 表には overflow-x:auto があり、内部でスクロールしていた
02 フレックス行の min-width 合計が超過していると計算した 外れ — 親に overflow:hidden があり、そこでクリップされていた
03 はみ出し要素を探すスクリプトを書いた 誤検出 — スクロール容器の中身まで報告していた
04 背景を疑ったが、「スクロール中に出る」という観察から取り下げた 撤回が誤り — 取り下げるべきではなかった
05 background-image: none を実際に試した 当たり — 症状が消え、背景が原因と確定
06 ::before + fixed に移した 部分解 — Chrome で解消。ただしスクロールバー幅の余白が残る
07 ::before + absolute に変えた 後退 — 範囲が文書全体に戻り、元の症状が再現
08 body に overflow を足した 前進 — iPad Safari で解消(後に clip へ変更)
09 will-change を ::before に入れた 効果なし — 昇格させる対象が違った
10 will-change を body に入れた 解消 — 両環境で安定
振り返って言えること。 01・02・04 は、いずれもCSSを読んで組み立てた推測でした。 決め手になった 05 と 10 は、どちらも実際に試した結果です。 そして 03 では、確かめるために作った道具自体が間違った答えを返していました。
もうひとつの教訓。 別のページで「効果のないはずの記述を足したら直った」ことがありました。 キャッシュが切り替わっただけの可能性があります。 修正の効果を判定するときは、キャッシュを無効にした状態で比べるのが確実です。
[ 7 ]

まとめの早見表

状況対処
測ってもはみ出し要素が出ない背景・影・transform を疑う
背景が原因か確かめたいbackground-image: none を一時的に
塗る範囲を1画面に限る::before + position: fixed
スクロールバー幅を含めたくないposition: absolute + 親に overflow-x: clip
z-index:-1 が背景に隠れる親に position + z-index: 0
スクロール中だけ描画が乱れるwill-change: transform
修正の効果が判定できないキャッシュを無効にして比べる

この記事のまとめ

  • 背景は要素の寸法に現れない。測って0件でも、原因が無いとは限らない。
  • body の背景はキャンバスへ伝播し、文書全体を覆う。繰り返し模様では枚数が跳ね上がる。
  • fixedビューポート基準=1画面ぶん。ただしスクロールバー幅を含む
  • absolute親の箱基準。バーは含まないが、範囲は文書全体に戻る。
  • z-index:-1 を効かせるには、親が重ね合わせ文脈を作っている必要がある。
  • will-change昇格させたい要素そのものに書く。子に書いても親は再計算される。
  • 常時 will-changeメモリを消費し続ける。他に手段が無いときの選択。
  • 理屈で3回外し、実測で2回当てた。消して試すのが、いちばん速い。
CSS NOTES シリーズ
  1. 縮まないと、あふれる
    min-width:0 と overflow の関係
  2. クリップとスクロールの境界
    hidden / clip / overscroll-behavior
  3. 背景と合成レイヤー(この記事)
    ::before / fixed / will-change

『背景と合成レイヤー — ::before / fixed / will-change』を公開しました。