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' });
});
});
実装上の注意:
-
a.hrefではなくa.getAttribute('href')を使う。前者は base で解決済みの絶対URLになっており、id を取り出せない -
getElementByIdが空振りしたらpreventDefault()を呼ばずに抜ける。無関係なリンクの既定動作を奪わない -
href属性自体は消さない。JS無効環境でもリンクの意味は残る - 要素が動的に増えるページなら、親要素に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/