いちばん時間を溶かしたのは、「候補をクリックしても、選ぶ前にリストが消える」問題でした。最初は click イベントの書き方を疑って、addEventListener の場所を行ったり来たり。でも、原因はそこではありませんでした。入力欄の blur が、クリック処理より先に発火して、候補リストを片付けてしまっていたのです。見ているイベントの「ひとつ手前」で、別のことが起きていた。これに気づくまで、ずいぶん遠回りしました。
前編で、日本語サジェストの検索ロジックは作りました。ひらがなで入れると、読みの前方一致で候補が出る。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:「あ」で「雨」「赤」を出す日本語サジェストを、HTML と JavaScript だけで作る|IME に頼らない読み検索(基本編)
Part 2(この記事):日本語サジェストの実装版|キーボード操作・blur 競合・WAI-ARIA・API 連携まで直して、ようやく使える検索 UI にした話
左が前編の終わり、右がこの記事のゴールです
検索ロジックは、なぜ「検索 UI」にならないのか?
前編のコードを書き終えた時点で、ひらがなを入れたら候補が下に並ぶ状態まではできていました。マウスだけで触っていれば、それっぽく見えます。ところが、キーボード中心で操作した瞬間、急に物足りなくなりました。検索フォームに慣れた人は、入力したあと ↓ で候補に降りて、Enter で確定する動きを自然に求めます。前編のコードでは、それが一切できません。↓ を押すとカーソルが動くだけ。Enter はただの送信扱い。
もっと手強かったのが、Enter の扱いでした。日本語入力では、Enter は IME の変換を確定するキーでもあり、サジェストでは候補を確定するキーでもあります。同じキーが、状況で意味を変える。これを keydown だけで一律に処理すると、まだ変換途中なのにサジェストの候補まで一緒に確定してしまう事故が起きました。そしてもうひとつが、冒頭に書いた、候補をクリックすると選ぶ前に消える問題です。前編で見えていた景色と、実際に直すことになった項目を、並べておきます。
| 前編の終わりに見えていた状態 | 実際に使うと困ったこと |
|---|---|
| 候補は出る | ↑↓ キーで選べない |
| Enter は効く | IME 確定とサジェスト選択がぶつかる |
| マウスで選べる(はず) | クリックの瞬間にリストが消える |
| 見た目は動いている | VoiceOver では候補の存在が伝わらない |
こうして並べると、サジェスト 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日時点で確認しています。
|
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" を入れているのは、ブラウザ標準の入力履歴と自作の候補が二重に出ないようにするためです。これがないと見た目が二段になって、どちらを使えばいいのか分かりにくくなります。そして、実際に 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 を入れると、何が変わる?
候補が出るだけだと、検索窓としては少し物足りません。実際に使うなら、キーボードだけでひととおり動かせる必要があります。今回入れたのは、よくある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() も入れています。これがないと、候補のハイライトを動かしたいだけなのに、入力欄のカーソル位置まで行ったり来たりして、操作感が悪くなりました。地味な調整ですが、入れた瞬間に、ちゃんとした検索フォームっぽさが出ます。
クリックで消えるのは、本当に click のせい?
冒頭の問題に戻ります。候補に click を設定しているのに、クリックした瞬間に候補が消える。最初は addEventListener の付け方を疑ってあちこち書き直したのですが、原因はもっと手前でした。順番を並べると、こうなります。
| 順 | 起きていること |
|---|---|
| 1 | 入力欄にフォーカスがある |
| 2 | 候補をクリックしようとする |
| 3 | クリック対象が入力欄ではないので、フォーカスが外れる |
| 4 | blur が先に発火して、候補リストを閉じる |
| 5 | その後 click が処理されるが、候補は既に消えている |
犯人は click ではなく、その手前の blur でした
つまり、click の前に blur でリストを片付けていた。候補を選ぶ前に消えるのは、ある意味で当然の挙動でした。とりあえずの対策として、blur ですぐ閉じず、setTimeout で 200ms だけ遅らせました。
|
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); } }); |
100ms でも動く場面はありましたが、手元の macOS のトラックパッドだと、クリックの途中で閉じることがありました。300ms だと、今度は閉じるのが少し遅く感じます。100 / 200 / 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 番目が青くなる、この一手間が欲しかった
「てんき」で 2 件に絞れた瞬間です
辞書データは、どこに置く?
サンプルでは、天気、色、地名、プログラミング用語を辞書に入れています。実際のブログで使うなら、ここを記事タイトル、タグ、カテゴリ名、プラグイン名、よく検索される語句に差し替えます。私のブログは記事数がまだそこまで多くないので、まずブラウザ内に辞書を持たせる方法で十分だと考えています。候補数が数百件程度なら、API を立てなくても体感速度は問題になりにくいです。記事数や商品数、FAQ が多いサイトなら、ブラウザに全部持たせるより、サーバー側で検索したほうが管理しやすくなります。新しい候補が増えても、データベースを更新するだけで反映できますし、JavaScript ファイルが肥大化しません。
debounce の値は、ローカルと API で同じでいい?
サンプルでは、入力から検索までを 150ms にしています。ブラウザ内の配列を検索するだけなら、これで重さは感じません。候補がきびきび追従して、操作感も自然でした。一方、入力のたびにサーバーへリクエストを送る場合は、150ms だと少し細かすぎる可能性があります。1 文字打つたびにリクエストが飛ぶので、サーバーへの負担も馬鹿になりません。API 連携なら、まず 300ms 前後から試して、操作感とサーバー負荷の両方を見ながら調整するほうが安全です。ここは正解がひとつではありません。サイト規模、サーバーの余裕、候補の更新頻度、想定するユーザーの入力速度。これらで自然な値が変わります。
API 連携で、古いレスポンスが後から来たら?
候補をサーバーから取る場合、気をつけたいのがレスポンスの順番です。ユーザーが「あ」と入れたあと、すぐ「あめ」と入れたとします。サーバーには「あ」と「あめ」のリクエストがほぼ連続で飛びます。ネットワーク次第では、「あめ」のレスポンスが先に返って、そのあと「あ」のレスポンスが返ることがある。素直に受け取ると、入力欄は「あめ」なのに、候補欄には「あ」の結果が出る、という不思議な状態になります。下の図が、その逆転です。
遅れて届いた「あ」が、最新の「あめ」を上書きしてしまう
これを避けるのに使えるのが AbortController です。MDN では、signal を fetch に渡しておけば、abort() でまだ完了していないリクエストを中断できる、と説明されていました(2026年5月6日時点で確認)。
|
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 人が数文字入れるだけで、複数回リクエストが飛ぶ。アクセスが増えると、毎回データベースを叩くより、間にキャッシュを挟んだほうが安定しそうです。方法は2つ考えています。Redis のようなインメモリキャッシュを使う方法と、よく使う候補を静的 JSON として事前生成する方法です。
| 方法 | 向いている場面 |
|---|---|
| Redis キャッシュ | 同じクエリが短時間に繰り返される。DB を都度叩きたくない |
| 静的 JSON の事前生成 | API を立てず CDN で完結させたい。トラフィックが増えても耐えたい |
|
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 文字ごとに候補ファイルを作って、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 が、という事故を起こしにくくなります。
まとめ|「ひとつ手前」を見ていなかった
冒頭のクリック問題に戻ります。あのとき私は、click の書き方ばかり疑っていました。本当の原因は、もう一段手前の blur にあった。動かない、イコール、その処理のコードが悪い、と決めつけて、すぐ近くだけを見続けた結果、解決まで遠回りしました。サジェスト UI で時間を使ったのは、検索ロジックそのものより、UI として自然に使えるようにする部分でした。IME 変換中の Enter をどう扱うか。候補クリックで blur が先に走らないか。キーボードだけで動かせるか。スクリーンリーダーで候補の存在が伝わるか。API 化したとき古いレスポンスが後から出ないか。レート制限はあるか。どれも、コードを眺めているだけだと見落としやすい項目でした。実際に入力して、変換して、クリックして、↑↓ で動かして、VoiceOver でも読ませて、ようやく違和感に気づけた。動いているように見えるけれど、実は使えていない、という状態は、自分で触ってみないと分かりません。
小さな検索窓ひとつでも、実用に近づけるには意外と工程があります。まずはブラウザ内の辞書で動かして、候補数が増えたら API 化や静的 JSON 化を検討する。この順番が、個人ブログや小規模サイトでは扱いやすいです。UI まわりで詰まったら、自分が見ているイベントの「ひとつ前」「ひとつ外側」で何が起きているかを、一度引いて確認する。次に同じ系統で詰まったとき、あなたが最初に思い出すのは、たぶんそこです。あなたの検索窓は、クリックされる「ひとつ手前」で、何をしていますか。
関連記事
- 「あ」で「雨」「赤」を出す日本語サジェストを、HTML と JavaScript だけで作る|IME に頼らない読み検索(基本編) ── 本記事の前編。日本語サジェストの基本的な仕組みと、IME に頼らない検索ロジック。
- 姓名フォームのフリガナ自動入力をcompositionイベントで自前実装した話 ── 同じ composition イベントを使った別実装。
- 日本語フォームの半角カナ・全角英数を 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日











コメント