症状:検索ボックスの絞り込みが変換途中の文字列で動いてしまう
商品名の絞り込みやメニュー検索のようなインクリメンタル検索を実装すると、日本語入力(IME)で打っている最中から一覧が反応してしまうことがあります。けんさく と打とうとしているだけなのに、「け」「けん」「けんさ」という変換の途中経過ごとに絞り込みが実行され、一覧がちらつく。ユーザーが最終的に確定した文字列だけで検索してほしいのに、キーを打つたびに反応してしまう問題です。正確には、検索ボックスの値が変わるたびに input イベントが発火し、そこに登録した絞り込み処理が変換途中でも実行されている、という現象です。
起きる場面:3つのつまずきパターン
原因はどれも同じところにあります。まずは、この問題がどんな実装で顕在化するかを見てみます。
パターン1:インクリメンタル検索・絞り込みリスト
入力のたびにリストを絞り込む実装。変換途中の文字列でもフィルタが実行され、一覧が意味のない状態で何度も切り替わります。
パターン2:オートコンプリート・サジェスト
入力のたびにAPIへ候補を問い合わせる実装。変換途中の文字列でも通信が発生し、無駄なリクエストと表示のちらつきにつながります。
パターン3:入力に応じた重い処理(バリデーション・自動保存)
inputイベントで重いチェックや下書き保存を都度実行する実装。変換が確定していない文字列に対しても処理が走り、無駄な実行が積み重なります。
これらはすべて、inputイベントは変換が確定する前の途中経過でも発火するという1つの仕様が原因です。逆に言うと、文字数カウントのように変換中も表示を更新した方が自然なUIでは、無理に止める必要はありません。確定後だけ処理するかどうかは、通信や重い処理が発生するか、途中結果を見せると混乱するかで判断します。Enterキーで送信されてしまう問題(日本語入力の確定Enterでフォームが送信される原因と直し方)とは別の話で、あちらはキー入力(keydown)側、こちらはinputイベント側で、同じ「変換中かどうか」の判定が必要になります。
デモ:自分の目で確認する
下は業務システムのメニュー検索を想定した絞り込みリストです。日本語入力(IME)をオンにして、けんさく などと打ってみてください。特別な絞り込みロジックはなく、inputイベントが発火するたびにそのままフィルタを実行しています。変換途中の文字列でも実行ログが増えていくのが分かります。
// NG: inputイベントが来るたびにそのままフィルタを実行する
input.addEventListener('input', function () {
runFilter(input.value); // 変換中の文字列でも毎回実行される
});
ログに 変換中に実行 と赤字で記録される行があれば、まだ確定していない文字列でフィルタが走ったということです。この状態で候補が多いリストやAPI呼び出しを伴う実装だと、意味のない結果の表示や無駄な通信が積み重なります。
対策:isComposingで変換中のinputを無視する
inputイベントには isComposing というプロパティがあり、IMEで変換している最中に発火したイベントでは true になります。これが立っている間はフィルタを実行せず、変換が確定したタイミングでまとめて1回だけ実行します。
// OK: 変換中のinputは無視し、確定時にまとめて実行する
var timer = null;
function scheduleFilter() {
clearTimeout(timer);
timer = setTimeout(function () {
runFilter(input.value);
}, 200); // 入力待ちの間引きと、確定時イベントの重複をまとめて1回にする
}
input.addEventListener('input', function (e) {
if (e.isComposing) return; // 変換中のinputは無視
scheduleFilter();
});
input.addEventListener('compositionend', function () {
scheduleFilter(); // 確定した最終文字列で1回実行する
});
ポイントは2つです。input 側では e.isComposing が立っているイベントを早期returnで弾くこと、そして compositionend でも同じ処理を呼んでおくことです。変換確定時のイベントの発火順はブラウザで異なり、確定文字列を反映した最後の input が compositionend より前に発火して isComposing が true のままになる環境では、input 側の処理だけだと確定値を拾えません。そのため compositionend からも同じ処理を予約しておきます。日本語入力を使わない入力(英数字の直接入力や貼り付けなど)では compositionend は発火しないため、通常の input 側の処理がそのまま使われます。
逆に、compositionend の後に isComposing が false の input が発火する環境もあります。その場合は両方から scheduleFilter() が呼ばれますが、clearTimeout() で直前の予約を取り消しているため、最終的なフィルタ処理は1回だけ実行されます。つまりこのコードのdebounceは、入力が落ち着くまで待つ間引きだけでなく、確定時に重複して届くイベントを1回にまとめる役割も持っています。setTimeout を外して直接実行する形に書き換えると、環境によってはAPI呼び出しなどが二重に走るので注意してください。
なお、現行ブラウザでは変換中かどうかを判定するための自前フラグはほとんど不要です。compositionstart でフラグを立てて compositionend で下ろす古い書き方も見かけますが、input イベントの isComposing で途中入力を除外し、compositionend で確定値を拾う上の構成にまとめられます。Enter送信の判定(確定Enterの誤送信)では compositionend と keydown の発火順そのものが対策コードを左右しましたが、input側では上のようにdebounceで重複を吸収できるため、発火順の違いを個別に追いかける必要はありません。
修正デモ:確定後だけフィルタを実行する
下は同じメニュー検索を、上のコードで直したものです。日本語を打っている間はフィルタが実行されず、変換を確定したタイミングでだけ一覧が絞り込まれます。ログには 確定後に実行 とだけ記録され、変換中に無駄な実行が起きていないことが確認できます。
変換中はリストも実行ログも動かず、確定した瞬間にだけ絞り込みが反映されます。候補の多いリストや外部APIを呼ぶオートコンプリートほど、この差がそのまま無駄な処理の削減になります。
まとめ
検索ボックスやインクリメンタル検索でIME変換中に処理が走ってしまう問題は、inputイベントが変換の途中経過でも発火するという仕様が原因です。オートコンプリートや文字数カウントなど、見た目の違う症状もすべて同じ仕様に行き着きます。
inputイベントは変換の途中経過でも発火する。isComposingで変換中を判定し、確定後にまとめて1回処理する。
実装としては、input イベントで e.isComposing が立っている間は処理をスキップし、compositionend でも同じ処理を呼んで確定後の文字列を必ず拾う。両方の呼び出しをdebounce関数にまとめておけば、確定時にイベントが重複して届く環境でも実行は1回に収まります。全角・半角の正規化のように入力内容そのものを書き換える処理でも同じ配慮が必要で、その判断は全角・半角変換とフォーム入力のクリーニングをJavaScriptでまとめて処理するで扱っています。Enterキーでの誤送信と合わせて、IME周りの実装ではこの2つを押さえておけば大半のトラブルを防げます。
参考資料
IME関連のイベントはブラウザの実装差が残る領域です。挙動の裏付けは次の一次情報で確認できます。