パソコンで動かすアプリ(zip を落として開くもの)のダウンロードページを作りました。ところが、SNS から来る人の多くはスマホです。スマホでは zip を落としても使えないので、スマホで来た人にだけ「このアプリはパソコン用です」という案内と、「このページを自分に送る」ボタンを出すことにしました。
この記事では、その仕組み(mobile.js、80行ほど)を紹介します。
- スマホかどうかの判定(判定できないときはパソコン扱い)
- 「自分に送る」ボタン:共有メニュー → コピー → URLを表示、の順に切り替える
- CSP でインラインのスクリプトを禁止しているページへの組み込み方
- Node でブラウザの部品を模擬してテストする方法
確認した環境:Node.js v24.20.0、macOS の Google Chrome(headless)。
1. 方針:出さないほうに倒す
このページの主役は、パソコン向けのダウンロードボタンです。案内の判定をまちがえてパソコンの人に出すと、ボタンの上に関係のない枠が出てしまいます。そこで、決まりを次のようにしました。
- 案内の枠は、HTML では最初から
hiddenにしておく - スクリプトが「スマホだ」とはっきり言えたときだけ
hiddenを外す - 判定できない・途中でエラーになったときは、何もしない(=パソコン扱い)
<aside class="mobile-note" id="mobileNote" hidden aria-label="スマホでご覧の方へ">
JavaScript が動かない環境でも、今までどおりのパソコン向けのページのまま表示されます。
2. スマホの判定:UA と matchMedia のどちらか
判定は2本立てです。どちらか一方が「スマホ」と言えば、案内を出します。
var PHONE_UA = /iPhone|iPod|Android.+Mobile|Mobile.+Firefox|Windows Phone|IEMobile|BlackBerry|Opera Mini/i;
var PHONE_MQ = '(max-width: 760px) and (pointer: coarse)';
// nav … navigator、mm … matchMedia(どちらも無ければ false=パソコン扱い)
function isPhone(nav, mm) {
var byUA = false, byMQ = false;
try { byUA = !!(nav && typeof nav.userAgent === 'string' && PHONE_UA.test(nav.userAgent)); } catch (e) { byUA = false; }
try { byMQ = !!(typeof mm === 'function' && mm(PHONE_MQ).matches === true); } catch (e) { byMQ = false; }
return byUA || byMQ;
}
-
UA(ユーザーエージェント):iPhone や、
AndroidのあとにMobileが続くもの(Android のスマホ)を見ます。タブレットの Android はMobileが付かないことが多いので、ここでは拾いません。Instagram のアプリ内ブラウザも、UA にiPhoneなどが入っているので、この正規表現で拾えます。 -
matchMedia:「幅が 760px 以下」かつ「指で触る端末(
pointer: coarse)」のときだけ。パソコンのウィンドウを細くしただけでは、pointerがfineのままなので出ません。 -
matchMediaが無い・例外を投げる・matchesがtrueでない、のどれでもfalseになります。=== trueと書いているのは、結果がはっきりしないときに出さないためです。
UA だけだと、UA をパソコン風に変えている端末を取りこぼします。matchMedia だけだと、横向きや大きめの画面で外れることがあります。そのため、どちらかが当たれば出す形にしました。
3. 「自分に送る」ボタン:3段階のフォールバック
スマホで見つけたページを、あとでパソコンで開いてもらうためのボタンです。メールや LINE、メモなど、送り先は人によって違うので、端末の共有メニュー(Web Share API)に任せます。
function sendToSelf(win, doc, say) {
var url = pageUrl(doc, win.location), nav = win.navigator;
function fallback() {
return copyText(win, doc, url).then(function (ok) {
var m = ok ? 'コピーしました。メモやメールに貼って、パソコンで開いてください。'
: 'コピーできませんでした。このアドレスをパソコンで開いてください:' + url;
say(m); return m;
});
}
if (nav && typeof nav.share === 'function') {
var p;
try { p = nav.share({ title: TITLE, url: url }); } catch (e) { return fallback(); }
return Promise.resolve(p).then(function () { say(''); return 'shared'; }, function (e) {
if (e && e.name === 'AbortError') { say(''); return 'canceled'; }
return fallback();
});
}
return fallback();
}
流れは次のとおりです。
-
navigator.shareがあれば、共有メニューを開く。渡すのはtitleとurlだけ - 共有メニューを本人が閉じた(
AbortError)ときは、何もしない。コピーもしないし、文言も出さない -
shareが無い、呼んだ時点で例外、AbortError以外で失敗した、のどれかなら、URL をコピーする - コピーもできなければ、URL をそのまま画面に出す
2 のところが大事です。共有をやめた人に「コピーしました」と出すと、本人がしていない操作を勝手にしたことになります。やめたのなら、そのままにしておきます。
say は、ボタンの下の role="status" の段落に文言を入れる関数です。戻り値('shared' / 'canceled' / 表示した文言)は、テストで確かめるために返しています。
送る URL
送る URL は、ページの <link rel="canonical"> から取ります。SNS から来た人の URL には ?utm_source=... などが付いていることがあるので、それを持ち回らないためです。
function pageUrl(doc, loc) {
var c = doc && doc.querySelector && doc.querySelector('link[rel="canonical"]');
var h = c && c.getAttribute('href');
if (h && /^https:\/\/tools\.equaglobal\.com\//.test(h)) return h;
return loc ? loc.origin + loc.pathname : '';
}
canonical が無いときは、location の origin + pathname を使います。こちらもクエリと # は付きません。
コピーの2段構え
コピーは navigator.clipboard.writeText を先に試し、使えない・失敗したときは、昔からある textarea + execCommand('copy') に切り替えます。
function copyText(win, doc, text) {
var nav = win.navigator;
if (nav && nav.clipboard && typeof nav.clipboard.writeText === 'function') {
return nav.clipboard.writeText(text).then(function () { return true; }, function () { return legacyCopy(doc, text); });
}
return Promise.resolve(legacyCopy(doc, text));
}
execCommand は非推奨ですが、clipboard が使えない環境のための最後の手段として残しています。これも失敗したら false を返し、URL を表示する文言になります。
4. CSP でインラインのスクリプトを禁止しているページに入れる
このページは CSP(Content-Security-Policy)で、スクリプトを同じサイトのファイルとアクセス解析のものだけに絞っています。
script-src 'self' https://www.googletagmanager.com;
'unsafe-inline' を付けていないので、<script>...</script> に直接書いたコードは動きません。そのため、案内の処理は別ファイルにして、defer で読み込んでいます。
<script src="mobile.js?v=202610282400" defer></script>
defer なので、HTML を読み終わってから実行されます。念のため、ファイルの最後でも読み込みの状態を見ています。
var M = { isPhone: isPhone, pageUrl: pageUrl, sendToSelf: sendToSelf, setup: setup, PHONE_MQ: PHONE_MQ, TITLE: TITLE };
if (typeof module === 'object' && module.exports) { module.exports = M; return; }
var run = function () { try { setup(root, root.document); } catch (e) { /* 失敗してもパソコン用の表示のまま */ } };
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', run); else run();
- Node から
requireされたときは、関数を返すだけで画面には触りません(テスト用) - ブラウザでは
setupを呼びますが、全体をtryで包んでいるので、何かあってもパソコン用の表示のまま残ります
mobile.js 自体は通信をしません。外部への送信も計測もなく、共有やコピーは端末の機能を呼ぶだけです(ページ全体にはアクセス解析が入っていますが、それは別のファイルです)。
5. Node でテストする:window と navigator を手で作る
ブラウザの部品は、テストの中で普通のオブジェクトとして作ります。isPhone は navigator と matchMedia を引数で受け取る形にしてあるので、そのまま差し替えられます。
const mmQ = []; const mm = (hit) => (q) => { mmQ.push(q); return { matches: hit }; };
eq(M.isPhone({ userAgent: IPHONE }, mm(false)), true, '表示条件:iPhoneのUA');
eq(M.isPhone({ userAgent: MAC }, null), false, '判定できない(matchMediaなし)→パソコン扱い');
eq(M.isPhone(undefined, undefined), false, '判定できない(両方なし)→パソコン扱い');
eq(M.isPhone({}, () => { throw new Error('x'); }), false, 'matchMedia がエラー→パソコン扱い');
「出さない」側のテストを多めに入れています。Mac・Windows の UA、matchMedia が無い、例外を投げる、結果が {} で matches が無い、のどれも false になることを確かめています。
共有のテストでは、share や clipboard.writeText を、呼ばれたら記録するだけの関数にします。たとえば「共有をやめたらコピーしない」はこう書いています。
w = winOf(IPHONE, true, { share: () => Promise.reject(Object.assign(new Error('a'), { name: 'AbortError' })), clipboard: { writeText: (t) => { copied = t; return Promise.resolve(); } } });
eq(await M.sendToSelf(w, d.doc, (t) => (msg = t)), 'canceled', '共有をやめたら何もしない'); eq(copied, null, 'やめたときはコピーしない');
同じやり方で、次の場面を1つずつ確かめています。
-
shareに渡すのがtitleと canonical の URL だけ(utm_source付きのlocation.hrefを渡しても、送るのは canonical) -
shareが無い → コピーして「コピーしました」 -
shareがNotAllowedErrorで失敗 → コピーに切り替え -
clipboardが失敗し、execCommandも失敗 → URL を表示 -
clipboardが無く、execCommandは成功 → 「コピーしました」 - 模擬した
windowのfetchが一度も呼ばれない。あわせて、mobile.jsのソースにfetch(・XMLHttpRequest・sendBeacon・外部 URL などが無いことも文字列で調べる
ページ全体のテスト(スクリプトはインラインが無いか、案内の枠が hidden でダウンロードボタンより上にあるか、など)と合わせて、89件が通っています。
free/test.js: 89 passed, 0 failed
ブラウザでは、headless の Chrome で UA を iPhone にして開くと案内の hidden が外れ、Mac の UA では hidden のまま、という表示の切り替えまでを確かめました。
まとめ
- 案内は
hiddenで置き、「スマホだ」と言えたときだけ出す。判定できない・エラーはパソコン扱い - 判定は UA と
matchMedia('(max-width: 760px) and (pointer: coarse)')のどちらか - 「自分に送る」は
share→ (AbortErrorなら何もしない)→clipboard→execCommand→ URL 表示 - 送る URL は canonical から取り、計測用のクエリを持ち回らない
- CSP でインラインを禁止しているので、別ファイルを
deferで読む -
navigatorなどを引数で受け取る形にしておくと、Node で模擬してテストしやすい