先日 window.open
をしようとしたらポップアップブロッカーに阻まれて open
することができなかった.
まあ,これならよくあることなのだが,いかんせん自分の記憶では onClick
のようなユーザーのアクション内で開かれた window.open
は阻まれないことになってると思っていた.だからそのときも onClick
のイベントハンドラ内で window.open
したから大丈夫だろう,と思っていたら,見事にブロックされてしまったのでなぜだろう,となっていた.
検証
なので,検証するために 3 つのケースを用意してみた:
検証ページを用意したのであなたの環境でも試してみてね♥
今回試すブラウザは Google Chrome を前提にしてます
ケース1
const immediate = () => { window.open('https://www.google.co.jp/'); }; document.getElementById('button_1').addEventListener('click', immediate);
これはユーザーの click
経由だから素直に開く.
ケース2
次にこれを非同期にして (async
),6 秒待ってから window.open
するようにしてみる:
const deferred = async () => { await new Promise((resolve) => { setTimeout(resolve, 6000); }); window.open('https://www.google.co.jp/'); }; document.getElementById('button_2').addEventListener('click', deferred);
これは開かない.ポップアップブロックされる.しかし,たまに開くケースもある.
ケース3
任意の秒数を与えられるようにしてみる:
const deferred = async (waitSeconds: number = 6) => { await new Promise((resolve) => { setTimeout(resolve, 1000 * waitSeconds); }); window.open('https://www.google.co.jp/'); }; document.getElementById('button_3').addEventListener('click', () => deferred(3));
3 秒にするといつも開く.
意味がわからなかったので
Chromium のソースコードからポップアップブロッカーの実装にあたってみた.ポップアップブロッカーのコードはここだ:
ところでポップアップがブロックされる理由は 2 種類ある:
// Classifies what caused a popup to be blocked. enum class PopupBlockType { kNotBlocked, // Popup blocked due to no user gesture. kNoGesture, // Popup blocked due to the abusive popup blocker. kAbusive, };
kNoGesture
はユーザーの操作なくスクリプトが勝手にポップアップしたとみなされた場合 (ユーザーの意図,とも訳せるかも知れない).kAbusive
は特に不正とされたページでポップアップが開いてブロックされた場合だ.3 種類あるやん,って思うかもしれないですが,kNotBlocked
はそのとおり not blocked
である.
デバッグ
実際にさっきのページのケースがどうだったか,Chromium をデバッグビルドしてデバッガーで確認してみることにした.
結果としては kNoGesture
のケースだったことになる.そもそも不正なページではないので残ったケースといえばそうなる.
しかし,この部分からスタックトレースを追っていくと,ユーザー操作があったかのフラグである user_gesture
の真偽値はどこから来たかがわかる.
RenderFrameHostImpl::CreateNewWindow
のあたりに面白いコードがある:
bool effective_transient_activation_state = params->allow_popup || frame_tree_node_->HasTransientUserActivation(); // Ignore window creation when sent from a frame that's not current or // created. bool can_create_window = IsCurrent() && render_frame_created_ && GetContentClient()->browser()->CanCreateWindow( this, GetLastCommittedURL(), GetMainFrame()->GetLastCommittedURL(), last_committed_origin_, params->window_container_type, params->target_url, params->referrer.To<Referrer>(), params->frame_name, params->disposition, *params->features, effective_transient_activation_state, params->opener_suppressed, &no_javascript_access);
chromium/render_frame_host_impl.cc at master · chromium/chromium · GitHub
ここの effective_transient_activation_state
がここからの呼び出しにおける user_gesture
フラグの真偽値になっている.frame_tree_node_->HasTransientUserActivation()
という関数がポイントだ.UserActivationState
というクラスの状態がユーザーの操作があったかどうかを管理している.
User Activation
ところで,このユーザーの操作があったか,というコンセプトは User Activation v2 と呼ばれているようで,コメントに詳細な解説がある (下のコメントは長いので中略している):
// This class represents the user activation state of a frame. It maintains two // bits of information: whether this frame has ever seen an activation in its // lifetime (the sticky bit), and whether this frame has a current activation // that is neither expired nor consumed (the transient bit). See User // Activation v2 (UAv2) links below for more info. // // More Info // ========= // // - UAv2 explainer: https://mustaqahmed.github.io/user-activation-v2 // - Main design: // https://docs.google.com/a/chromium.org/document/d/1erpl1yqJlc1pH0QvVVmi1s3WzqQLsEXTLLh6VuYp228 // - Browser-side replication for OOPIFs: // https://docs.google.com/document/d/1XL3vCedkqL65ueaGVD-kfB5RnnrnTaxLc7kmU91oerg class BLINK_COMMON_EXPORT UserActivationState {
DesignDoc と解説のページがあるので,詳細なところはそこを見てもらうとして,「ユーザーが意図的ななにかをしたか」というのが管理されるようになっている:
今回のケースにおいて,このモデルが関与しているのは次の二点:
- ユーザーはそのページを見たか?
- ユーザーはそのページでクリック等の操作をしたか?
となっていて
- (2) は 一定時間経ってリセットされる
- (2) は
window.open
のような操作後もリセットされる
Chromium の実装に戻ると.UserActivationState
では (2) の一定時間が定数で定義されている.それは次のようになっている:
// The expiry time should be long enough to allow network round trips even in a // very slow connection (to support xhr-like calls with user activation), yet // not too long to make an "unattneded" page feel activated. constexpr base::TimeDelta kActivationLifespan = base::TimeDelta::FromSeconds(5);
5 秒である.コメントにあるように,5 秒あれば大体のアクションは完了するだろう,と見做されている.
だから,今回のケースでは (2) に該当して「なにも操作をせずに 5 秒待っているとポップアップブロックされる」という結果になった.そのため,カチカチと適当にクリックをして待っているとポップアップは開く:
ポップアップブロッカー
ポップアップブロッカーのテストコードを見てみると,とてもおもしろいものになっている.というのも,ありとあらゆるポップアップの試みのテストケースがあるからだ.めちゃくちゃポップアップ開くとか,不可視の <a>
を踏ませるとか,そういう想定されるケースがブロックされるかが書かれている:
だから,target="_blank"
な <a>
を document.createElement
して click
とか,よくあるやつはブロックされるかチェックされている:
<script> function test() { document.querySelector("a").dispatchEvent(new MouseEvent("click", {ctrlKey: true, metaKey: true})); } </script> </head> <body onload="test()"> If the fake click was not blocked then there will be a warning message displayed in a new tab. Otherwise, the test passes. <a href="popup-success.html" target="tab">link</a> </body>
IN_PROC_BROWSER_TEST_F(PopupBlockerBrowserTest, PopupBlockedFakeClickOnAnchor) {
RunCheckTest(browser(), "/popup_blocker/popup-fake-click-on-anchor.html",
WindowOpenDisposition::CURRENT_TAB, kExpectForegroundTab,
kDontCheckTitle);
}
さらに,
開いたウィンドウを opener
が操作するためには,postMessage
してやりとりする方法がある:
window.addEventListener('message', (e) => { location.href = e.data; }, false); const deferred2 = async () => { const uri = location.href; const win = window.open(uri); await new Promise((resolve) => { setTimeout(resolve, 6000); }); if (win) { win.postMessage('https://www.google.co.jp/', '*'); } else { console.error('You have already closed the popup. Sheesh!'); } };
(※ この例では楽してるけど,本当はちゃんとメッセージの正当性を確認するために origin
を指定&チェックしたりする必要がある)
こうすれば window.open
しておいて,データが来た後に操作をすることができる.
さいごに
調べてみると時間がかかったわりに,へぇ〜…という感想しか抱かないものになっている.貴重な時間が消えて切ない.Firefox の実装は調べてないが,同じようなものと想像している.
1995〜2000年代の忌み嫌われた広告ウィンドウがビュンビュン飛び交うインターネットの反省で,window.open
は実質封印されたも同然になっている.スマホの現代,window.open
はもう @deprecated
にしてもいいのではないか.大言壮語.