Part 1では、「HTMLサジェストで『あ→雨』を実現する」ための基礎知識と問題の本質について解説しました。IMEの変換候補は取得できないこと、代わりに「読みによるプレフィックス検索」を使うこと、そしてIMEとの共存が課題になることを学びました。
Part 2となるこの記事では、いよいよJavaScriptによる実装に入ります。「プログラミングは苦手」という方も、安心してください。一行一行、なぜそのコードが必要なのかを丁寧に解説していきます。
この記事を読み終える頃には、以下のことができるようになっているはずです。
- JavaScriptのイベント処理の仕組みを理解する
- IMEの合成中かどうかを判定するコードを書ける
- debounce(遅延実行)を実装できる
- プレフィックス検索のロジックを理解し、実装できる
では、始めましょう。
このシリーズの全体像
📚 シリーズ記事一覧
Part 1:なぜ難しい?仕組みを徹底理解
▶ Part 2:IME対応とJavaScript実装の基本(この記事)
Part 3:実践コード完全版と動作デモ
Part 4:応用テクニックと本番運用
第1章:JavaScriptのイベント処理を基礎から理解する
サジェスト機能を実装するには、JavaScriptの「イベント処理」を理解する必要があります。まずは、イベント処理の基本から確認していきましょう。
1-1. イベントとは何か
Webページ上で起きる「出来事」のことを、プログラミングではイベントと呼びます。
例えば、以下のようなものがすべてイベントです。
- ボタンをクリックした →
clickイベント - 入力欄に文字を入力した →
inputイベント - キーボードのキーを押した →
keydownイベント - ページの読み込みが完了した →
loadイベント - マウスカーソルを要素の上に乗せた →
mouseoverイベント
JavaScriptでは、これらのイベントを「監視」して、イベントが発生したときに特定の処理を実行することができます。この仕組みをイベント駆動(イベントドリブン)と呼びます。
1-2. イベントリスナーの登録方法
イベントを監視するには、イベントリスナーを登録します。「この要素で、このイベントが発生したら、この処理を実行してね」とブラウザに伝えるのです。
基本的な書き方は以下の通りです。
|
1 2 3 4 5 6 7 8 |
// 要素を取得 const element = document.getElementById('myInput'); // イベントリスナーを登録 element.addEventListener('イベント名', function(event) { // イベントが発生したときに実行される処理 }); |
具体例を見てみましょう。入力欄(<input>要素)に文字が入力されたときに、その内容をコンソールに出力するコードです。
|
1 2 3 4 5 6 7 8 9 |
// 入力欄の要素を取得 const input = document.getElementById('searchInput'); // inputイベントを監視 input.addEventListener('input', function(event) { // 入力された値を取得してコンソールに出力 console.log('入力値:', event.target.value); }); |
このコードでは、inputイベントが発生するたびに、入力欄の現在の値(event.target.value)がコンソールに出力されます。
💡 初心者向けポイント
event.targetは、イベントが発生した要素(この場合は入力欄)を指します。event.target.valueで、その要素の現在の値を取得できます。
1-3. inputイベントとchangeイベントの違い
入力欄に関連するイベントには、inputとchangeの2種類があります。似ているようで、動作が異なるので注意が必要です。
inputイベントは、入力欄の値が変わるたびに発火します。1文字入力するごとに、1回ずつイベントが発生します。リアルタイムに入力を追跡したい場合に使います。
changeイベントは、入力欄からフォーカスが外れたとき(他の要素をクリックしたときなど)に発火します。入力が「完了」したタイミングで処理を実行したい場合に使います。
サジェスト機能では、ユーザーの入力にリアルタイムに反応したいので、inputイベントを使います。
1-4. アロー関数で書く
最近のJavaScriptでは、関数を短く書けるアロー関数という記法がよく使われます。先ほどのコードをアロー関数で書き直すと、以下のようになります。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// 従来の書き方 input.addEventListener('input', function(event) { console.log('入力値:', event.target.value); }); // アロー関数で書いた場合 input.addEventListener('input', (event) => { console.log('入力値:', event.target.value); }); // さらに短く(引数が1つの場合は括弧を省略可能) input.addEventListener('input', e => { console.log('入力値:', e.target.value); }); |
この記事では、読みやすさを重視して、アロー関数を使った記法で解説を進めます。
第2章:日本語入力の特殊なイベントを理解する
ここからが、日本語サジェスト実装の核心部分です。日本語入力には、英語入力にはない特殊なイベントがあります。
2-1. 日本語入力で発生するイベントの流れ
日本語をIMEで入力すると、どのようなイベントが発生するのでしょうか。「あめ」と入力して確定するまでの流れを、詳しく見てみましょう。
ステップ1:最初の文字を入力
キーボードで「a」を押すと、以下のイベントが発生します。
1. keydown イベント(キーが押された)
2. compositionstart イベント(IME合成が開始された)
3. compositionupdate イベント(合成中のテキストが「あ」に更新された)
4. input イベント(入力欄の値が変わった)
5. keyup イベント(キーが離された)
ステップ2:続きの文字を入力
キーボードで「m」「e」と押すと、それぞれ以下のイベントが発生します。
「m」を押したとき:
1. keydown イベント
2. compositionupdate イベント(合成中のテキストが「あm」に更新)
3. input イベント
4. keyup イベント
「e」を押したとき:
1. keydown イベント
2. compositionupdate イベント(合成中のテキストが「あめ」に更新)
3. input イベント
4. keyup イベント
ステップ3:確定する
Enterキーを押して確定すると、以下のイベントが発生します。
1. keydown イベント
2. compositionend イベント(IME合成が終了した)
3. input イベント
4. keyup イベント
注目すべきポイントは、compositionstartからcompositionendまでの間です。この間、IMEは「合成中」の状態にあり、入力はまだ確定していません。
2-2. compositionイベントの重要性
composition系のイベントは、日本語サジェスト実装において極めて重要です。
compositionstart:IMEによる文字の合成が開始されたときに発火します。このイベントが発生したら、「今からIMEで入力が始まるぞ」と判断できます。
compositionupdate:合成中のテキストが更新されるたびに発火します。ローマ字からひらがなへの変換など、合成中の変化を追跡できます。
compositionend:IMEによる合成が終了したとき(確定したとき)に発火します。このイベントが発生したら、「入力が確定したぞ」と判断できます。
✅ ポイント
サジェスト検索を実行するベストなタイミングは、compositionendが発火したときです。この時点で、ユーザーの入力が確定しているからです。
2-3. isComposingプロパティを活用する
inputイベントが発火したとき、「今、IMEで合成中なのかどうか」を判定する方法がもう一つあります。それが、event.isComposingプロパティです。
isComposingは、イベントオブジェクトのプロパティで、合成中ならtrue、そうでなければfalseを返します。
|
1 2 3 4 5 6 7 8 |
input.addEventListener('input', (e) => { if (e.isComposing) { console.log('合成中なので何もしない'); return; } console.log('確定済みなので検索を実行:', e.target.value); }); |
このコードでは、isComposingがtrueのとき(合成中のとき)は早期リターンして、処理をスキップしています。
2-4. フラグ変数を使った確実な判定
isComposingプロパティは便利ですが、ブラウザによっては正しく動作しない場合があります。より確実に判定するために、自前でフラグ変数を管理する方法を併用することをお勧めします。
以下は、compositionstartとcompositionendイベントでフラグを管理するコードです。
|
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 |
// 合成中かどうかを示すフラグ let isComposing = false; // 合成開始時にフラグをONにする input.addEventListener('compositionstart', () => { isComposing = true; console.log('合成開始'); }); // 合成終了時にフラグをOFFにする input.addEventListener('compositionend', () => { isComposing = false; console.log('合成終了'); }); // inputイベントでフラグをチェック input.addEventListener('input', (e) => { // 合成中ならスキップ if (e.isComposing || isComposing) { console.log('合成中なのでスキップ'); return; } // 確定済みなので検索を実行 console.log('検索実行:', e.target.value); }); |
このコードのポイントは、e.isComposing || isComposingという条件式です。e.isComposingと自前のフラグの両方をチェックすることで、どのブラウザでも確実に判定できます。
⚠️ 注意点
古いバージョンのSafariやiOSでは、isComposingプロパティが正しくtrueを返さないケースが報告されています。自前のフラグ管理を併用することで、これらの環境でも正しく動作させることができます。
第3章:debounce(遅延実行)を実装する
IMEの合成中は検索をスキップする仕組みができました。次に実装するのは、debounce(デバウンス)です。
3-1. なぜdebounceが必要なのか
Part 1で触れたように、debounceは「最後の入力から一定時間経過するまで、処理を遅延させる」仕組みです。
なぜこれが必要なのでしょうか。具体例で考えてみましょう。
ユーザーが「天気予報」と入力したいとします。「て」「ん」「き」「よ」「ほ」「う」と、6回の確定が発生します。debounceがないと、6回の検索リクエストが送信されてしまいます。
しかし、ユーザーが本当に知りたいのは「天気予報」の結果であって、「て」や「てん」の結果ではありません。途中の検索は無駄なのです。
debounceを導入すると、「最後の入力から(例えば)150ミリ秒間、新しい入力がなければ検索を実行する」という動作になります。これにより、ユーザーが入力を終えたタイミングで1回だけ検索が実行されます。
3-2. debounceの仕組みを図で理解する
debounceの動作を、タイムラインで見てみましょう。
【debounceなしの場合】
時間 →→→→→→→→→→→→→→→→→→→→→→
入力: て てん てんき てんきよ てんきよほう
検索: ⚡ ⚡ ⚡ ⚡ ⚡
↑ ↑ ↑ ↑ ↑
5回の検索が実行される(無駄)
【debounce 150msありの場合】
時間 →→→→→→→→→→→→→→→→→→→→→→→→
入力:て てん てんき てんきよ てんきよほう
↓ ↓ ↓ ↓ ↓ [150ms経過]
リセット リセット リセット リセット タイマー開始→ ⚡
↑
1回だけ検索実行
入力があるたびにタイマーをリセットし、最後の入力から150ミリ秒後に検索が実行されています。
3-3. debounce関数を実装する
debounceを実装するには、setTimeoutとclearTimeoutを組み合わせます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function debounce(func, delay) { let timeoutId = null; return function(...args) { // 前回のタイマーがあればキャンセル if (timeoutId !== null) { clearTimeout(timeoutId); } // 新しいタイマーを設定 timeoutId = setTimeout(() => { func.apply(this, args); }, delay); }; } |
この関数が何をしているのか、一行ずつ解説します。
let timeoutId = null;
タイマーのIDを保存する変数です。setTimeoutは、タイマーを識別するためのIDを返します。このIDを使って、後からタイマーをキャンセルできます。
return function(...args) { ... }
新しい関数を返します。この関数が、実際に呼び出されるdebounce版の関数になります。...argsは、元の関数に渡された引数をすべて受け取るための記法です。
clearTimeout(timeoutId);
前回設定したタイマーをキャンセルします。これにより、前回の処理は実行されなくなります。
timeoutId = setTimeout(() => { ... }, delay);
新しいタイマーを設定します。delayミリ秒後に、元の関数funcを実行します。
3-4. debounce関数の使い方
debounce関数を使って、検索処理を遅延実行する例を見てみましょう。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// 検索を実行する関数 function performSearch(query) { console.log('検索実行:', query); // ここで実際の検索処理を行う } // debounceを適用した検索関数を作成 const debouncedSearch = debounce(performSearch, 150); // inputイベントで使用 input.addEventListener('input', (e) => { if (e.isComposing || isComposing) { return; } // debounce付きで検索を呼び出す debouncedSearch(e.target.value); }); |
debounce(performSearch, 150)で、150ミリ秒のdebounceが適用された新しい関数debouncedSearchを作成しています。この関数を何度呼び出しても、最後の呼び出しから150ミリ秒後に1回だけperformSearchが実行されます。
💡 初心者向けポイント
debounceの遅延時間は、100〜300ミリ秒程度が一般的です。短すぎると効果が薄く、長すぎるとレスポンスが悪く感じられます。150ミリ秒は、多くのケースでバランスの良い値です。
第4章:プレフィックス検索のロジックを実装する
IMEの合成中判定とdebounceが実装できました。次は、いよいよプレフィックス検索のロジックを実装します。
4-1. 辞書データの構造
まず、検索対象となる辞書データを用意します。辞書は、「表示する文字」と「読み(ひらがな)」のペアの配列として作成します。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
const dictionary = [ { text: '雨', reading: 'あめ' }, { text: '赤', reading: 'あか' }, { text: '青', reading: 'あお' }, { text: '秋', reading: 'あき' }, { text: '朝', reading: 'あさ' }, { text: 'アメリカ', reading: 'あめりか' }, { text: '天気', reading: 'てんき' }, { text: '天気予報', reading: 'てんきよほう' }, // ... 他の単語も追加 ]; |
各要素は、text(表示するテキスト)とreading(読み)のプロパティを持つオブジェクトです。
4-2. filterとstartsWithで検索する
JavaScriptの配列メソッドfilterと、文字列メソッドstartsWithを組み合わせて、プレフィックス検索を実装します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function searchByReading(query) { // 入力を正規化(小文字化、前後の空白を除去) const normalizedQuery = query.toLowerCase().trim(); // 空の入力なら空配列を返す if (!normalizedQuery) { return []; } // 読みがqueryで始まる単語を検索 const results = dictionary.filter(item => { return item.reading.startsWith(normalizedQuery); }); return results; } |
このコードが何をしているのか、解説します。
query.toLowerCase().trim()
入力された文字列を正規化しています。toLowerCase()は小文字に変換し、trim()は前後の空白を除去します。これにより、大文字・小文字の違いや、誤って入力された空白を無視できます。
dictionary.filter(item => { ... })
filterメソッドは、配列の各要素に対して条件をチェックし、条件を満たす要素だけを含む新しい配列を返します。
item.reading.startsWith(normalizedQuery)
startsWithメソッドは、文字列が指定した文字列で始まるかどうかをチェックします。trueなら条件を満たすので、その要素は結果に含まれます。
4-3. テキスト自体も検索対象にする
読みだけでなく、テキスト自体も検索対象にすると、より便利になります。例えば、「アメリカ」という単語は、「あめりか」でも「アメリカ」でもヒットしてほしいですよね。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function searchByReading(query) { const normalizedQuery = query.toLowerCase().trim(); if (!normalizedQuery) { return []; } const results = dictionary.filter(item => { // 読みで始まる OR テキスト(小文字化)で始まる return item.reading.startsWith(normalizedQuery) || item.text.toLowerCase().startsWith(normalizedQuery); }); return results; } |
||(OR演算子)を使って、「読みで始まる」または「テキストで始まる」のどちらかを満たせばヒットするようにしました。
4-4. 検索結果をソートする
検索結果が複数ある場合、どの順番で表示するかが重要です。一般的には、以下のような優先順位でソートします。
- 完全一致を最優先(入力と読みが完全に一致するもの)
- 読みが短いものを優先(より具体的な単語を上に)
|
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 |
function searchByReading(query) { const normalizedQuery = query.toLowerCase().trim(); if (!normalizedQuery) { return []; } let results = dictionary.filter(item => { return item.reading.startsWith(normalizedQuery) || item.text.toLowerCase().startsWith(normalizedQuery); }); // ソート results.sort((a, b) => { // 完全一致を最優先 const aExact = a.reading === normalizedQuery || a.text.toLowerCase() === normalizedQuery; const bExact = b.reading === normalizedQuery || b.text.toLowerCase() === normalizedQuery; if (aExact && !bExact) return -1; // aを上に if (!aExact && bExact) return 1; // bを上に // 読みが短いものを優先 return a.reading.length - b.reading.length; }); // 最大10件まで返す return results.slice(0, 10); } |
sortメソッドは、配列の要素を並び替えます。比較関数が負の値を返すとaが前に、正の値を返すとbが前に来ます。
slice(0, 10)で、結果を最大10件に制限しています。候補が多すぎると、ユーザーにとって選びづらくなるからです。
第5章:ここまでのコードを統合する
ここまでに学んだ要素を統合して、一つのコードにまとめてみましょう。
5-1. 統合コード
|
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 |
// ======================================== // 辞書データ // ======================================== const dictionary = [ { text: '雨', reading: 'あめ' }, { text: '赤', reading: 'あか' }, { text: '青', reading: 'あお' }, { text: '秋', reading: 'あき' }, { text: '朝', reading: 'あさ' }, { text: 'アメリカ', reading: 'あめりか' }, { text: '天気', reading: 'てんき' }, { text: '天気予報', reading: 'てんきよほう' }, { text: 'JavaScript', reading: 'じゃばすくりぷと' }, { text: 'プログラミング', reading: 'ぷろぐらみんぐ' }, ]; // ======================================== // 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 normalizedQuery = query.toLowerCase().trim(); if (!normalizedQuery) { return []; } let results = dictionary.filter(item => { return item.reading.startsWith(normalizedQuery) || item.text.toLowerCase().startsWith(normalizedQuery); }); results.sort((a, b) => { const aExact = a.reading === normalizedQuery || a.text.toLowerCase() === normalizedQuery; const bExact = b.reading === normalizedQuery || b.text.toLowerCase() === normalizedQuery; if (aExact && !bExact) return -1; if (!aExact && bExact) return 1; return a.reading.length - b.reading.length; }); return results.slice(0, 10); } // ======================================== // メイン処理 // ======================================== const input = document.getElementById('searchInput'); let isComposing = false; // 合成開始 input.addEventListener('compositionstart', () => { isComposing = true; }); // 合成終了 input.addEventListener('compositionend', () => { isComposing = false; // 合成終了時に検索を実行 updateSuggestions(input.value); }); // 候補を更新する関数 function updateSuggestions(query) { const results = searchByReading(query); console.log('検索結果:', results); // ここで候補をUIに表示する処理を追加 } // debounce付きの更新関数 const debouncedUpdate = debounce(updateSuggestions, 150); // 入力イベント input.addEventListener('input', (e) => { if (e.isComposing || isComposing) { return; } debouncedUpdate(e.target.value); }); |
5-2. コードの流れを確認
このコードの動作を、ステップバイステップで確認しましょう。
1. ユーザーが「あ」と入力(IME使用)
compositionstartが発火 →isComposing = trueinputイベントが発火 →isComposingがtrueなのでスキップ
2. ユーザーが「あ」を確定
compositionendが発火 →isComposing = false、updateSuggestions('あ')を実行- 検索結果として「雨」「赤」「青」「秋」「朝」「アメリカ」などがヒット
3. ユーザーが続けて「め」と入力(IME使用)
- 再び
compositionstartからcompositionendの流れ - 確定後、
updateSuggestions('あめ')を実行 - 検索結果として「雨」「アメリカ」などがヒット(候補が絞り込まれる)
4. ユーザーが英字「test」と入力(IME不使用)
compositionstartは発火しないinputイベントが発火 →isComposingはfalseなのでdebouncedUpdateを実行- 150ms後に検索が実行される
第6章:この記事のまとめと次回予告
6-1. Part 2で学んだこと
📌 Part 2 の重要ポイント
1. JavaScriptのイベント処理
addEventListenerでイベントリスナーを登録し、特定のイベントが発生したときに処理を実行できる。
2. 日本語入力の特殊なイベント
compositionstart、compositionupdate、compositionendイベントでIMEの合成状態を追跡できる。isComposingプロパティと自前のフラグを併用すると、より確実に判定できる。
3. debounce(遅延実行)
setTimeoutとclearTimeoutを使って、最後の入力から一定時間後に処理を実行する仕組みを作れる。これにより、無駄な検索リクエストを削減できる。
4. プレフィックス検索
filterとstartsWithを組み合わせて、読みが入力文字列で始まる単語を検索できる。sortで結果を適切な順序に並び替えることで、ユーザビリティが向上する。
6-2. 次回:Part 3 の予告
Part 3では、完全に動作するサジェスト機能を実装します。
具体的には、以下の内容を扱います。
- HTMLとCSSで候補表示UIを構築
- キーボード操作(↑↓キー、Enter、Escape)への対応
- マウスクリックでの候補選択
- XSS対策(セキュリティ)
- コピペで動く完全なサンプルコード
Part 2までの知識をベースに、実際に使えるサジェスト機能を組み立てていきます。お楽しみに!
次の記事
Part 3:実践コード完全版と動作デモ編
→ コピペで動く完全なサンプルコードを提供します
最後まで読んでいただき、ありがとうございました。
Part 3では、この記事で学んだロジックを使って、実際に動作するサジェスト機能を完成させます。コードが難しく感じた部分があれば、この記事を読み返してから次に進むことをお勧めします。


コメント