日本語サジェスト完全版|キーボード操作・blur落とし穴・API連携まで実装した記録

Program
この記事は約29分で読めます。

日本語サジェストの後編です。前編で作った検索ロジックにUIを組み合わせ、キーボード操作・フォーカス制御・API連携まで含めた「コピペで動く完全版」を仕上げます。

前編では、日本語サジェストの仕組み——IMEのcompositionイベント対応、debounce、プレフィックス検索のロジック——を解説しました。

後編では、前編で作った「頭脳」に「体」を与えます。HTMLとCSSでUIを構築し、キーボード操作に対応させ、コピペで動くサンプルコードを仕上げます。さらに後半では外部API連携やキャッシュなど、本番運用に向けた応用もカバーします。

この後編を書くことになった経緯

前編を公開した後、自分のブログにサジェスト機能を実装しようとしたところ、検索ロジックだけでは「使い物になるUI」にはならないことを痛感しました。

前編のコードは「ひらがなを入力すると候補が出る」ところまでは動きます。しかし実際にブログの検索窓に組み込んでみると、問題が次々と出てきました。

↑↓キーで候補を選べない。Enterで確定できない。候補をマウスでクリックしようとすると、クリックの瞬間にリストが消える。スクリーンリーダーでは候補の存在すら認識されない。

「動くけど使えない」という状態です。検索ロジックはあくまで部品であって、ユーザーが触れるUIにするにはキーボード操作、フォーカス制御、アクセシビリティの3つが必要でした。この後編はその実装記録です。

HTMLの構造——WAI-ARIAで支援技術に伝える

サジェストUIは入力欄+候補リストの2要素で構成し、WAI-ARIAの属性でスクリーンリーダーに「ドロップダウンリストと連携している」と伝えます。

autocomplete="off"はブラウザ標準のオートコンプリートと自前のサジェストが競合するのを防ぐためです。

role="combobox"aria-expandedは地味ですが重要です。これがないと、VoiceOverやNVDAのユーザーには候補リストの存在が見えません。実際に自分のMacでVoiceOverを有効にしてテストしたところ、ARIA属性なしでは「入力欄です」としか読み上げられず、候補が出ていることに気づけませんでした。

CSSのポイント——position: absoluteとz-index

候補リストはposition: absoluteで入力欄の直下に配置します。ホバーとキーボード選択で色を変え、どちらの操作かが視覚的に分かるようにします。

:hover(マウス)と.selected(キーボード)で色を分けているのは、ユーザーが「今どちらの操作で候補を選んでいるか」を把握しやすくするためです。特にタッチデバイスとマウスが混在する環境では、この区別が効きます。

z-index: 1000は他のUI要素(ヘッダー、モーダルなど)より前面に出すためです。サイトによっては値を調整してください。

キーボード操作——↑↓Enter Escの4キー対応

サジェストを使い物にするにはキーボード操作が必須です。↑↓で候補を循環選択、Enterで確定、Escでキャンセル。IME変換中はすべてスキップします。

実装で最もハマったのはe.isComposingのチェックです。これがないと、IMEで「てんき」と入力中にEnterを押したとき、IMEの確定とサジェストの確定が同時に発火してしまいます。前編で解説したcompositionstart/endのフラグと併用して、変換中のキーイベントを確実にスキップします。

e.preventDefault()は↑↓キーのデフォルト動作(テキストカーソルの移動)を無効化するためです。これがないと候補選択とカーソル移動が同時に起きて混乱します。

末尾で先頭に戻る「循環選択」は、候補が10件あっても素早く目的の候補にたどり着けるようにするための工夫です。

blurとクリックの競合——200msの遅延で解決

入力欄からフォーカスが外れたら候補を閉じたい。しかし候補をクリックした瞬間にblurが先に発火してリストが消え、クリックが空振りする。200msの遅延を入れることで解決します。

これは実装中に最も「なぜ?」となった問題です。候補をマウスでクリックすると、クリックの瞬間に入力欄からフォーカスが外れてblurイベントが発火し、候補リストが消える。リストが消えた後にclickイベントが処理されるので、クリック対象が存在しない。結果、候補を選択できない。

200msの間にclickイベントが処理されるので、「候補を選択→リストが閉じる」という正しい順序になります。この200msという値は経験的なもので、100msだとクリックが間に合わないことがあり、300msだと閉じるのが遅く感じる。

コピペで動く完全版コード

ここまでのすべてを統合した完全版です。HTMLファイルとして保存してブラウザで開けば、そのまま動きます。

完成したサジェスト機能のデモ画面(「あ」で候補が表示されている状態) 「あ」入力時の候補表示画面

↓キーで2番目の候補が青く選択されている状態 ↓キーで2番目の候補が青く選択されている状態

「てんき」入力時に「天気」「天気予報」に絞り込まれている状態 「てんき」入力時に「天気」「天気予報」に絞り込まれている状態

カスタマイズのポイント

辞書データとdebounce時間をサイトに合わせて調整すれば、このサンプルをそのまま本番に使えます。

辞書データ:ECサイトなら商品名とカテゴリ、ブログなら記事タイトルとタグ、企業サイトならサービス名とFAQ。辞書の中身がサジェストの品質を直接決めます。

表示件数:slice(0, 10)の数値で変更できます。5件ならコンパクトに、15件なら選択肢を増やせます。私のブログでは記事数が100本前後なので8件にしています。

debounce時間:150msはローカル検索用の値です。APIを叩くなら300msに上げてサーバー負荷を減らすのが妥当です。

ここから先は本番運用の話

ここまでの実装は「辞書データがブラウザ内にある」前提です。辞書が大きくなったり、リアルタイムに候補を更新したい場合は、サーバーサイドとの連携が必要になります。

小〜中規模のサイト(辞書が数百件程度)なら、ブラウザ内の辞書で十分です。私のブログはこの方式で運用しています。以下は辞書が大きくなった場合に必要になる技術です。

AbortControllerでリクエストの追い越しを防ぐ

サーバーから候補を取得する場合、ユーザーが「あ」→「あめ」と素早く入力すると、「あ」のレスポンスが「あめ」より後に返って古い結果が表示されることがあります。AbortControllerで前回のリクエストをキャンセルすれば解決します。

この「レスポンスの追い越し問題」は、サジェストに限らず非同期検索全般で発生します。

fetchの第2引数にsignalを渡しておくと、abort()呼び出し時にそのリクエストがキャンセルされます。AbortErrorは正常なキャンセルなので、エラーとして処理する必要はありません。私はこのパターンを知る前、setTimeoutで古いレスポンスを捨てるという回りくどい実装をしていました。AbortControllerの方がはるかにシンプルです。

サーバーサイドでの候補生成

候補データをMySQLに格納し、LIKE ‘あ%’の前方一致検索をかけます。インデックスが効くので数十万件でも高速です。

popularityカラムで検索回数を記録しておけば、よく使われる候補を上位に表示できます。

漢字→読みの変換を手動で作るのは大変なので、MeCabやpykakasiで自動生成するのが現実的です。ブログなら記事公開時にタイトルとタグの読みを自動生成して辞書テーブルに登録する運用ができます。

キャッシュ——RedisまたはJSON事前生成

サジェストAPIは短時間に大量のリクエストが飛ぶため、キャッシュが効果的です。Redisで動的キャッシュするか、よく使うプレフィックスを事前にJSONファイルとして生成する方法があります。

Redisでの動的キャッシュ

静的JSONファイルへの事前計算

ひらがな1文字分のプレフィックス(「あ」「い」「う」…)の結果を事前にJSONファイルとして生成し、CDNに乗せる方法です。APIサーバーすら不要になります。クライアント側では1文字目のJSONを読み込み、2文字目以降はブラウザ内で絞り込む。私のブログではこの方式を検討中です。

セキュリティ——レート制限とエスケープ

本番運用ではレート制限で連続リクエストを制限し、HTMLエスケープでXSSを防ぎます。

長さ制限(100文字)は異常に長いクエリによるDoS対策です。レート制限は同一IPからの連続リクエストを制限し、APIの悪用を防ぎます。

HTMLエスケープについては前編でも触れましたが改めて強調します。辞書データが自前であっても、将来の拡張でユーザー入力やAPI経由のデータを含める可能性があります。最初からエスケープしておけば安全です。完全版コード内のescapeHtml()関数がこれに該当します。

まとめ

この記事の完全版コードをHTMLファイルとして保存すれば、ひらがなから漢字候補を表示するサジェスト機能がキーボード操作・アクセシビリティ対応付きですぐに動きます。

実装で最もハマったのは、blurとクリックの競合(200msの遅延で解決)と、IME変換中のキーイベント制御(isComposingフラグ)の2つでした。本番運用に進むならAbortControllerでリクエストの追い越しを防ぎ、Redisや静的JSONでキャッシュし、レート制限で悪用を防ぐ。まずはコピペで動かしてみて、辞書データをあなたのサイトに合わせて差し替えるところから始めてみてください。

Program
この記事を書いた人
rapls

Web開発歴6年以上のフリーランスエンジニア。WordPressプラグイン開発とAIツール活用が専門。「現場で本当に役立つ情報」をモットーに、開発で遭遇したトラブルと解決策を発信しています。

raplsをフォローする
raplsをフォローする

コメント

タイトルとURLをコピーしました