0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ページ内リンクが別ページへ飛ぶ原因は <base href> だった — 切り分けと4つの直し方

0
Posted at

TL;DR

  • ページ内リンク(href="#id")を押すと、スクロールせず別のURLへ遷移する
  • 犯人は <base href>。#id も相対URLなので、今のページではなく base のURL基準で解決される
  • 元のHTMLに <base> が書かれていなくても、表示する側が差し込んでいることがある
  • 切り分けはコンソールで document.baseURI を1行打つだけ

起きたこと

HTMLで作った一覧ページに、見出しへ飛ぶ目次リンクを足した。ローカルでファイルを開いて押すと期待どおりスクロールする。ところが本番の閲覧経路(別のHTMLを包んで表示するビューア経由)で開くと、押した瞬間に403のエラー画面へ飛んだ。

自分のHTMLには <base> を書いていない。書いていないのに効いていた。包んで表示する側が <head> に差し込んでいたからだ。

仕組み

<base href> は、そのページ内の相対URLの解決基準を決める。

<base href="https://example.com/assets/">

これがあると <img src="logo.png"> は https://example.com/assets/logo.png を読む。別の場所にあるHTMLを包んで表示する仕組みが、画像やCSSのパスをまとめて通すために使うことが多い。

問題は href="#install" も相対URLだという点。ブラウザは次のように解決する。

条件 今のページ href="#install" の解決結果
base なし https://example.com/docs/page.html https://example.com/docs/page.html#install
base あり(/assets/) https://example.com/docs/page.html https://example.com/assets/#install

解決結果が今のページと異なるURLになるため、ブラウザはクリックをページ遷移として処理する。遷移先が存在しない/直接アクセスを拒否する場所なら404・403になる。href="#" も同様に壊れる。

切り分け手順

1. base が効いているかを見る

document.baseURI              // アドレスバーのURLと一致しなければ base が効いている
document.querySelector('base') // null なら base タグは無い

ソースをgrepするだけでは足りない。表示時に差し込まれるケースがあるので、実際にレンダリングされた状態で確認する。

2. リンクの解決先を見る

const a = document.querySelector('a[href="#install"]');
a.getAttribute('href'); // "#install"(書いたままの値)
a.href;                 // 解決後の絶対URL ← ここが今のページと違えばアウト

3. base 以外の原因も一応潰す

症状 原因の候補 確認
遷移せずスクロールもしない href と id の不一致(大文字小文字・全角半角・空白) document.getElementById('install') が null でないか
意図と違う見出しへ飛ぶ 同じ id が複数ある document.querySelectorAll('#install').length === 1
見出しが固定ヘッダーに隠れる レイアウトの問題 見出しに scroll-margin-top を指定
別URLへ遷移する base タグ document.baseURI
何も起きない(id は正しい) 他のスクリプトがクリックを横取り DevTools の Event Listeners を確認

対処

A. base を外す

一番素直。ただし包んで表示する側が base 前提で画像・CSSのパスを通しているなら、外すと別の場所が壊れる。

B. JavaScriptでスクロールを肩代わりする

base を外せないときはこれ。私はこの方法を採った。

document.querySelectorAll('a[href^="#"]').forEach(function (a) {
  a.addEventListener('click', function (e) {
    var id = decodeURIComponent(a.getAttribute('href').slice(1));
    var target = document.getElementById(id);
    if (!target) return;       // 対象が無いときは既定の動作に任せる
    e.preventDefault();
    target.scrollIntoView({ behavior: 'smooth', block: 'start' });
  });
});

実装上の注意:

  1. a.href ではなく a.getAttribute('href') を使う。前者は base で解決済みの絶対URLになっており、id を取り出せない
  2. getElementById が空振りしたら preventDefault() を呼ばずに抜ける。無関係なリンクの既定動作を奪わない
  3. href 属性自体は消さない。JS無効環境でもリンクの意味は残る
  4. 要素が動的に増えるページなら、親要素に1つだけリスナーを付けて委譲する

欠点は、アドレスバーに #id が残らないこと。

C. location.hash に代入する

URLに #id を残したい場合は、Bの scrollIntoView の行をこうする。

location.hash = id;

location.hash は表示中ページのURLそのものを書き換えるので <base> の影響を受けない。

D. href を絶対URLにする

href="https://example.com/docs/page.html#install" のようにページ自身のURLを含めて書く。ページのURLが固定なら有効だが、?file=... のようなクエリ付きで閲覧するページだと、クエリが1文字違うだけで別ページ扱いになり読み込み直しが起きる。

検証環境に base を再現する

今回の本当の失敗は、直したことよりも確認のしかたにあった。ローカルでHTMLを直接開いて「スクロールするからOK」と判断したが、ローカルには base が無いので当然動く。壊れるのは本番経路だけだった。

修正後は、検証用コピーの <head> 直後に本番と同じ形の base を入れてから試した。

<head>
  <base href="/files/">

この状態でリンクを押し、アドレスバーのURLが変わらずスクロールだけ起きることを確認する。包む側が差し込むタグやスタイルがあるなら、それを再現していない検証は検証になっていない。

まとめ

ページ内リンクが効かないとき、まず id の綴りを疑いたくなる。ただし「押した瞬間にページが読み込み直される」なら、原因は <head> 側にある。document.baseURI を一行打てば、id の問題か base の問題かはすぐ分かれる。


同じ題材をAIが一人称で書いたブログ版もあります: https://kujiragames.com/2026/09/base-href-anchor-links/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?