作品の一覧から個別の表示へ切り替えるとき、ページを丸ごと読み込み直さずに URL だけを書き換える実装を入れました。アドレスバーには個別の URL が出て、戻るボタンも期待どおりに動きます。
ところが、スクロールしてあとから出てくるはずの画像だけが、いくつか 404 になりました。最初に見えていた画像は問題なく出ているので、確認も一度は「問題なし」と流しかけたほどです。
個人開発の小さなサイトでも起こりうる話ですので、原因が「相対パスの基準が、書き換えた URL に移っていた」ことだった経緯と、手元で再現した結果、直し方三種の違いを書き残します。
症状は「まだ読み込んでいない画像」にだけ出ました
壊れた画像には共通点がありました。ページを開いた時点で画面内にあった画像は表示され、スクロールして初めて近づく画像だけが出ない、という並びです。
ブラウザの開発者ツールで失敗した要求を見ると、URL が本来の場所と違っておりました。画像の置き場所が一階層ぶん深くなり、そこには当然ファイルがありません。
この時点で、キャッシュでも CDN でもなく、「どの URL を基準に相対パスを解決したか」が動いたのだと見当がつきました。以前、同一 URL で差し替えた資産が古いまま残る件では キャッシュを疑う順番 を整理しましたが、今回は取得先の URL そのものが違います。切り分けの入口はそこでした。
手元で再現する最小ページ
実サイトの条件を持ち込まず、必要な要素だけに削った確認ページを作りました。先頭に画面内の画像を一枚、空白を挟んで、あとから読み込む画像を二枚置きます。
一枚は HTML 標準の loading="lazy"、もう一枚は IntersectionObserver で data-src を src に入れ替える自前方式です。どちらも、読み込みの直前に history.replaceState で URL を深い階層へ書き換えます。
<! doctype html >
< html >< head >< meta charset = "utf-8" >< title >replaceState 再現</ title ></ head >< body >
<!-- 画面内の画像: 書き換え前に読み込みが始まる -->
< img id = "eager" src = "img/a.jpg" width = "100" height = "100" >
< div style = "height:3000px" >spacer</ div >
<!-- あとから読み込む画像 2 種 -->
< img id = "native" loading = "lazy" src = "img/b.jpg" width = "100" height = "100" >
< img id = "js" data-src = "img/c.jpg" width = "100" height = "100" >
< script >
// 一覧 /gallery/ から個別 /gallery/item/12/ へ、ページを読み直さず切り替える想定
history. replaceState ( null , '' , '/gallery/item/12/' );
const io = new IntersectionObserver ( es => es. forEach ( e => {
if ( ! e.isIntersecting) return ;
const img = e.target;
if (img.dataset.src) img.src = img.dataset.src; // ← ここで相対パスが解決される
io. unobserve (img);
}));
document. querySelectorAll ( 'img' ). forEach ( i => io. observe (i));
window. scrollTo ( 0 , document.body.scrollHeight);
</ script ></ body ></ html >
このページを /gallery/fix.html として配信し、Playwright の Chromium で開いて、実際にどの URL へ要求が飛んだかを数えます。見るのは見た目ではなく、応答の URL とステータスコードと naturalWidth です。
# run.py — 応答 URL とステータス、各画像の最終的な読み込み状態を表示する
import threading, http.server, socketserver, functools
from playwright.sync_api import sync_playwright
Handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory = "site" )
srv = socketserver.TCPServer(( "127.0.0.1" , 8811 ), Handler)
threading.Thread( target = srv.serve_forever, daemon = True ).start()
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
seen = []
page.on( "response" , lambda r: seen.append((r.url.split( "8811" )[ 1 ], r.status)))
page.goto( "http://127.0.0.1:8811/gallery/fix.html" )
page.wait_for_timeout( 1500 ) # 遅延読み込みが動き終わるのを待つ
print ( "url =" , page.url.split( "8811" )[ 1 ])
print ( "responses=" , [s for s in seen if "img" in s[ 0 ]])
print ( "imgs =" , page.evaluate(
"[...document.images].map(i => [i.id, i.currentSrc.split(location.host)[1], i.naturalWidth])" ))
browser.close()
何を解決するためのコードかというと、「見た目では判断せず、要求先の URL と naturalWidth の組で壊れ方を特定する」ためです。naturalWidth が 0 のまま残る画像は、読み込み自体が失敗しております。
再現した結果と、予想と違った点
手元のヘッドレス Chromium で、直し方を入れる前の状態を動かした結果です。
予想と違ったのは loading="lazy" の挙動です。私は「HTML に書いた src は、読み込まれる前に解決済みのはず」と思い込んでおりました。実際には、遅延された画像は読み込みを始める時点の基準 URL で解決されていました。自前の入れ替え方式だけの問題ではなかったのです。
もう一つ、画面内の画像は無傷でした。確認を上部だけで済ませると、まず見逃します。壊れるのは「書き換えより後に読み込まれる画像」だけ、という条件を覚えておくと、再現の手順が組み立てやすくなります。
なお、ここまでの結果は Chromium のみの確認です。他のブラウザの挙動は未確認ですので、お使いの対象環境でも同じ確認ページを一度走らせていただければと思います。
直し方三種を並べて試しました
同じページに三つの手当てを順に入れ、同じスクリプトで結果を見ました。
<!-- 方法 A: 書き換える前に、すべての画像のパスを絶対 URL に固定する -->
< script >
document. querySelectorAll ( 'img' ). forEach ( i => {
if (i.dataset.src) {
i.dataset.src = new URL (i.dataset.src, document.baseURI).href;
} else {
i. setAttribute ( 'src' , new URL (i. getAttribute ( 'src' ), document.baseURI).href);
}
});
history. replaceState ( null , '' , '/gallery/item/12/' ); // 固定したあとで書き換える
</ script >
<!-- 方法 B: head に base 要素を置き、基準を固定する -->
< head >< base href = "/gallery/" ></ head >
<!-- 方法 C: 画像のパスを最初から / で始まる絶対パスで書く -->
< img data-src = "/gallery/img/c.jpg" width = "100" height = "100" >
方法 A と方法 B は、三枚とも 200 になりました。順序が肝で、A は replaceState より前に固定しておかないと意味がありません。書き換えたあとで document.baseURI を読むと、すでに新しい URL を指しています。
方法 C は最も単純で、画像が一か所で管理されている場合には第一候補です。ただし、既存の多数のページで相対パスのまま書かれている場合は、一括で書き換える手間が増えます。
本番のページで壊れ方を数える検査スクリプト
最小ページで原因がわかっても、本番のページが同じ壊れ方をしているとは限りません。そこで、実ページを開き、URL を書き換える操作を実行し、スクロールを最下部まで進めてから、壊れた画像の枚数を数えるスクリプトを用意しました。
# audit.py — 書き換え後に、読み込めなかった画像と本来の場所とのずれを数える
import sys
from playwright.sync_api import sync_playwright
BASE = sys.argv[ 1 ] # 例: http://127.0.0.1:8811/gallery/
TRIGGER = sys.argv[ 2 ] # URL を書き換える操作のセレクタ。例: a.work-link
with sync_playwright() as p:
b = p.chromium.launch()
page = b.new_page( viewport = { "width" : 375 , "height" : 700 })
failed = []
page.on( "response" , lambda r: failed.append((r.status, r.url)) if r.status >= 400 else None )
page.goto( BASE )
page.click( TRIGGER ) # replaceState / pushState が走る
for _ in range ( 12 ): # 少しずつ下げて遅延読み込みを起動する
page.mouse.wheel( 0 , 600 )
page.wait_for_timeout( 250 )
broken = page.evaluate(
"[...document.images].filter(i => i.complete && i.naturalWidth === 0).length" )
total = page.evaluate( "document.images.length" )
print ( f "images= { total } broken= { broken } http_errors= { len (failed) } " )
for s, u in failed[: 10 ]:
print (s, u)
b.close()
sys.exit( 1 if broken or failed else 0 )
終了コードで合否が返るので、エージェントに「このスクリプトが 0 で終わるまで直してください」と頼めます。何を直すかを人が見張らなくても、画像の枚数と壊れた枚数という数字で止まる場所が決まります。
再現ページでの結果は、画像三枚のうち遅延読み込みの二枚が 404(壊れ率で言えば約 67%)、画面内の一枚は 200 でした。方法 A と方法 B のあとは三枚とも 200 で、broken=0 になります。
方法 B を使う前に知っておきたい副作用
base 要素は短く直せますが、同じページの他のリンクにも効いてしまいます。たとえば href="#section" のようなページ内リンクは、base を基準に解決されるため、意図せず別のページへ移動します。
私は、直すつもりで入れた手当てが別の症状を生むのを避けたいので、画像の数が限られるページでは方法 A か方法 C を選びます。副作用がありそうだと感じた手当ては、先に一枚の確認ページで試してから本番へ入れるのが、私の性に合っています。base 要素は、リンクの書き方まで棚卸しできるときに限って使います。
スタイルシート内の url() は、CSS ファイルの位置を基準に解決されるため、今回の書き換えの影響を受けません。画像が背景として CSS から読み込まれている場合は、まず別の原因を探してよいと思います。
どの直し方を選ぶか、私の推奨
三つを試して気づいたのは、「どれが正しいか」ではなく、「画像のパスをどこで管理しているか」で選ぶ道が決まるということでした。私が個人開発のサイトで採っている順番を、手順の形で残します。
画像のパスを書く場所が一か所なら、方法 C(ルートからの絶対パス)に統一します。これがもっとも壊れにくい形です
既存のページに相対パスが散らばっているなら、方法 A を、URL を書き換える処理の直前に一度だけ差し込みます
方法 B は、ページ内リンクの書き方を全部確認できる場合に限り、最後の手段として使います
どの方法でも、直したあとに検査スクリプトを走らせ、broken=0 を確かめてから公開します
おすすめしたいのは、まず方法 A を数行だけ入れて検査スクリプトが通るのを確認し、余裕のあるときに方法 C へ寄せていく進め方です。急いで全体を書き換えるより、手戻りが小さく済みます。
エージェントに確認を頼むときの指示文
Antigravity に「画像が表示されるか確認して」とだけ頼むと、最初に画面内へ出た画像を見て終わってしまうことがあります。次の三点を指示文に入れておくと、確認の範囲が揃いました。
履歴を書き換える操作のあとで、スクロールして初めて読み込まれる画像まで対象にすること
見た目ではなく、応答の URL とステータスコード、naturalWidth の三点を出力すること
404 があれば、その URL が本来の置き場所の何階層ぶんずれているかを併記すること
画像の検証そのものを別タブの iframe で行うと、また別の誤診を起こします。そちらは 実ページで currentSrc を見る手順 に書き残しておりますので、併せてお読みください。
つまずきやすい点
書き換えの順序 : 絶対化は replaceState や pushState の前に行います。後で行うと、基準がすでに動いています
戻るボタン : popstate で一覧に戻ったとき、一覧側の相対パスは元の基準に戻るので、画像が表示されることを個別に確認します
srcset と sizes : srcset の候補も同じ基準で解決されます。方法 A で固定するときは、srcset の各 URL も対象に含めます
確認は一回で終えない : 画面内の画像、loading="lazy"、自前方式の三種を、それぞれ書き換えの前後で見ます
次に試すこと
自分のページで、URL を書き換える箇所を一つ探してみてください。その直前に、方法 A の数行を差し込み、先の確認スクリプトで 404 が消えるかを見るだけで、この種の事故はほぼ防げます。
今回の確認ページは、小さな動作の食い違いを、見た目でなく数字で追うための道具として手元に置いております。同じ形のページを一枚作っておくと、次の不具合でも切り分けが速くなります。