2026年10月8日にMDN Web PerformanceとGoogle Web Vitalsの本文を再確認した。モバイル性能は回線速度だけでなく、端末CPU/メモリ、入力、電池、画面密度、利用環境の違いを含む。LCP・INP・CLSのフィールド75パーセンタイル、実機RUM、ラボ回帰、画像・JavaScript・DOM・ネットワークの計測を組み合わせ、デスクトップの合成スコアをモバイル利用者の成果へ直接置き換えない。
30秒で要点
- モバイルでは、通信待ち、CPUの仕事、操作方法、画面の表示状態を分けて調べます。端末の名前だけで一つの原因に決めません。
- LCPになり得る先頭画像は遅延させず、後方の画像や開かれるまで不要な機能を候補にします。Service Workerはキャッシュ未命中や更新も含めた設計が必要です。
- ボタンは標準の操作を保ち、ジェスチャーには取消と代替操作を用意します。ここでのローカル教材の成功は、実端末での速度・電池・売上改善の証明ではありません。
同じ遅さでも原因を分ける
架空の二つの観測を考えます。Aは画像転送の待ちが目立ち、Bはデータ取得後のJavaScript処理が長い状態です。Aで画像の候補を小さくする仮説と、Bで処理を遅らせたり分割したりする仮説は異なります。モバイル流入率や離脱率を一般的な固定値で埋めず、自分の対象ページ・期間・端末群で確認します。
表が収まらない場合は、横にスクロールできます
| 架空の観測 | 次に確認する条件 | 小さな変更 | 継続・戻す判断 |
|---|---|---|---|
| A:画像の到着待ちが長い | 選択された画像URL・転送量・キャッシュ | 画面幅に合う候補を用意 | 品質を保って待ちが減るか |
| B:取得後に操作が止まる | 長いタスクと利用者操作の重なり | 不要機能を後から描画 | 操作とエラー表示が保たれるか |
| C:押しても反応しない | 対象サイズ・重なり・イベント | 標準buttonと一つのclick処理 | 指・キーボードの両方で使えるか |
良好なCore Web Vitalsの目安はLCP 2.5秒以下、INP 200ms以下、CLS 0.1以下です。フィールドではページ読み込みの75パーセンタイルをモバイルとデスクトップに分けて評価します。ラボの一回の計測が、この分布を代表するわけではありません。
通信を減らす前に画像の役割を決める
先頭画像と後方画像を分ける
次は先頭の商品画像の候補を画面幅で切り替える教材です。ここではLCP候補を想定し、loading="lazy"を付けません。width/heightはアスペクト比を確保するための情報で、実ファイルとも一致させます。異なるトリミングを使う場合は、それぞれの見せたい内容も確認します。
<picture>
<source media="(max-width: 600px)" srcset="/images/item-small.webp">
<img src="/images/item-large.webp" width="1200" height="900"
alt="商品の全体が分かる正面写真" fetchpriority="high">
</picture>
接続の準備やpreloadは、必要性が確認できた資源だけに絞ります。例のドメインは架空です。preloadしたCSSは、それだけでは適用されません。画像のpreloadも実際に選択される候補と違えば余分な転送になり得ます。
<link rel="dns-prefetch" href="//static.example.test">
<link rel="preconnect" href="https://static.example.test" crossorigin>
<link rel="preload" href="/styles/landing.css" as="style">
<link rel="stylesheet" href="/styles/landing.css">
キャッシュは未命中と失敗を扱う
Service Workerの公式解説に沿った、固定の公開ファイルだけを対象にする教材です。インストール時に用意したものはcache-first、未命中はネットワークへ進みます。API、認証応答、POST、他オリジンは対象外です。const CACHE = 'public-assets-v1';
const ASSETS = ['/offline.html', '/styles/offline.css'];
self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)));
});
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (event.request.method !== 'GET' || url.origin !== self.location.origin ||
!ASSETS.includes(url.pathname) || url.search) return;
event.respondWith((async () => {
const cache = await caches.open(CACHE);
const hit = await cache.match(event.request);
if (hit) return hit;
const response = await fetch(event.request);
if (response.ok) await cache.put(event.request, response.clone());
return response;
})());
});
この断片だけでは登録処理や更新通知は完成しません。installの失敗、古いworkerが開いているタブを制御する期間、キャッシュの世代削除、未命中時のオフライン表示を別途設計します。ネットワーク失敗は成功応答に偽装せず、利用者へ状態を伝えます。個人別ページを安易に保存しないことも境界です。
タッチ操作を標準操作から組み立てる
押せる範囲とフォーカスを確保する
以下の44pxはこの教材の設計値で、WCAGの適合判定そのものではありません。隣接する操作との間隔、ズーム、文字拡大、重なる要素を含めて確認します。
.action-button {
min-inline-size: 44px;
min-block-size: 44px;
padding: .75rem 1rem;
touch-action: manipulation;
}
.action-button:focus-visible { outline: 3px solid #175a9b; outline-offset: 3px; }
普通のボタンにtouchstartとclickの両方で同じ送信を登録すると、一操作が二重処理になる恐れがあります。button要素の指・マウス・キーボードの起動を活かし、通常はclickを一つにします。非同期処理中の二重操作も抑止します。
export function bindAction(button, action) {
let busy = false;
const onClick = async () => {
if (busy) return;
busy = true; button.disabled = true;
try { await action(); }
catch { button.dataset.error = '再試行できます'; }
finally { busy = false; button.disabled = false; }
};
button.addEventListener('click', onClick);
return () => button.removeEventListener('click', onClick);
}
スワイプには取消と代替ボタンを付ける
次は横スワイプの方向だけを決める部分です。対象は同じpointer IDで追跡し、縦スクロールやpointercancelを横移動として確定しません。Pointer Eventsの取消を扱います。40pxは教材用の閾値で、実機で調整が必要です。
export function bindSwipe(area, move) {
let start = null;
const down = e => { if (e.isPrimary && e.button === 0) start = { id: e.pointerId, x: e.clientX, y: e.clientY }; };
const cancel = () => { start = null; };
const up = e => {
const saved = start; start = null;
if (!saved || saved.id !== e.pointerId) return;
const dx = e.clientX - saved.x, dy = e.clientY - saved.y;
if (Math.abs(dx) >= 40 && Math.abs(dx) > Math.abs(dy)) move(dx < 0 ? 1 : -1);
};
area.style.touchAction = 'pan-y';
area.addEventListener('pointerdown', down);
area.addEventListener('pointerup', up);
area.addEventListener('pointercancel', cancel);
return () => {
area.removeEventListener('pointerdown', down);
area.removeEventListener('pointerup', up);
area.removeEventListener('pointercancel', cancel);
};
}
「前へ」「次へ」の通常buttonも同じmoveへつなぎます。ポインターが領域外へ出たときの扱い、画面読み上げでの項目名、先頭・末尾の状態も含めて完成させます。ジェスチャーだけでしか操作できないUIにしません。
動きと非表示中の仕事を見直す
leftの変更とtransformの変更は同じ処理経路とは限りませんが、transformなら電池消費が一定割合減るとは言えません。レイヤーや描画も測定します。動きを減らす設定では遷移を止めます。
.drawer { transform: translateX(-100%); transition: transform 180ms ease; }
.drawer.is-open { transform: translateX(0); }
@media (prefers-reduced-motion: reduce) {
.drawer { transition: none; }
}
Page Visibility APIを使えば、タブが非表示の間のポーリングを止める方針を実装できます。focus、要素が画面内にあること、OSによるタイマー抑制とは別です。次はstart/stopを何度呼んでもタイマーが増えない教材です。
export function bindVisibility(doc, startWork, stopWork) {
let running = false;
const sync = () => {
const wanted = doc.visibilityState === 'visible';
if (wanted === running) return;
running = wanted;
if (wanted) startWork(); else stopWork();
};
doc.addEventListener('visibilitychange', sync); sync();
return () => {
doc.removeEventListener('visibilitychange', sync);
if (running) { running = false; stopWork(); }
};
}
通信中のキャンセルと再表示時の鮮度は呼出側で設計します。止めたことと、電池持ちの実測改善を分けて記録してください。
画面幅と拡大を両立する
モバイル向けのCSSを先に置き、必要な幅だけで列を増やす例です。900pxはこのレイアウトの仮定で、端末分類の普遍的な境界ではありません。長い日本語・URL・拡大時の折返しも確認します。
.catalog { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; }
.catalog > * { min-inline-size: 0; overflow-wrap: anywhere; }
@media (min-width: 900px) {
.catalog { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
ズームを禁止する指定は入れません。画面幅へ合わせることと、利用者が拡大できることを両立します。
<meta name="viewport" content="width=device-width, initial-scale=1">
設計の全体像はレスポンシブデザインで整理できます。
商品ページと広告領域の組み合わせ例
ECページでは、先頭の商品画像と、下部の詳細写真を分けます。次は後方にある詳細写真と、操作後に開くサイズ表の教材です。SizeGuideはdefault exportを持つ別ファイルを前提にし、読み込み失敗用のError Boundaryを上位に設けます。React lazyとNext.jsの遅延読み込みの対象を混同しません。
import { lazy, Suspense, useState } from 'react';
const SizeGuide = lazy(() => import('./SizeGuide'));
export function ProductDetails() {
const [open, setOpen] = useState(false);
return <section>
<picture><source media="(max-width: 600px)" srcSet="/images/detail-small.webp" />
<img src="/images/detail-large.webp" width="1000" height="750" loading="lazy" alt="生地の拡大写真" />
</picture>
<button type="button" onClick={() => setOpen(true)}>サイズ表</button>
{open && <Suspense fallback={<p>表を準備中</p>}><SizeGuide /></Suspense>}
</section>;
}
広告などの第三者処理は、必要な同意や掲載条件を確認した上で読み込みます。次は外部広告を呼ばない教材で、observeとloadを注入します。実際のIntersection Observerとの接続は別途行い、何度可視通知が来てもロードを重複させません。失敗時も自動の無限再試行はしません。
export function loadOnceWhenVisible(observe, load) {
let started = false;
const stop = observe(visible => {
if (!visible || started) return;
started = true;
Promise.resolve().then(load).catch(() => { /* 表示枠に再試行の案内を出す */ });
});
return stop;
}
広告枠の高さを先に確保し、読み込み後に本文が移動しないことを確認します。「25%改善」といった値を置く前に、通信・LCP・操作・レイアウト移動のどれを、何件・どの期間で比べたかを明記してください。
実務では対象URLと操作を一つ選び、低速回線だけでなくCPU負荷、再訪、オフライン、操作取消も確認します。変更後の継続観測はパフォーマンス監視、画像候補は画像最適化を参照できます。初学者はWeb制作入門から、設計との接続はWebサイト設計、調査の順序はパフォーマンス全体像へ進んでください。