スマートフォン向けのページで、ボタンをタップしてから反応するまでに、わずかな「間」を感じることがあります。かつてのモバイルブラウザーでは、タップしてから click イベントが起きるまでに、約 300 ミリ秒の遅れがありました。ここでは、その遅れが生まれた理由と、昔使われた回避策の問題点、今の書き方を説明します。

なぜ 300 ミリ秒遅れたのか

初期のスマートフォンのブラウザーは、パソコン向けのページを縮小して表示するのが前提でした。文字が小さいので、利用者は画面をすばやく 2 回たたいて (ダブルタップ) 拡大します。

ブラウザーから見ると、1 回目のタップの時点では、それが単独のタップなのか、ダブルタップの 1 回目なのかが分かりません。そこで、2 回目のタップが来ないことを確かめるまで、約 300 ミリ秒待ってから click イベントを起こしていました。これが反応の遅れの正体です。

ボタンを押すたびに 0.3 秒待たされると、利用者は「重いページ」だと感じます。ゲームや電卓のように連続して操作する画面では、特に目立ちました。

昔の回避策と、その問題

当時よく使われたのは、click を待たずに、指が離れたときの touchend イベントで処理してしまう方法です。仕組みを示すと、次のようになります。

// 仕組みを示すための例 (今は使う必要がない)
const button = document.querySelector(".js-button");
let startX = 0;
let startY = 0;
let moved = false;

button.addEventListener("touchstart", (event) => {
  const touch = event.touches[0];
  startX = touch.clientX;
  startY = touch.clientY;
  moved = false;
});

button.addEventListener("touchmove", (event) => {
  const touch = event.touches[0];
  // 指が 10px 以上動いたら、タップではなくスクロールと見なす
  if (Math.abs(touch.clientX - startX) > 10 || Math.abs(touch.clientY - startY) > 10) {
    moved = true;
  }
});

button.addEventListener("touchend", (event) => {
  if (moved) return;
  event.preventDefault(); // あとから届く click を止める
  doSomething(); // ボタンを押したときの処理
});

反応は速くなりますが、問題も多くありました。

  • スクロールしようとして触れただけでも、処理が動いてしまうことがある。上の例のように指の移動量を調べる必要があった
  • touchend のあとにも、遅れて click が届く。画面が切り替わっていると、指の下にある別のボタンやリンクが押されてしまう (「ゴーストクリック」と呼ばれた)
  • preventDefault() で click を止めると、リンクの移動やフォームの部品の動きも自分で作り直すことになる
  • キーボードや支援技術での操作では touchend が起きないので、click の処理も別に用意する必要がある

これらをまとめて引き受けるライブラリーも使われましたが、どれも「ブラウザーの遅れを外からごまかす」方法でした。

今は viewport の指定で遅れが無くなる

今のブラウザーは、ページが画面の幅に合わせて作られていると分かれば、ダブルタップによる拡大を待ちません。そのため、次の meta 要素を書くだけで、遅れは起きなくなります。Android の Chrome も iOS の Safari も、2010 年代の半ばまでにこの扱いになりました。

<meta name="viewport" content="width=device-width, initial-scale=1">

user-scalable=no や maximum-scale=1 を付けて拡大そのものを禁止する方法も見かけますが、勧められません。文字を拡大して読みたい利用者の妨げになりますし、ブラウザーによっては、この指定は無視されます。

viewport を変えられない場合や、特定の部品だけで確実に遅れを無くしたい場合は、CSS の touch-action を使います。manipulation を指定すると、スクロールとピンチによる拡大は残したまま、ダブルタップによる拡大だけを止めます。

/* 押せる部品では、ダブルタップによる拡大を待たない */
a,
button,
input,
label,
select,
textarea,
[role="button"] {
  touch-action: manipulation;
}

どちらの方法でも、処理は今までどおり click イベントに書けば済みます。click はマウス・タッチ・キーボードのどれで操作しても起きるので、入力の方法ごとに処理を分ける必要がありません。

速くなったら、連打にも備える

反応が速くなると、利用者がボタンを続けて押したときに、処理が 2 回動きやすくなります。保存や送信のように、2 回動いては困る処理では、処理中はボタンを無効にしておきます。

const saveButton = document.querySelector("#save");

saveButton.addEventListener("click", async () => {
  saveButton.disabled = true; // 処理が終わるまで押せなくする
  try {
    await fetch("/api/save", { method: "POST" });
  } finally {
    saveButton.disabled = false; // 失敗しても押せる状態に戻す
  }
});

disabled が付いたボタンには click が起きないので、処理中の連打を防げます。finally で戻しておけば、通信に失敗したときにボタンが押せないまま残ることもありません。

ただし、画面の上でボタンを止めても、通信の遅れなどで同じ要求が 2 回届くことはあり得ます。注文や登録のように重複が困る処理は、サーバーの側でも二重に受け付けない作りにしておくと安心です。

まとめ

  • かつての遅れは、ダブルタップによる拡大かどうかを見分けるために、ブラウザーが約 300 ミリ秒待っていたことが原因だった
  • touchend で処理する昔の回避策は、スクロールとの区別やゴーストクリックなどの問題があり、今は使う必要がない
  • width=device-width の viewport を指定すれば遅れは無くなる。部品ごとに止めるなら touch-action: manipulation を使う
  • 拡大の禁止 (user-scalable=no) は使わない
  • 反応が速くなった分、2 回動いては困る処理は、処理中にボタンを無効にして連打を防ぐ

参考