画像・添付ファイルと画面性能を構成する
原本と表示用ファイルを分け、表示サイズに合う画像を配信する。転送量・応答時間・表示・操作を別々に測り、遅い箇所を探す。
画像ストレージ・圧縮・ブラウザーの要求を構成する
開館時間をポスター画像だけに載せると、テキストを読むツールは本文から曜日や例外を得にくい。ポスターのURLまで失効すると、画像自体も取得できない。開館時間と休館日をHTMLで書き、写真にはアクセスできるURLと説明を付ければ、テキストと画像のそれぞれを読む経路ができる。 Googleの画像ガイド
オブジェクトストレージ(object storage)は画像や添付ファイルをキーとファイル内容で保存するサービスだ。バケット(bucket)はファイルをまとめる保存単位だ。DBにはファイルの場所・説明・公開状態を、ストレージには原本と表示用の派生ファイルを置ける。署名付きURL(signed URL)は署名と有効期限の条件によってアクセスを許可するURLだ。公開ページに短期間で失効するURLを固定保存すると、後のクロール時に画像が取得できなくなる場合がある。
WebP・AVIFは画像ファイル形式だ。エンコード(encoding)はピクセルをファイル形式へ変換し、デコード(decoding)は表示するピクセルへ戻す処理だ。EXIFは写真の向きや撮影情報などの付加情報だ。ハッシュ(hash)は内容から計算する識別値で、原本の変更と派生ファイルの再利用を区別するために使える。
srcは基本の画像URL、srcsetはサイズ別の候補URL、sizesは画面配置で想定する表示幅だ。DPR(Device Pixel Ratio)はCSSピクセル1個に対応するデバイスピクセルの比率だ。同じ表示幅でもDPRによって大きな候補が選ばれる場合がある。width・heightやaspect-ratioで場所を先に確保すると、画像読込後に周囲の文章がずれる現象を減らせる。
Brotli・gzipはHTML・CSS・JavaScriptなどの転送内容を圧縮する方式だ。サーバーやCDNが交渉したContent-Encodingで配信する。圧縮済み画像のファイルサイズは画像変換の過程で減らす。最初の画面の主要画像を遅延読込すると表示が遅れる場合があるため、最初に見える画像と下部の画像を分けて要求時点を確認する。
CORS(Cross-Origin Resource Sharing)は、ブラウザーが別オリジンの応答をJavaScriptで読めるか制御する規則だ。サーバー間の取得や通常の文書アクセスの公開可否とは異なる。公開APIをブラウザーの別ドメインから呼ぶ必要があれば許可するオリジンを設定し、認証が必要なAPIには別に権限検査を適用する。
応答時間・画面性能・エラー率の意味を理解して測る
TTFB(Time To First Byte)は要求開始から最初の応答バイトが届くまでの時間だ。DNS照会・接続・ネットワーク往復・サーバー処理などが影響する。遅い場所を探すには、サーバー処理時間をアプリケーションログから別に読む必要がある。
LCP(Largest Contentful Paint)は画面上の大きな主要コンテンツが表示される時点、CLS(Cumulative Layout Shift)は予期しない配置の移動を表す。INP(Interaction to Next Paint)はクリック・タップ・キーボード入力から次の画面描画までの応答遅延を、ページ訪問全体で観測した値だ。通常は最も長い遅延を使い、操作50回ごとに最高値を1個除外して極端な値を扱う。1回のクリックの遅延と訪問全体のINPを区別する。複数の訪問のINPを集めたp75は、さらに別の集計段階だ。 INPの定義 · Web Vitals
ラボデータ(lab data)は決めた機器・ネットワーク条件で行う検査結果だ。フィールドデータ(field data)は実際の利用環境で集めた測定値だ。p75・p95は観測値を小さい順に並べたときの75%・95%の位置の値だ。平均とともに分布を見ると、一部の要求だけが極端に遅い問題を探せる。機器・期間・標本数も保存する。
エラー率は対象要求のうち失敗した要求の割合だ。5xx、タイムアウト、WAFの遮断を分けて集計する。キャッシュヒット率は、キャッシュを調べた要求のうち保存済み応答で処理した割合だ。ヒット率が高くても古い記事を返す場合があるため、応答のrevisionも読む。
検索から訪れた人が大きな画像を待ったり、読込中に申請ボタンがずれたりすると、情報を読んで行動する過程が妨げられる。LCP・INP・CLSはこの体験の異なる側面を測る。GoogleはCore Web Vitalsを順位システムで使うと説明している。生成型回答の引用を判断するときは実際の引用結果を別に読み、画像へのアクセス・文章による説明・利用体験のどれを変えたか区別する。 ページ体験と検索
CMSの画像配信とキャッシュを公開方針に合わせる
小さく表示した写真でも、原本全体をダウンロードする場合がある。開発者ツールのNetworkで実際の要求URL・応答バイト数・MIME・ピクセル寸法を読み、表示幅と比べる。静的写真は最適化されていてもCMS写真だけが原本を返していないか、srcsetとsizesが実際に出力されているか、ブラウザーがどの候補を選んだか確認する。
原本は根拠資料として保存し、表示用の派生画像を別に作る。変換器には入力容量・ピクセル数・許可形式・処理時間の上限を設ける。向きの情報を適用してから縮小し、WebPやAVIFなどの対応形式へエンコードする。原本より拡大せず、透明度やアニメーションの処理も確認する。EXIFの削除が必要なら公開用派生ファイルに適用し、原本を上書きしない。
派生ファイルのキーには原本のハッシュ・出力幅・エンコーダー設定の版を含める。同じ写真の変換結果を再利用しつつ、原本の交換や画質設定の変更後に古いファイルを誤って再利用しないようにする。対応する幅と画質は、実際の表示サイズ・DPR・見た目を比べて決める。カード・本文・拡大表示のサイズが違えば、必要な派生ファイルの幅も異なる。
画面にはimgのsrcset・sizesか、設定した画像コンポーネントを使う。sizesは実際のグリッドで画像が占める幅に合わせる。全カードに小さな幅を指定すると大画面でぼやけ、常に画面全幅を指定すると不要に大きな画像を受け取る。width・heightやaspect-ratioで場所も確保する。 Googleの画像ガイドに従い、srcの代替URL、代替テキスト、本文の文脈を提供する。
next/imageのunoptimizedは内蔵の画像変換を省く設定だ。loaderは指定した幅と画質に合う画像URLを作る関数だ。独自loaderが派生画像を配信するか、認証が必要な原本や原本を直接指定する別のコンポーネントがないか調べる。Networkで選ばれた派生ファイルの幅・容量・形式を比較する。
キャッシュは変換結果の再利用とアクセス制御を分けて設計する。撤回後すぐに遮断する必要がある資料なら、非公開ストレージの派生ファイルを再利用しながら、要求ごとに現在の公開状態と権限を確認できる。公開CDNに長く残るimmutable応答では、撤回後もファイルが配信される場合がある。原本・派生ファイル・通常の画像最適化URLに同じ撤回条件を適用し、アクセス検査を迂回するURLがないか確認する。
Next.jsの標準画像最適化機能は原本への要求に認証ヘッダーを渡さない。認証が必要な原本は要求の構造を確認し、公開画像用の別経路や既存画像サービスの派生ファイルを使える。unoptimizedを指定した場合、srcが小さな派生ファイルか大きな原本かによって転送量が変わる。loaderが/media/photo?w=640という文字列を作っても、サーバーが実際に640pxのファイルを作らなければ容量は減らない。 Next.js Imageの設定
画像転送量と実際の読込時間を分けて測る
画像修正の前後は、同じページ・画面幅・DPR・スクロール範囲・キャッシュ条件で比べる。要求した写真数、選ばれたsrcset候補、応答バイト数、画像デコード、原本との見た目の差を記録する。初回訪問と再訪問を混ぜると、圧縮とキャッシュの効果を区別しにくい。
転送量の減少はダウンロードしたファイルサイズの結果だ。主要コンテンツの表示時点LCP、応答性INP、配置移動CLSは別々に測る。最初の大きな画像を縮小しても、サーバー応答や別の資源がボトルネックならLCPが期待ほど短くならない場合がある。Lighthouseは指定した検査環境で測定し、実利用者のデータは多様な機器・ネットワークから集まる。両者を分けて保存し、遅い箇所を探す。
フィールドデータには提供期間とページ・サイト単位の集計範囲を書く。資料不足で値がなければ未確認と記録する。配信直後の転送量確認と、その後に集計する利用体験、検索表示・クリックを分けて比べる。検索表示・クリックとAI引用は、それぞれの観測表で前後の値を比較する。
ブラウザーが選んだ画像URLを直接確認する
make-image-demo.mjsは図形画像2枚とHTML1枚を作る。画像幅は640px・1280px、形式はWebPだ。Next.jsフォルダーでnpm install --save-dev sharp@0.35.5を実行し、続いてnode make-image-demo.mjsを実行する。ファイル生成後にサーバーを起動する。すでに動いている場合は再起動してpublicファイルを認識させる。
// インストール: npm install --save-dev sharp@0.35.5
// 実行: node make-image-demo.mjs (Next.js実習プロジェクトのルート)
import fs from 'node:fs/promises';
import sharp from 'sharp';
await fs.mkdir('public/photos', { recursive: true });
const source = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720">
<rect width="1280" height="720" fill="#e8eff5"/>
<path d="M160 540V180H1120V540Z" fill="#31516b"/>
<path d="M240 260H1040M240 360H1040M240 460H1040" stroke="#e8eff5" stroke-width="32"/>
</svg>`);
for (const width of [640, 1280]) {
await sharp(source).resize({ width }).webp({ quality: 80 }).toFile(`public/photos/shelves-${width}.webp`);
}
await fs.writeFile('public/image-demo.html', `<!doctype html>
<html lang="ja"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>画像サイズ選択の実習</title>
<style>body{margin:16px}main{max-width:960px;margin:auto}img{display:block;width:100%;height:auto}</style>
<main><h1>画像サイズ選択の実習</h1>
<img src="/photos/shelves-640.webp"
srcset="/photos/shelves-640.webp 640w, /photos/shelves-1280.webp 1280w"
sizes="(max-width: 992px) calc(100vw - 32px), 960px"
width="1280" height="720" alt="3段の棚を描いた実習用の図形"
fetchpriority="high">
<p>サイズ別のファイル選択を確認する実習用の図形です。</p>
</main></html>`);
console.log('Created public/image-demo.html and two WebP fixtures');
BufferはSVG文字列をファイル処理へ渡すバイトにし、sharpのresize・webp・toFileは各幅の派生ファイルを保存する。HTMLのsrcは基本URL、srcsetは候補と実際のピクセル幅、sizesはCSS配置での表示幅だ。bodyの左右16pxとmainの最大960pxをsizesに合わせた。width・heightは16:9の領域を先に確保する。
実習サーバーの/image-demo.htmlを390pxと1440pxの画面で開く。開発者ツールのConsoleでdocument.querySelector("img").currentSrc、naturalWidth、getBoundingClientRect().widthを確認する。Networkで実際の要求ファイルと転送量を読む。DPRやキャッシュで候補選択が変わるため、狭い画面でも大きなファイルを再利用する場合がある。新しいコンテキストを使い、同じDPR・キャッシュ条件で比べる。
最初の画面の主要画像にはloading="lazy"を付けていない。下部の写真にはlazyを適用し、スクロール前後の要求時点を確認できる。altは画像の意味を説明し、装飾だけの画像は空のaltを使う。候補が404ならpublicのパスとsrcsetを、表示幅より大きなファイルを受け続けるならsizesと実際の配置を照合する。
srcsetとsizesによってブラウザーが表示に必要な候補を選べる。390px画面で大きな原本の代わりに適切な派生ファイルを受け取れば転送量を減らせ、width・heightは画像到着前に場所を確保する。このコードによる変化はcurrentSrc・応答バイト数・配置移動で確認する。時間表の内容が画像にしかなかった問題は、別の本文説明で直す。
同じCMS画像の原本要求と派生ファイル要求を比べる
前の静的ファイル実習では事前生成したファイルを選んだ。次の実習ではCMS画像IDのphotoを要求すると公開状態を確認し、派生ファイルを生成または再利用する。HTMLは同じ配置を保ち、原本表示では原本URL、派生ファイル表示ではsrcset候補を出力する。比べると、CSSで小さく見える写真が実際にも小さなファイルか分かる。
空のフォルダーに公開データ編のmedia-store.mjs、文書と時間編のidentity-time.mjs、実装編のarticle-jsonld.mjs、URLとクロール編のfeed.mjsを保存する。以下をmedia-demo.mjsとして保存する。Node.js 22とsharp 0.35.5を使い、既存のNext.js実習とは別のフォルダーで実行する。
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import sharp from 'sharp';
import { createMediaStore } from './media-store.mjs';
import { article, renderArticle, escapeHtml } from './article-jsonld.mjs';
import { renderFeed } from './feed.mjs';
export async function sampleImage() {
// 結果を再現できる図形。実際の写真は実行引数に渡す。
const rectangles = Array.from({ length: 1600 }, (_, i) =>
`<rect x="${(i * 67) % 1600}" y="${(i * 43) % 1000}" width="55" height="55"
fill="hsl(${i % 360},70%,50%)"/>`).join('');
return sharp(Buffer.from(`<svg width="1600" height="1000">${rectangles}</svg>`))
.jpeg({ quality: 95 }).toBuffer();
}
export function createDemoServer(store, origin = 'http://127.0.0.1:3031') {
return http.createServer(async (req, res) => {
const send = (status, type, body, extra = {}) => {
res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'private, no-store, max-age=0', ...extra });
res.end(body);
};
try {
const url = new URL(req.url, origin);
if (req.method !== 'GET') return send(405, 'text/plain', 'Method not allowed');
if (url.pathname === '/media/photo') {
const width = url.searchParams.has('w') ? Number(url.searchParams.get('w')) : null;
const r = await store.read(width);
return send(r.status, 'text/plain', r.body, r.headers);
}
if (url.pathname === '/rss.xml') {
// この実習では記事と写真1枚を一緒に公開・撤回する。
const visible = (await store.read()).status === 200;
return send(200, 'application/rss+xml; charset=utf-8', renderFeed(visible ? [article] : [], origin,
new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')));
}
if (url.pathname === '/video.html' || url.pathname === '/talk.mp4') {
const body = await readFile(new URL('.' + url.pathname, import.meta.url));
if (url.pathname.endsWith('.html')) return send(200, 'text/html; charset=utf-8', body);
const headers = { 'Accept-Ranges': 'bytes', 'Content-Length': body.length };
if (!req.headers.range) return send(200, 'video/mp4', body, headers);
// 小さなローカル動画の単一byte range。運用ではファイルストリーム・メディアサーバーを使う。
const match = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range);
let start = match?.[1] ? Number(match[1]) : Math.max(0, body.length - Number(match?.[2]));
let end = match?.[1] && match[2] ? Math.min(Number(match[2]), body.length - 1) : body.length - 1;
if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(start)
|| !Number.isSafeInteger(end) || start > end || start >= body.length)
return send(416, 'text/plain', '', { 'Content-Range': `bytes */${body.length}` });
return send(206, 'video/mp4', body.subarray(start, end + 1), {
...headers, 'Content-Length': end - start + 1,
'Content-Range': `bytes ${start}-${end}/${body.length}`,
});
}
if (url.pathname !== '/' && url.pathname !== article.path) return send(404, 'text/plain', 'Not found');
const candidates = await Promise.all([320, 640, 1280].map(async width => {
const r = await store.read(width);
return { ...r, path: `/media/photo?w=${width}` };
}));
if (candidates.some(c => c.status !== 200)) return send(404, 'text/plain', 'Not found');
const largest = candidates.at(-1);
if (url.pathname === article.path)
return send(200, 'text/html; charset=utf-8', renderArticle(origin, largest).html);
// 小さな原本は拡大しない。同じ幅になった候補は一つだけ残す。
const unique = [...new Map(candidates.map(c => [c.width, c])).values()];
const srcset = unique.map(c => `${c.path} ${c.width}w`).join(', ');
const original = url.searchParams.get('mode') === 'original';
return send(200, 'text/html; charset=utf-8', `<!doctype html><html lang="ja"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>CMS画像配信の実習</title><link rel="alternate" type="application/rss+xml" href="/rss.xml">
<style>body{margin:16px;font-family:sans-serif}img{display:block;width:min(100%,640px);height:auto}</style>
</head><body><h1>${original ? '原本' : '派生ファイル'}の配信</h1>
<p><a href="/?mode=original">原本を見る</a> · <a href="/">派生ファイルを見る</a>
· <a href="${article.path}">記事</a> · <a href="/video.html?t=4">動画の4秒位置</a></p>
<img src="${original ? '/media/photo' : largest.path}"
${original ? '' : `srcset="${escapeHtml(srcset)}" sizes="(max-width:672px) calc(100vw - 32px), 640px"`}
width="${largest.width}" height="${largest.height}" alt="画像配信の比較用図形">
</body></html>`);
} catch (error) {
send(error.code === 'ENOENT' ? 404 : 500, 'text/plain', 'Resource unavailable');
}
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const source = process.argv[2] ? await readFile(process.argv[2]) : await sampleImage();
const store = await createMediaStore(source);
createDemoServer(store).listen(3031, '127.0.0.1', () => console.log('http://127.0.0.1:3031'));
}
npm init -y
npm install sharp@0.35.5
node media-demo.mjs
# 別のターミナルで応答を比較
curl -sS -D original-headers.txt http://127.0.0.1:3031/media/photo -o original.jpg
curl -sS -D variant-headers.txt "http://127.0.0.1:3031/media/photo?w=640" -o variant.webp
# サーバー終了後、自分の写真でも実行できる。
# node media-demo.mjs ./photo.jpg
sampleImageは1600×1000の図形をJPEGで作る。自作写真のパスを実行引数に渡すと、同じ経路で写真を処理する。サーバーは127.0.0.1だけに接続し、外部公開のアップロード機能はない。/media/photoは原本、?w=640は派生ファイル、/news/new-hoursは記事、/rss.xmlはフィードだ。動画リンクは次のコンテンツ編の2ファイルとMP4を準備すると開ける。
候補幅は320・640・1280で、sizesは左右合計32pxの余白と最大表示幅640pxを反映する。390px画面での表示幅は358px、DPR 2では約716ピクセルが必要だ。そのため狭い画面でも1280の候補を選ぶ場合がある。選択にはブラウザーの判断、キャッシュ、接続条件も影響するため、画面幅だけで320のファイルを期待してはいけない。
小さな原本では複数の候補が同じ実幅になる場合がある。uniqueは実幅が同じ候補を一つにまとめる。srcsetにはその結果の幅を、imgのwidth・heightには実際の比率を使う。画面サイズを変えた後はcurrentSrcだけでなく、応答バイト数とファイルメタデータの幅も比べる。
このサーバーは実際の出力幅を得るため、HTML生成前に3つの派生ファイルを準備する。運用CMSではアップロード時に派生ファイルと寸法を保存しておけば、最初のページ要求で3回の変換を待たずに済む。アップロード完了と公開可能時点を結び付け、変換失敗時に使う画像も決める。写真を替えたら代替テキストと説明も更新する。
この実習は記事と写真1枚を一緒に公開する資料として扱う。写真を撤回すると記事とホームページは404になり、RSSからも記事を除く。実際のCMSで写真だけを替えられる記事なら、本文と画像の公開状態を別々に保存し、非公開写真を除いた本文を配信するか決める。
ファイルサイズとダウンロード・表示時点を同じ実行で記録する
次のinspect-image.mjsは、新しいブラウザーコンテキストで390・1440px、DPR 1・2、原本・派生ファイルの8組合せを実行する。応答bodyのバイト数を数え、sharpで実際のファイル幅を読む。ブラウザーでは表示幅・currentSrc、Resource Timing、LCP候補を読む。img.decode()完了を待ち、フレームを進めて表示結果を確認する。
測定前にホームページを1回要求してサーバーの派生ファイルを準備する。ブラウザーコンテキストは毎回新しく作り、以前の画像選択やブラウザーキャッシュの影響を減らす。初回変換のコストを調べるには、サーバーを再起動して最初の要求のサーバー処理時間も別に記録する。
import { chromium } from 'playwright';
import sharp from 'sharp';
const browser = await chromium.launch();
const rows = [];
try {
// サーバーの派生ファイル生成は完了させる。ブラウザーコンテキストは毎回新しく作る。
const warm = await fetch('http://127.0.0.1:3031/');
if (!warm.ok) throw new Error('Image server unavailable');
await warm.text();
for (const width of [390, 1440]) for (const dpr of [1, 2]) for (const mode of ['original', 'variants']) {
const context = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: dpr });
try {
const page = await context.newPage();
await page.addInitScript(() => {
window.imageLcp = [];
new PerformanceObserver(list => {
window.imageLcp.push(...list.getEntries().map(e => ({ time: e.startTime, url: e.url, tag: e.element?.tagName })));
}).observe({ type: 'largest-contentful-paint', buffered: true });
});
const imageResponse = page.waitForResponse(r => new URL(r.url()).pathname === '/media/photo');
await page.goto('http://127.0.0.1:3031/' + (mode === 'original' ? '?mode=original' : ''));
const response = await imageResponse;
const bytes = await response.body();
const metadata = await sharp(bytes).metadata();
await page.locator('img').evaluate(img => img.decode());
// 2フレーム待ち、decode後の表示とLCP項目を読む。
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const observed = await page.locator('img').evaluate(img => {
const resource = performance.getEntriesByName(img.currentSrc).at(-1);
return { currentSrc: img.currentSrc, cssWidth: img.getBoundingClientRect().width,
naturalWidth: img.naturalWidth, dpr: devicePixelRatio,
resource: resource && { start: resource.startTime, responseStart: resource.responseStart,
responseEnd: resource.responseEnd, encodedBodySize: resource.encodedBodySize, transferSize: resource.transferSize },
lcp: window.imageLcp.at(-1) ?? null };
});
rows.push({ width, mode, ...observed, bodyBytes: bytes.length,
fileWidth: metadata.width, fileHeight: metadata.height, format: metadata.format,
cacheControl: response.headers()['cache-control'] });
} finally { await context.close(); }
}
} finally { await browser.close(); }
console.log(JSON.stringify(rows, null, 2));
# 画像サーバーを起動したまま、同じ実習フォルダーの別ターミナルで実行
npm install --save-dev playwright@1.59.1
npx playwright install chromium
node inspect-image.mjs > image-observations.json
bodyBytesは受信した画像本文の容量、fileWidthはファイルのピクセル幅、cssWidthは表示幅だ。responseStartからresponseEndまでは応答の受信区間、startからresponseStartまでは要求準備やサーバー待機などを含む。transferSizeはブラウザーが報告したヘッダー込みの転送量だ。リモートURLではTiming-Allow-Originなどの条件によって一部の値が0になる場合がある。
LCPのtagとurlを一緒に読み、最大要素が写真か見出しか確認する。写真のダウンロードが減っても、HTML応答や画像要求の発見が遅ければ全体の表示遅延は残る。このコードはローカル機器で8回の実行結果を保存する。運用速度を比べるには、同じブラウザー・機器・ネットワーク・キャッシュ条件で複数回実行し分布を読む。デコード時間やメインスレッド遮断の詳細原因は、ブラウザーのPerformance記録で確認する。
TBTはFCP(最初のコンテンツ表示)後の観測区間で、メインスレッドの処理が50msを超えた部分を合計した値だ。120msの処理1件は70msの遮断時間を生む。INPはクリックや入力などの操作から画面が反応するまでの遅延を評価する。TBTが低くても、後で開くメニューの処理関数が重ければ操作は遅くなりうる。読込記録と実際の操作を別々に調べる。 TBTとINPの関係
フォントが遅れて届くと文字表示が待たされたり、代替フォントから切り替わって改行位置が動いたりする。font-display: swapは短い待機後に代替フォントを使い、使用フォントのpreloadは要求を早く始める。代替フォントと本文フォントの幅が違えばCLSは残る。最初の画面で使うファイルだけを先読みし、モバイルで実際のフォント要求・文字表示・改行移動を確認する。 font-displayの動作
この図形をローカルで実行した1標本では、原本は238,236バイト、640pxのWebPは28,676バイト、1280pxのWebPは49,546バイトだった。390px・DPR 1では640px、同じ画面・DPR 2では1280pxを受け取った。同じ原本でも画面の物理ピクセル要件によって転送量が変わる例だ。写真・画質設定・エンコーダーが変わったらファイルサイズを測り直す。