- 検索ロジックだけでは、検索 UI にはなりませんでした
- HTML は入力欄と候補リストをはっきり分けて、ロールも書く
- CSS は入力欄の真下に候補を重ねる
- ↑↓ Enter Esc を入れたら、ようやく検索 UI らしくなった
- 候補をクリックすると消える問題は、blur が原因でした
- コピーすればそのまま動く完全版コード
- 辞書データはサイトに合わせて差し替える
- debounce の値は、ローカル検索と API 検索で変えたほうがよさそうです
- API 連携では、古いレスポンスが後から返ってくる問題に注意
- 候補数が増えたら、サーバー側で検索する
- キャッシュはかなり効きそうです
- 本番運用なら、レート制限と入力チェックも入れておきたい
- 実際にやってみて感じたこと
- 関連記事
- 参考にした公式情報
前編で日本語サジェストの検索ロジックは作りました。ひらがなで入力すると、読みの前方一致で候補が出てきます。IME 変換中の暴発も止められるし、debounce で検索回数も絞れます。これでひと通り動く、というところまでは形になっていました。
そのままの状態で、自分のサイトの検索窓に入れてみました。動くのは動きます。動くのですが、実際に使い始めてみると、思っていた以上に細かいところで困りました。
候補は出るけれど、↑↓キーで選べない。Enter を押すと、IME の確定とサジェストの選択がぶつかる。マウスで候補をクリックしようとすると、その瞬間に候補リストが消えてしまう。試しに macOS の VoiceOver を有効にして触ってみたら、候補が出ていることそのものが、音声では伝わっていませんでした。
画面の上では「動いているように見える」のに、実際の検索 UI として使うには、ここからもう一段、組み立てる必要がある。前編のロジックを書き終えてから、そのことに気づきました。
この記事は、その「もう一段」の部分の記録です。HTML、CSS、キーボード操作、フォーカスと blur の扱い、WAI-ARIA のロール、そして候補をサーバーから取る場合の AbortController、キャッシュ、レート制限まで。私自身が、自分のサイトで使えるレベルに引き上げるためにやったことを、つまずいた箇所も含めて並べていきます。
この記事の前提
前編で作った日本語サジェストの検索ロジックを、実際の検索 UI として使えるところまで仕上げる記事です。キーボード操作、blur と click の競合、WAI-ARIA、API 連携、レート制限などを、自サイト raplsworks.com に組み込む過程で出会った問題と、その回避策を順番にまとめています。
記事に貼っているコードは、私の環境で動かした最小サンプルです。本番サイトに入れるときは、辞書データやデザイン、検索ページの遷移先などをサイト側に合わせて調整してください。
検証環境:Google Chrome 147 / macOS Tahoe 26.4 / JavaScript ES6+
検証実施:2026年4月 / 記事更新:2026年5月6日
この記事は2部構成です
Part 1:「あ」→「雨」を自力で実装する|IMEに頼らない日本語サジェスト
Part 2(この記事):キーボード操作・blur 対策・API 連携まで入れた実装版
検索ロジックだけでは、検索 UI にはなりませんでした
前編のコードを書き終えた時点では、ひらがなを入力したら候補が下に並ぶ状態までは出来ていました。マウスカーソルだけで触っていれば、それっぽく見えます。
ところが、実際にキーボード中心で操作してみると、急に物足りなくなります。検索フォームに慣れている人は、入力したあとに ↓ キーで候補に降りていって、Enter で確定する動きを自然に求めます。前編のコードでは、その操作が一切できませんでした。↓ を押すとカーソルが動くだけ。Enter を押すと、ただの送信扱いになります。
もっと手強かったのが、Enter キーの扱いでした。
日本語入力では、Enter は「IME の変換を確定するキー」でもあり、サジェストでは「候補を確定するキー」でもあります。同じキーが、状況によって違う意味を持つわけです。これを keydown イベントだけで一律に処理してしまうと、まだ変換途中なのにサジェストの候補まで一緒に確定してしまう、という事故が起きました。
もうひとつ、地味に時間を使ったのが「候補をクリックすると、選ぶ前にリストが消える」問題です。最初は click イベントの書き方を疑って、addEventListener の場所を行ったり来たりしていたのですが、原因はそこではありませんでした。詳しい話はあとで書きますが、入力欄の blur がクリック処理より先に発火していたのが原因です。
このあたりを順番に直していくうちに、サジェスト UI は単なる検索処理ではないんだな、と感じるようになりました。入力欄、候補リスト、フォーカス、キーボード、支援技術。これらを全部視野に入れないと、「動くだけのサジェスト」から「使える検索 UI」までは届きません。
HTML は入力欄と候補リストをはっきり分けて、ロールも書く
HTML 自体は、できるだけ単純にしました。入力欄と候補リストを同じコンテナに入れて、候補リストは入力欄の下に表示します。CSS 側で position: absolute を使って重ねるためです。
ここで意識したのが、role="combobox"、aria-expanded、aria-autocomplete、aria-controls などの WAI-ARIA 属性です。
MDN では、combobox ロールは「入力欄またはボタンが、リストボックスなどのポップアップ要素を制御することを示す」ものとして説明されています。WAI-ARIA Authoring Practices Guide でも、候補リストの表示状態に合わせて aria-expanded を true / false で切り替える書き方が示されていました。この記事では、2026年5月6日時点で MDN と WAI-ARIA APG の説明を確認しています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
<div class="suggest-container"> <input type="text" id="searchInput" class="suggest-input" placeholder="ひらがなで入力してください..." autocomplete="off" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="suggestionList" > <ul id="suggestionList" class="suggest-list" role="listbox" aria-label="検索候補" ></ul> </div> |
autocomplete="off" を入れているのは、ブラウザ標準の入力履歴と、自作の候補リストが二重で出てしまわないようにするためです。これがあると見た目が二段になって、どちらを使えばいいのか分かりにくくなります。
実際に macOS の VoiceOver を有効にして確認してみたら、ARIA 属性を入れていない状態では、候補が出ていても入力欄としてしか読み上げられませんでした。「候補が今 3 件あります」「候補リストが開いています」のような情報が、音声では伝わっていなかった、ということです。見えている人にはほぼ違いが分かりませんが、スクリーンリーダーから入る人には別物になっていました。
サジェスト UI は、見た目だけでなく「候補が開いているか」「どの候補リストと連動しているか」を、HTML の構造としても伝える必要がある。そんな当たり前のことを、ここで体感しました。
CSS は入力欄の真下に候補を重ねる
候補リストは、入力欄の真下に自然に出したかったので、コンテナに position: relative、候補リストに position: absolute を指定しました。これだけで、入力欄の幅に合わせて候補リストが並んでくれます。
サイト全体のヘッダーや他のレイアウトに隠れる場面もあるので、z-index も入れています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
.suggest-container { position: relative; width: 100%; max-width: 500px; } .suggest-input { width: 100%; padding: 14px 18px; font-size: 16px; border: 2px solid #e0e0e0; border-radius: 10px; outline: none; background: #fff; transition: border-color 0.2s, box-shadow 0.2s; } .suggest-input:focus { border-color: #0066cc; box-shadow: 0 0 0 3px rgba(0,102,204,0.1); } .suggest-list { position: absolute; top: 100%; left: 0; right: 0; margin: 4px 0 0; padding: 0; list-style: none; background: #fff; border: 1px solid #e0e0e0; border-radius: 10px; box-shadow: 0 4px 16px rgba(0,0,0,0.12); max-height: 320px; overflow-y: auto; z-index: 1000; display: none; } .suggest-list.active { display: block; } .suggest-item { padding: 12px 18px; cursor: pointer; transition: background-color 0.1s; display: flex; justify-content: space-between; align-items: center; } .suggest-item:hover { background: #f5f5f5; } .suggest-item.selected { background: #e6f0ff; } |
マウスのホバーと、キーボード選択中の候補で、背景色を分けています。最初は hover だけで足りると思っていたのですが、キーボード操作を入れた途端、マウスカーソルの位置と ↑↓ キーで選んでいる候補が一致しないことが起きました。両方が違う場所をハイライトしていると、画面がチカチカしているように見えます。そのため、キーボード選択中の候補には .selected を付けて、見た目を別物にしました。
↑↓ Enter Esc を入れたら、ようやく検索 UI らしくなった
サジェスト機能は、候補が出るだけだと少し物足りません。実際の検索窓として使うなら、キーボードだけでひととおり動かせる必要があります。今回入れたのは、よくある 4 つです。
- ↓ キーで次の候補へ移動
- ↑ キーで前の候補へ移動
- Enter で選択中の候補を確定
- Esc で候補リストを閉じる
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
input.addEventListener('keydown', (e) => { if (e.isComposing || isComposing) return; const isListVisible = suggestionList.classList.contains('active'); switch (e.key) { case 'ArrowDown': if (isListVisible && currentResults.length > 0) { e.preventDefault(); const next = selectedIndex < currentResults.length - 1 ? selectedIndex + 1 : 0; updateSelection(next); } break; case 'ArrowUp': if (isListVisible && currentResults.length > 0) { e.preventDefault(); const prev = selectedIndex > 0 ? selectedIndex - 1 : currentResults.length - 1; updateSelection(prev); } break; case 'Enter': if (isListVisible && selectedIndex >= 0) { e.preventDefault(); selectItem(selectedIndex); } break; case 'Escape': if (isListVisible) { e.preventDefault(); hideSuggestions(); } break; } }); |
ここでいちばん気を遣ったのが、IME 変換中の処理です。
日本語入力では、Enter は「候補を選ぶキー」でもあり、「IME の変換を確定するキー」でもあります。変換中かどうかを見ずに Enter を処理すると、まだ変換中なのにサジェスト側の候補まで一緒に確定してしまう、という事故が起きました。「あめ」と入力しようとして変換中に Enter を押した瞬間、候補欄の先頭にあった別の単語が入力欄に入ってきて、入力途中の文字が消える。これが地味に困ります。
そのため、e.isComposing と自前の isComposing フラグの両方を見て、IME 変換中はサジェスト側のキー操作をスキップするようにしました。
↑↓ キーには e.preventDefault() も入れています。これを入れないと、候補のハイライトを動かしたいだけなのに、入力欄のカーソル位置まで行ったり来たりしてしまい、操作感が悪くなりました。地味な調整ですが、入れた瞬間に「ちゃんとした検索フォームっぽさ」が出ます。
候補をクリックすると消える問題は、blur が原因でした
今回いちばん遠回りしたのが、これでした。
候補に click イベントを設定しているのに、クリックした瞬間に候補リストが消えてしまう。最初は addEventListener の付け方が悪いのかと思ってあちこち書き直したのですが、原因はもっと手前にありました。
順番にすると、こうなります。
- 入力欄にフォーカスがある状態
- 候補をクリックしようとする
- クリック対象が入力欄ではないので、入力欄からフォーカスが外れる
blurイベントが先に発火して、候補リストを閉じる- その後に
clickイベントが処理されるが、候補は既に消えている
つまり、click の前に blur でリストを片付けてしまっていた、というわけです。「候補を選ぶ前に消える」のは、ある意味で当然の挙動でした。
|
1 2 3 4 5 6 7 8 9 10 11 |
input.addEventListener('blur', () => { setTimeout(() => { hideSuggestions(); }, 200); }); input.addEventListener('focus', () => { if (input.value.trim()) { debouncedUpdate(input.value); } }); |
とりあえずの対策として、blur ですぐ閉じず、setTimeout で 200ms だけ遅らせる形にしました。
100ms でも動く場面はあったのですが、手元の macOS のトラックパッドだと、クリック操作の途中で閉じてしまうことがありました。300ms にすると、今度は閉じるタイミングが少し遅く感じます。100ms / 200ms / 300ms を順番に試して、結果として 200ms がいちばん違和感が少なかったので、ここに落ち着いています。
ただ、これは絶対の数字ではありません。サイトの UI、PC かタッチデバイスか、ブラウザの種類などで体感は変わるはずです。あくまで「私の環境ではこの値が一番自然だった」という記録として読んでください。後ほど紹介する完全版コードでは、もう一段堅い対策として mousedown 側で preventDefault() を呼ぶ方法も組み合わせています。
コピーすればそのまま動く完全版コード
ここまでの処理を、まとめて 1 つの HTML ファイルにしたものです。
このまま test.html のような名前で保存して、ブラウザで開けば、ひらがな入力から候補表示、↑↓ キーでの選択、Enter での確定、マウスクリック、Esc で閉じる、までひととおり確認できます。学習用・検証用の最小構成として書いています。本番サイトに入れる場合は、デザイン、辞書データ、検索ページへの遷移などをサイトに合わせて調整してください。
「あ」入力時の候補表示画面
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>日本語サジェスト サンプル</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 600px; margin: 60px auto; padding: 0 20px; } .suggest-container { position: relative; width: 100%; max-width: 500px; } .suggest-input { width: 100%; padding: 14px 18px; font-size: 16px; border: 2px solid #e0e0e0; border-radius: 10px; outline: none; background: #fff; transition: border-color 0.2s, box-shadow 0.2s; box-sizing: border-box; } .suggest-input:focus { border-color: #0066cc; box-shadow: 0 0 0 3px rgba(0,102,204,0.1); } .suggest-list { position: absolute; top: 100%; left: 0; right: 0; margin: 4px 0 0; padding: 0; list-style: none; background: #fff; border: 1px solid #e0e0e0; border-radius: 10px; box-shadow: 0 4px 16px rgba(0,0,0,0.12); max-height: 320px; overflow-y: auto; z-index: 1000; display: none; } .suggest-list.active { display: block; } .suggest-item { padding: 12px 18px; cursor: pointer; transition: background-color 0.1s; display: flex; justify-content: space-between; align-items: center; } .suggest-item:hover { background: #f5f5f5; } .suggest-item.selected { background: #e6f0ff; } .suggest-text { font-weight: 500; } .suggest-reading { color: #888; font-size: 13px; } </style> </head> <body> <h1>日本語サジェスト</h1> <div class="suggest-container"> <input type="text" id="searchInput" class="suggest-input" placeholder="ひらがなで入力してください..." autocomplete="off" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="suggestionList" > <ul id="suggestionList" class="suggest-list" role="listbox" aria-label="検索候補" ></ul> </div> <script> // ===== 辞書データ(サイトに合わせて差し替え可能) ===== const dictionary = [ { text: '雨', reading: 'あめ' }, { text: '赤', reading: 'あか' }, { text: '青', reading: 'あお' }, { text: '秋', reading: 'あき' }, { text: '朝', reading: 'あさ' }, { text: '天気', reading: 'てんき' }, { text: '天気予報', reading: 'てんきよほう' }, { text: '東京', reading: 'とうきょう' }, { text: '大阪', reading: 'おおさか' }, { text: '京都', reading: 'きょうと' }, { text: 'JavaScript', reading: 'じゃばすくりぷと' }, { text: 'WordPress', reading: 'わーどぷれす' } ]; // ===== 状態管理 ===== const input = document.getElementById('searchInput'); const suggestionList = document.getElementById('suggestionList'); let isComposing = false; // IME合成中フラグ let currentResults = []; // 現在表示中の候補 let selectedIndex = -1; // キーボード選択中のインデックス // ===== ユーティリティ:HTMLエスケープ ===== function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ===== ユーティリティ:debounce ===== function debounce(func, delay) { let timeoutId = null; return function(...args) { if (timeoutId !== null) clearTimeout(timeoutId); timeoutId = setTimeout(() => func.apply(this, args), delay); }; } // ===== 検索処理:読みの前方一致 ===== function searchByReading(query) { const q = query.trim(); if (!q) return []; return dictionary .filter(item => item.reading.startsWith(q)) .sort((a, b) => { // 完全一致を優先、次に読みの短い順 if (a.reading === q) return -1; if (b.reading === q) return 1; return a.reading.length - b.reading.length; }) .slice(0, 10); } // ===== 候補リストの描画 ===== function renderSuggestions(results) { currentResults = results; selectedIndex = -1; if (results.length === 0) { hideSuggestions(); return; } suggestionList.innerHTML = results.map((item, i) => ` <li class="suggest-item" role="option" data-index="${i}" id="suggest-item-${i}"> <span class="suggest-text">${escapeHtml(item.text)}</span> <span class="suggest-reading">${escapeHtml(item.reading)}</span> </li> `).join(''); suggestionList.classList.add('active'); input.setAttribute('aria-expanded', 'true'); // 候補クリック処理(blurより先に実行されるよう mousedown を使う) suggestionList.querySelectorAll('.suggest-item').forEach(li => { li.addEventListener('mousedown', (e) => { e.preventDefault(); // blurの発生を抑える selectItem(parseInt(li.dataset.index, 10)); }); }); } // ===== 候補リストを閉じる ===== function hideSuggestions() { suggestionList.classList.remove('active'); suggestionList.innerHTML = ''; input.setAttribute('aria-expanded', 'false'); selectedIndex = -1; } // ===== キーボード選択の更新 ===== function updateSelection(index) { selectedIndex = index; const items = suggestionList.querySelectorAll('.suggest-item'); items.forEach((li, i) => { li.classList.toggle('selected', i === index); }); if (items[index]) { items[index].scrollIntoView({ block: 'nearest' }); } } // ===== 候補を選択 ===== function selectItem(index) { if (index < 0 || index >= currentResults.length) return; const item = currentResults[index]; input.value = item.text; hideSuggestions(); // 実用例:検索ページへ遷移する場合はここで location.href = ... } // ===== 検索実行(debounce付き) ===== const debouncedSearch = debounce((value) => { if (isComposing) return; const results = searchByReading(value); renderSuggestions(results); }, 150); // ===== IMEイベント ===== input.addEventListener('compositionstart', () => { isComposing = true; }); input.addEventListener('compositionend', (e) => { isComposing = false; debouncedSearch(e.target.value); }); // ===== inputイベント(合成中はスキップ) ===== input.addEventListener('input', (e) => { if (e.isComposing || isComposing) return; debouncedSearch(e.target.value); }); // ===== keydownイベント(↑↓ Enter Esc) ===== input.addEventListener('keydown', (e) => { if (e.isComposing || isComposing) return; const isListVisible = suggestionList.classList.contains('active'); switch (e.key) { case 'ArrowDown': if (isListVisible && currentResults.length > 0) { e.preventDefault(); const next = selectedIndex < currentResults.length - 1 ? selectedIndex + 1 : 0; updateSelection(next); } break; case 'ArrowUp': if (isListVisible && currentResults.length > 0) { e.preventDefault(); const prev = selectedIndex > 0 ? selectedIndex - 1 : currentResults.length - 1; updateSelection(prev); } break; case 'Enter': if (isListVisible && selectedIndex >= 0) { e.preventDefault(); selectItem(selectedIndex); } break; case 'Escape': if (isListVisible) { e.preventDefault(); hideSuggestions(); } break; } }); // ===== focus / blurイベント ===== input.addEventListener('focus', () => { if (input.value.trim()) { debouncedSearch(input.value); } }); input.addEventListener('blur', () => { // mousedown側でpreventDefaultしているのでクリック自体は通るが、 // フォールバックとしてsetTimeoutで遅延させてリストを閉じる setTimeout(() => { hideSuggestions(); }, 200); }); </script> </body> </html> |
このコードで地味に効いているのが、候補クリックの処理に mousedown を使っているところです。mousedown は blur より先に発火するので、ここで preventDefault() を呼んでおくと、入力欄からフォーカスが外れる動作そのものをキャンセルできます。これで「クリック前に blur でリストが消える」問題を、もう一段堅く防げました。setTimeout による 200ms の遅延は、それでも閉じない端末や、トラックパッドの長押し挙動などへのフォールバックとして残しています。
↓キーで 2 番目の候補が青く選択されている状態
「てんき」入力時に「天気」「天気予報」に絞り込まれている状態
辞書データはサイトに合わせて差し替える
サンプルでは、天気、色、地名、プログラミング用語などを辞書データとして入れています。
実際のブログで使う場合は、ここを記事タイトル、タグ、カテゴリ名、プラグイン名、よく検索される語句に差し替えることになります。私のブログで試す場合は、記事数がまだそこまで多くないので、まずブラウザ内に辞書データを持たせる方法で十分だと考えています。候補数が数百件程度なら、API を立てなくても体感速度は問題になりにくいです。
記事数や商品数、FAQ が多いサイトで使う場合は、ブラウザにすべての候補を持たせるより、サーバー側で検索したほうが管理しやすくなります。新しい候補が増えても、データベースを更新するだけで反映できますし、JavaScript ファイルが肥大化しません。
debounce の値は、ローカル検索と API 検索で変えたほうがよさそうです
サンプルでは、入力から検索までの待ち時間を 150ms にしています。
ブラウザ内の配列を検索するだけなら、これくらいでも重さは感じません。入力に対して候補がきびきび追従するので、操作感も自然でした。
一方で、入力のたびにサーバーへリクエストを送る場合は、150ms だと少し細かすぎる可能性があります。1 文字打つたびにリクエストが飛ぶ計算になるので、サーバーへの負担も馬鹿になりません。API 連携にするなら、まずは 300ms 前後から試して、操作感とサーバー負荷の両方を見ながら調整するほうが安全だと思います。
ここは正解がひとつではありません。サイト規模、サーバーの余裕、候補の更新頻度、想定するユーザーの入力速度。これらの条件で、自然な値が変わってきます。
API 連携では、古いレスポンスが後から返ってくる問題に注意
候補データをサーバーから取る場合、気をつけたいのがレスポンスの順番です。
たとえば、ユーザーが「あ」と入力したあと、すぐに「あめ」と入力したとします。すると、サーバーには「あ」のリクエストと「あめ」のリクエストがほぼ連続で飛びます。ネットワーク状況によっては、「あめ」のレスポンスが先に返ってきて、そのあとから「あ」のレスポンスが返ってくることがあります。
素直に受け取る形で書いていると、入力欄には「あめ」と入っているのに、画面の候補欄には「あ」の検索結果が表示される、という不思議な状態になります。
これを避けるために使えるのが AbortController です。MDN では、AbortController の signal を fetch に渡しておけば、abort() でまだ完了していないリクエストを中断できると説明されていました。この記事では、2026年5月6日時点で MDN の説明を確認しています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
let abortController = null; async function fetchSuggestions(query) { if (abortController) abortController.abort(); abortController = new AbortController(); try { const res = await fetch(`/api/suggest?q=${encodeURIComponent(query)}`, { signal: abortController.signal }); if (!res.ok) throw new Error('Network error'); return await res.json(); } catch (err) { if (err.name === 'AbortError') return null; console.error('Fetch error:', err); return []; } } |
新しいリクエストを送る前に、前回のリクエストを abort() で止める。これだけで、古いレスポンスが後から届いて画面が逆転する事故を、かなり減らせます。
AbortError は、ネットワークエラーというより「こちらが意図して中断した結果」なので、画面に「エラーが発生しました」と出す必要はありません。コード上では普通のエラーと区別して、無視するように書いておくと自然です。
以前は、リクエストごとに連番を振って、古い番号のレスポンスは捨てる、という方法も考えました。動くには動くのですが、後から自分のコードを読み返したときに、なぜそうしているのかがすぐには分かりにくい書き方になります。fetch を使うなら AbortController のほうが意図が読み取りやすく、コードもすっきりしました。
候補数が増えたら、サーバー側で検索する
候補データが大きくなってきたら、MySQL などに候補テーブルを作って、読みとテキストで前方一致検索する形が扱いやすくなります。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
CREATE TABLE suggestions ( id INT AUTO_INCREMENT PRIMARY KEY, text VARCHAR(255) NOT NULL, reading VARCHAR(255) NOT NULL, popularity INT DEFAULT 0, INDEX idx_reading (reading), INDEX idx_text (text) ); SELECT text, reading FROM suggestions WHERE reading LIKE 'あ%' OR text LIKE 'あ%' ORDER BY popularity DESC, LENGTH(reading) ASC LIMIT 10; |
reading 列にインデックスを張っておけば、LIKE 'あ%' のような前方一致検索は比較的軽く返ってきます。%あ% のような前後ともワイルドカードのパターンは重くなりやすいですが、サジェストでは前方一致だけで多くのケースをカバーできるはずです。
ブログで使う場合は、記事を公開するタイミングで、タイトルやタグから読みを生成して候補テーブルに入れる運用が考えられます。
ただし、漢字から読みを自動生成する部分は、簡単そうに見えて意外と難しいところです。同じ漢字でも文脈によって読みが変わる単語があり、機械的な変換だけだと違う読みで登録されてしまうことがあります。MeCab や pykakasi のような形態素解析・読み変換ライブラリを使う場合でも、すべて任せきりにせず、よく使う語句や検索結果に直結する単語は、手動で確認したほうが安心です。
キャッシュはかなり効きそうです
サジェスト API は、通常のページ表示よりも細かいタイミングで呼ばれます。1 人のユーザーが数文字入力するだけでも、複数回リクエストが飛びます。アクセスが増えてくると、毎回データベースを叩くより、間にキャッシュを挟んだほうが安定しそうです。
方法としては、Redis のようなインメモリキャッシュを使う方法と、よく使う候補を静的 JSON として事前生成する方法が考えられます。
Redis でキャッシュする
|
1 2 3 4 5 6 7 8 9 10 11 12 |
const Redis = require('ioredis'); const redis = new Redis(); async function getSuggestions(query) { const cacheKey = `suggest:${query}`; const cached = await redis.get(cacheKey); if (cached) return JSON.parse(cached); const results = await searchFromDatabase(query); await redis.setex(cacheKey, 3600, JSON.stringify(results)); return results; } |
同じクエリに対する候補を一定時間保持しておけば、短い時間内に同じ検索が来た場合、データベースまで降りずに済みます。
検索候補のように、多少古い情報でも致命的にはならないデータは、キャッシュと相性が良いです。1 時間に 1 回しか更新されない検索結果が、毎リクエストごとに DB を叩くというのは、考えてみれば過剰です。
静的 JSON を事前生成する
もうひとつ考えているのが、最初の 1 文字ごとに候補 JSON を事前生成しておく方法です。
「あ」「い」「う」など、最初のかな 1 文字ごとに候補ファイルを作って、CDN や静的ファイルとして配信します。クライアント側では、ユーザーが 1 文字入力した時点で対応する JSON を読み込み、2 文字目以降はブラウザ内で絞り込みます。
この方式なら、API サーバーを立てなくてもサジェストに近い動きが作れます。CDN の前で完結するので、トラフィックが増えてもサーバー側の負担はほぼ増えません。私のブログ規模では、まずブラウザ内辞書方式で足りると見ていますが、記事数が増えてきたら、この静的 JSON 方式も試してみたいと考えています。
本番運用なら、レート制限と入力チェックも入れておきたい
候補 API を公開する場合は、セキュリティ面も最初から見ておきたいところです。
まず、短い時間に大量アクセスされないようにレート制限を入れます。サジェストは入力のたびに呼ばれる仕組みなので、悪意がなくてもリクエスト数が増えやすい機能です。1 ユーザーあたり 1 分に 60 リクエスト、くらいの制限から始めて、ログを見ながら調整するのが現実的だと思います。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
const rateLimit = require('express-rate-limit'); app.use('/api/suggest', rateLimit({ windowMs: 60 * 1000, max: 60, message: { error: 'Too many requests' } })); app.get('/api/suggest', (req, res) => { const query = req.query.q; if (!query || typeof query !== 'string' || query.length > 100) { return res.json([]); } res.json(searchByReading(query)); }); |
クエリの長さ制限も入れておくと安心です。サジェストの入力に 100 文字以上が必要になることはほとんどありません。異常に長い文字列が飛んできた時点で、空配列を返してしまっても問題ないはずです。長すぎるクエリで DB に負荷をかけられる、という攻撃の入り口を、ここで一段塞いでおきます。
HTML エスケープも、最初から入れておきたいです。
辞書データが自分で用意したものだけなら問題は起きにくいですが、後からユーザー投稿、外部 API、管理画面入力のデータを混ぜる可能性があります。最初の段階で escapeHtml() を通しておけば、後から仕様が変わったときに「あの修正で XSS が…」みたいな事故を起こしにくくなります。
実際にやってみて感じたこと
サジェスト UI は、検索ロジックそのものより、UI として自然に使えるようにする部分のほうが、地味に時間を使いました。
IME 変換中の Enter をどう扱うか。候補をクリックしたときに blur が先に走らないか。キーボードだけで操作できるか。スクリーンリーダーで候補の存在が伝わるか。API 化したときに古いレスポンスが後から表示されないか。レート制限はあるか。これらは、コードを眺めているだけだと見落としやすい部分です。
実際に入力して、変換して、クリックして、↑↓ で動かして、VoiceOver でも読み上げさせて、ようやく違和感に気づける項目ばかりでした。「動いているように見えるけれど、実は使えていない」状態は、自分でも触ってみないとなかなか分かりません。
小さな検索窓ひとつでも、実用レベルに近づけるには意外と工程があります。まずはブラウザ内の辞書データで動かして、候補数が増えたら API 化や静的 JSON 化を検討する。この順番が、個人ブログや小規模サイトでは扱いやすいと思います。
最後に、自分への戒めも込めて書いておきます。今回いちばん遠回りしたのは、「候補をクリックしても消えてしまう」問題で、最初に click イベントの書き方ばかり疑ってしまったことでした。本当の原因は、もう一段手前のフォーカスの動きにありました。「動かない=その処理のコードが悪い」と決めつけて、すぐ近くだけを見続けてしまった結果、解決まで遠回りしました。UI まわりで詰まったときは、自分が見ているイベントの「ひとつ前」「ひとつ外側」で何が起きているかを、一度引いて確認する。これは、これから自分が同じ系統の問題にぶつかったときに、最初に思い出したい注意点です。
関連記事
- 「あ」→「雨」を自力で実装する|IMEに頼らない日本語サジェストの作り方 ── 本記事の前編。日本語サジェストの基本的な仕組みと、IMEに頼らない検索ロジックの作り方。
- 姓名フォームのフリガナ自動入力をcompositionイベントで自前実装した話 ── 同じ composition イベントを使った別実装。姓名フォームのフリガナ自動入力を自前で組んだ記録。
- 日本語フォームの半角カナ・全角英数を input と blur で使い分けて自動変換する話 ── 日本語フォーム実装シリーズの仕上げ記事。半角カナや全角英数の自動変換を input と blur で役割分担する話。
参考にした公式情報
- MDN Web Docs「ARIA: combobox ロール」確認日:2026年5月6日
- WAI-ARIA Authoring Practices Guide「Combobox Pattern」確認日:2026年5月6日
- MDN Web Docs「AbortController: abort() メソッド」確認日:2026年5月6日
- Google Search Central「AI 生成コンテンツに関する Google 検索のガイダンス」公開日:2023年2月8日、確認日:2026年5月6日











コメント