多くの SaaS / Web 製品では、次のような典型的な要求に直面します:
ユーザーがボタンをクリック → フロントエンドはまずインターフェースを呼び出して動的 URL を取得する必要がある → それを新しいタブで開く。
簡単に見えますが、もし直接次のように書くと:
const url = await fetch('/api/url')
window.open(url)
あなたは 100% 問題に直面します:
- ブラウザはポップアップをブロックします(
awaitがユーザーのジェスチャーを失わせるため) - 新しく開いたページは
window.openerを通じてあなたのサイトを逆に制御できるかもしれません - リクエストが遅いまたは失敗した場合、ユーザーに「応答なし」の感覚を与えます
- 一部のブラウザは
window.openを無視します
これらは明示的なエラーメッセージではなく、非常に隠れたエンジニアリングの罠です。
この記事では、実践で検証された汎用的な安全策を紹介します:
非同期で URL を取得する場合でも、ポップアップのブロックを回避し、noopener,noreferrerの安全な隔離を維持する方法です。
1. なぜブラウザにブロックされるのか?#
ブラウザは同期のユーザーのジェスチャー(user gesture)イベントの中でのみ新しいウィンドウを開くことを許可します。例えば:
clickハンドラー内の同期コードonTouchStart内の同期コード
一度次のようなコードが現れると:
await ...
setTimeout(...)
Promise.then(...)
ブラウザはこのポップアップがユーザーによってトリガーされたものではないと見なします → ブロックされます。
これが「ボタンをクリックした後に反応がない」という根本的な理由です。
2. 汎用的な解決策:プレースホルダーウィンドウ(placeholder tab)#
ユーザーのジェスチャーを保持しつつ、非同期 URL を待つために、私たちは一つのテクニックが必要です:
✔ 手順:#
- 同期段階で空白のウィンドウを開く(ジェスチャーを保持)
- 非同期で URL をリクエスト
- 成功 → 空のウィンドウをターゲット URL にリダイレクト
- 失敗 → 空のウィンドウを閉じ、ゴミのタブを残さない
コード例(汎用的な最小実装)#
const handleClick = async () => {
// 1. 同期プレースホルダーウィンドウ
const placeholder = window.open('', '_blank', 'noopener,noreferrer')
try {
// 2. 非同期でURLを取得(例)
const res = await fetch('/api/target-url')
const { url } = await res.json()
// 3. URLが正常な場合はリダイレクト
if (url) {
placeholder.location.href = url
return
}
} catch (err) {
console.error('URLの読み込みに失敗しました', err)
}
// 4. 取得に失敗した場合、プレースホルダーウィンドウを閉じる
placeholder?.close()
}
なぜこうするのか?#
- ブロックされない:プレースホルダーウィンドウは同期コード内で開かれています
- 十分に安全:常に
noopener,noreferrerを使用 - 体験が良い:失敗時に空白のタブが残ることはありません
- メンテナンスが容易:新しいタブを開く関連のロジックが一つの関数に集中しています
3. noopener /noreferrer の意義#
デフォルトでは:
window.open(url)
開かれたページは次にアクセスできます:
window.opener
それによってあなたのページを逆に制御することができます:
- あなたの URL を変更する(タブナビゲーション攻撃)
- 悪意のあるスクリプトを注入する
- 偽のサイトにログインを模倣してリダイレクトする
したがって、私たちは常に次のように書かなければなりません:
window.open(url, '_blank', 'noopener,noreferrer')
ここで:
- noopener:新しいページが
window.openerを取得するのを防ぐ - noreferrer:リファラーを渡さず、文脈をさらに隔離する
これは外部リンクを開くすべてのフロントエンドコードが遵守すべき安全なベースラインです。
4. リクエスト遅延によるユーザー体験の問題#
インターフェースの応答が遅い場合、ユーザーはクリックが無効だと誤解します。
プレースホルダーウィンドウには自然な利点があります:
- ブラウザはすでにタブを開いています
- ユーザーは読み込み中であることがわかります
- URL が到着した後に自動的にリダイレクトされ、ジェスチャーのタイミングを逃しません
- 失敗した場合は閉じられ、ゴミのタブが残りません
これはシンプルですが非常に効果的な UX の向上です。
5. このパターンはどのようなシーンに適用されますか?#
**「クリック後に非同期で URL を取得する」** というビジネス要件を満たす限り、使用できます:
- サードパーティの管理バックエンドを開く(Billing、Portal、Dashboard)
- サインが必要な一時的な URL にリダイレクト(S3、R2、GCS、MinIO、COS)
- OAuth、SSO、一時的な認証ページ
- 非同期で生成されるリソースのエクスポート / ダウンロード
- 高い安全性が求められる外部リンクのリダイレクト
- 動的に生成される支払い / 領収書 / サブスクリプションページ
このパターンはフレームワークに制限されません:
React、Vue、Svelte、Next.js、純粋な HTML でも使用できます。
6. まとめ#
もしリンクが API を通じて非同期で取得する必要があるなら
「プレースホルダーウィンドウ + 非同期フィル」のパターンを使用しなければなりません
そうすることでポップアップのブロックを回避し、安全な隔離を保証できます。
核心的なポイント:
- 同期の window.open → ユーザーのジェスチャーを保持
- 常に noopener,noreferrer を使用 → 逆制御を防ぐ
- 失敗時にウィンドウを閉じる → 無意味なタブを避ける
- リクエスト完了後にリダイレクト → 体験と安全を両立させる
これはエンジニアリングの実践でしばしば見落とされるが非常に重要なテクニックです。
もしあなたのアプリケーションが外部リンクのリダイレクト、一時的な URL、OAuth、Billing、ファイルのダウンロードに関与しているなら、ユーザー体験と安全性を大幅に改善することができます。