Googleの検索窓に「あ」と入力すると、「雨」「赤」「青」のような候補がすっと出てきます。
自分のサイトにも、ああいう日本語サジェストを付けられたら便利ですよね。私もブログ内検索を少し使いやすくしたくて、最初は「IMEの変換候補をJavaScriptで取れないのかな」と考えました。
ところが、実際に調べて試してみると、その方法は使えませんでした。ブラウザのJavaScriptから、IMEの変換候補そのものを取得することはできません。これは不具合ではなく、セキュリティとプライバシーを守るための自然な制限です。
では、どうやって「あ」と入力したときに「雨」や「赤」を候補として出すのか。
答えは、IMEの変換候補を使うのではなく、「漢字」と「読み」をセットにした辞書データを自分で用意し、その読みを前方一致で検索する方法です。
この記事では、HTMLとJavaScriptだけで、日本語の読み検索に対応したサジェスト機能を作る流れを紹介します。IME入力中に候補が暴発しないようにする方法、debounceによる検索回数の調整、読みの前方一致検索、HTMLエスケープまで、実際に動かすときに必要になる部分を順番に整理します。
完成イメージはこちらです。入力欄に「あ」と入れると、読みが「あ」から始まる候補がドロップダウンに表示されます。
▲ 完成したサジェスト機能のデモ画面(「あ」で候補が表示されている状態)
この記事で作るもの
この記事では、IMEの変換候補を取得するのではなく、自前の辞書データを使って「読み」で候補を出す日本語サジェストを作ります。キモは、IMEの合成中は検索を走らせず、入力が確定したタイミングで候補を更新することです。
検証環境:Google Chrome 147 / macOS Tahoe 26.4 / JavaScript ES6+
検証実施:2026年4月 / 記事更新:2026年5月5日
この記事は、日本語サジェスト実装の基本編です。候補リストのキーボード操作、blur時の挙動、外部API連携、サーバーサイド実装などは後編で扱います。
この記事は2部構成です
Part 1(この記事):日本語サジェストの基本的な仕組みと、IMEに頼らない実装方法
Part 2:日本語サジェスト完全版|キーボード操作・blur落とし穴・API連携
IMEの変換候補はJavaScriptから取得できない
最初に押さえておきたいのは、ブラウザのJavaScriptからIMEの変換候補を直接取得することはできないという点です。
私も最初は、「日本語入力中の候補をそのまま読めれば簡単なのでは」と考えました。たとえば「あ」と入力したときに、IMEが内部で持っている「雨」「赤」「青」のような候補をJavaScriptで取得できれば、辞書を自作する必要はありません。
しかし、この方法は使えません。
IMEは、OS側で動作する入力支援の仕組みです。ブラウザ側のJavaScriptは、IMEが持っている変換候補の中身までは見ることができません。ブラウザが扱えるのは、入力中の文字列や、IMEによる合成が始まった・終わったといったイベント情報までです。
これは制限が厳しいように見えますが、よく考えると当然でもあります。
もしWebサイト側がIMEの候補を自由に取得できてしまうと、ユーザーがまだ確定していない入力内容まで読み取れてしまいます。さらに、IMEには学習機能があります。過去に入力した人名、住所、会社名、メールアドレスの一部などが候補に出る可能性もあります。
そのような情報をWebサイトが勝手に読めてしまうと、プライバシー上かなり危険です。だからこそ、IMEの候補はJavaScriptから直接触れない設計になっています。
日本語サジェストを作るときは、ここで発想を切り替える必要があります。IMEの候補を「取得する」のではなく、サイト側で候補を「検索する」形にします。
読み検索用の辞書を自分で持つ
実装の考え方はシンプルです。
サイト側で、漢字や単語と、その読みをセットにした辞書データを用意します。そして、ユーザーが入力したひらがなをもとに、読みが一致するものを探します。
|
1 2 3 4 5 6 7 8 |
辞書データの例: { text: "雨", reading: "あめ" } { text: "赤", reading: "あか" } { text: "青", reading: "あお" } { text: "秋", reading: "あき" } 入力「あ」 → 読みが「あ」で始まるものがヒット → 雨, 赤, 青, 秋 入力「あめ」 → 読みが「あめ」で始まるものがヒット → 雨 |
この方法なら、IMEの内部には一切アクセスしません。ブラウザ上で普通のJavaScriptとして動きます。
たとえばブログ内検索であれば、記事タイトル、タグ名、カテゴリ名、よく検索される単語などを辞書化しておくと便利です。小規模なサイトなら、JavaScript内に辞書を持たせるだけでも十分使えます。
本格的に運用する場合は、WordPress側で記事データから検索用の辞書を作り、API経由で候補を返す形にもできます。ただ、まず仕組みを理解する段階では、ローカルの辞書データで試すのが一番分かりやすいです。
日本語入力ではIMEの「合成中」を考える必要がある
英語のサジェストであれば、inputイベントを監視するだけでもある程度動きます。ユーザーが1文字入力するたびに検索して、候補を更新すればよいからです。
ところが、日本語入力ではそう簡単にはいきません。
日本語には、IMEによる「合成」という段階があります。ローマ字で「a」「m」「e」と入力し、それが「あめ」に変換され、Enterなどで確定されるまでの間は、まだ最終的な入力が決まっていません。
この合成中の文字列に対して毎回検索を走らせると、候補表示が不安定になります。
inputイベントだけを見ると候補が暴れる
たとえば「あめ」と入力する場合、内部ではこういう流れになります。
|
1 2 3 4 |
1. 「a」を押す → inputイベント発火(入力欄:「あ」) ← まだ未確定 2. 「m」を押す → inputイベント発火(入力欄:「あm」) ← まだ未確定 3. 「e」を押す → inputイベント発火(入力欄:「あめ」) ← まだ未確定 4. Enterで確定 → inputイベント発火(入力欄:「あめ」) ← 確定 |
inputイベントが発火するたびに検索すると、「あ」の時点で候補が出て、「あm」で候補が消えて、「あめ」でまた候補が出る、というような動きになります。
実際に試すと、候補リストがちらついたり、意図しないタイミングで消えたりします。英語入力では気にならない処理でも、日本語入力ではかなり使いにくくなります。
日本語サジェストでは、入力中のすべての変化に反応するのではなく、IMEの合成が終わったタイミングで検索するのが扱いやすいです。
compositionstartとcompositionendを使う
IMEの状態を扱うために、ブラウザにはcomposition系のイベントが用意されています。
compositionstartは、IMEによる合成が始まったときに発火します。「今から日本語入力の途中状態に入ります」という合図です。
compositionupdateは、合成中の文字列が更新されるたびに発火します。
compositionendは、合成が終わったときに発火します。入力が確定したタイミング、ですね。
サジェスト検索を走らせるなら、このcompositionendのタイミングが重要になります。合成中は検索を止め、確定後に候補を更新します。
合成中かどうかをフラグで管理する
実装では、isComposingというフラグを用意して、IMEの合成中かどうかを管理します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
const input = document.getElementById('searchInput'); let isComposing = false; // 合成開始 → フラグON input.addEventListener('compositionstart', () => { isComposing = true; }); // 合成終了 → フラグOFF → 検索実行 input.addEventListener('compositionend', () => { isComposing = false; performSearch(input.value); }); // inputイベント → 合成中ならスキップ input.addEventListener('input', (e) => { if (e.isComposing || isComposing) { return; } performSearch(e.target.value); }); |
inputイベントにはisComposingというプロパティがあります。ただし、環境によって挙動に差が出ることがあります。そのため、イベント側のe.isComposingだけに頼らず、自前のisComposingフラグも併用しています。
少し地味な部分ですが、日本語入力対応ではかなり大事です。ここを雑にすると、コード自体は動いているのに、実際に入力すると使いにくいサジェストになります。
debounceで検索の回数を調整する
IMEの合成中に検索を止められるようになったら、次に考えたいのが検索回数です。
入力のたびに即座に検索しても、ローカルの小さな辞書ならそれほど問題にならないかもしれません。ただ、候補リストの表示が細かく更新されすぎると、画面が落ち着かない印象になります。
また、将来的にAPI経由で候補を取得するような構成にすると、入力のたびにリクエストが飛ぶのは避けたいところです。
そこで使うのがdebounceです。
debounceは、「最後の入力から少し待って、それ以上入力が続かなければ処理を実行する」仕組みです。入力が続いている間はタイマーをリセットし、入力が止まったところで1回だけ検索します。
今回のようなローカル辞書の検索であれば、150ms程度の待ち時間でも、体感としてはほぼ即時に感じます。一方で、無駄な検索や表示のちらつきは抑えやすくなります。
debounce関数を作る
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function debounce(func, delay) { let timeoutId = null; return function(...args) { // 前回のタイマーがあればキャンセル if (timeoutId !== null) { clearTimeout(timeoutId); } // 新しいタイマーを設定 timeoutId = setTimeout(() => { func.apply(this, args); }, delay); }; } // 使い方 const debouncedSearch = debounce(performSearch, 150); |
仕組みは単純です。setTimeoutで処理を予約し、次の入力が来たらclearTimeoutで前の予約を取り消します。
待ち時間は、用途によって調整します。小さな辞書をブラウザ内で検索するだけなら100〜200ms程度でも自然です。外部APIに問い合わせる場合は、300ms前後にするなど、通信回数とのバランスを見ながら決めるとよいです。
読みとテキストの両方で前方一致検索する
次に、実際の検索ロジックを作ります。
今回は、辞書データにtextとreadingを持たせます。
|
1 2 3 4 5 6 7 8 9 10 11 |
const dictionary = [ { text: '雨', reading: 'あめ' }, { text: '赤', reading: 'あか' }, { text: '青', reading: 'あお' }, { text: '秋', reading: 'あき' }, { text: '朝', reading: 'あさ' }, { text: 'アメリカ', reading: 'あめりか' }, { text: '天気', reading: 'てんき' }, { text: '天気予報', reading: 'てんきよほう' } ]; |
検索では、読みが入力文字列で始まるかを見ます。JavaScriptではstartsWithを使うと分かりやすいです。
また、読みだけでなくtext自体も検索対象にしておくと、「あめりか」でも「アメリカ」でも候補に出せます。
検索関数の実装
|
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 |
function searchByReading(query) { const normalizedQuery = query.toLowerCase().trim(); if (!normalizedQuery) { return []; } // 読み OR テキスト自体で前方一致検索 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); } |
候補は最大10件に制限しています。候補が多すぎると、ユーザーは選びにくくなります。検索窓のサジェストは、たくさん出すよりも、選びやすい数に絞るほうが使いやすいです。
ソートでは、完全一致を優先しています。たとえば「あめ」と入力したときは、「雨」を上に出し、そのあとに「あめりか」のような候補を出すイメージです。
このあたりは、サイトの用途に合わせて調整できます。記事検索ならアクセス数の多い記事を上に出す、商品検索なら在庫のある商品を優先する、といった工夫もできます。
候補をHTMLに入れるときはエスケープする
サジェスト候補を画面に表示するときは、HTMLエスケープも忘れないようにします。
今回の辞書データは自分で用意するため、危険な文字列が入る可能性は低いかもしれません。ただ、将来的にユーザー投稿、外部API、WordPressの記事タイトルなどを候補に使う場合は注意が必要です。
候補文字列をそのままinnerHTMLに入れると、意図しないHTMLやJavaScriptが実行される危険があります。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // 候補をHTMLに挿入するとき li.innerHTML = ` ${escapeHtml(item.text)} ${escapeHtml(item.reading)} `; |
textContentに入れてからinnerHTMLとして取り出すことで、<や&などが安全な文字列に変換されます。
サンプルコードでは省略されがちな部分ですが、実際のサイトに組み込むなら最初から入れておいたほうが安心です。
ここまでの処理をまとめる
ここまで作った処理をまとめると、こんな形になります。
辞書データ、debounce、HTMLエスケープ、読み検索、IME合成中の判定をひとつにまとめています。
|
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 |
// === 辞書データ === const dictionary = [ { text: '雨', reading: 'あめ' }, { text: '赤', reading: 'あか' }, { text: '青', reading: 'あお' }, { text: '秋', reading: 'あき' }, { text: '朝', reading: 'あさ' }, { text: 'アメリカ', reading: 'あめりか' }, { text: '天気', reading: 'てんき' }, { text: '天気予報', reading: 'てんきよほう' }, // サイトに合わせて追加 ]; // === ユーティリティ === function debounce(func, delay) { let timeoutId = null; return function(...args) { if (timeoutId !== null) clearTimeout(timeoutId); timeoutId = setTimeout(() => { func.apply(this, args); }, delay); }; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // === 検索ロジック === function searchByReading(query) { const q = query.toLowerCase().trim(); if (!q) return []; let results = dictionary.filter(item => item.reading.startsWith(q) || item.text.toLowerCase().startsWith(q) ); results.sort((a, b) => { const aExact = a.reading === q || a.text.toLowerCase() === q; const bExact = b.reading === q || b.text.toLowerCase() === q; 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.trim()); console.log('検索結果:', results); // 次のステップで、ここに候補表示UIを追加する } const debouncedUpdate = debounce(updateSuggestions, 150); input.addEventListener('input', (e) => { if (e.isComposing || isComposing) return; debouncedUpdate(e.target.value); }); |
この段階では、検索結果をconsole.logに出しているだけです。まずはDevToolsのConsoleで、期待どおりに候補が取れているかを確認します。
いきなりUIまで作り込むより、先に「入力に対して正しい候補が返るか」を見たほうが、原因の切り分けがしやすくなります。
実際の入力の流れ
IMEで「あめ」と入力した場合、処理はこう流れます。
|
1 2 3 4 5 6 |
1. 「a」を押す → compositionstart → isComposing = true 2. inputイベント → isComposingがtrueなので検索しない 3. 「m」「e」を押す → 同じく検索しない 4. Enterで確定 → compositionend → isComposing = false 5. updateSuggestions('あめ') を実行 6. 検索結果: [{ text: '雨', reading: 'あめ' }, { text: 'アメリカ', reading: 'あめりか' }] |
一方、IMEを使わずに英字で入力した場合は、compositionイベントは発火しません。そのため、通常どおりinputイベントからdebounce付きで検索が実行されます。
|
1 2 3 4 |
1. compositionstartは発火しない 2. isComposingはfalseのまま 3. inputイベントでdebouncedUpdateが呼ばれる 4. 150ms後にupdateSuggestionsが実行される |
このようにしておくと、日本語入力と英字入力の両方に対応できます。
WordPressやブログ内検索に応用する場合
今回のサンプルでは、辞書データをJavaScript内に直接書いています。学習用としてはこの形が一番分かりやすいです。
ただ、実際のブログやWordPressサイトで使う場合は、候補データをどう作るかが重要になります。
たとえば、こういうデータを辞書にすると使いやすくなります。
- 記事タイトル
- カテゴリ名
- タグ名
- よく検索されるキーワード
- 自作プラグイン名や商品名
- 読者が間違えやすい表記ゆれ
小規模なサイトなら、あらかじめ候補データをJSONとして出力しておき、ブラウザ側で検索しても問題ありません。候補数が数十件から数百件程度であれば、体感速度も十分です。
一方で、候補数が多い場合や、検索ログをもとに候補を変えたい場合は、サーバー側で検索して結果だけを返す設計のほうが向いています。
ただし、最初から大きな仕組みにする必要はありません。まずは小さな辞書で動かし、サイトに合うかどうかを確認するのがおすすめです。
後編で候補表示UIを作る
ここまでで、日本語サジェストの土台はできました。
この記事で扱ったのは、候補を探すための「頭脳」の部分です。実際にユーザーが使える形にするには、検索結果をリストとして表示し、クリックやキーボード操作で選択できるようにする必要があります。
後編では、次の部分を実装します。
- 候補リストのHTMLとCSS
- 上下キーでの候補移動
- Enterキーでの確定
- Escapeキーでの候補閉じ
- blur時にクリックが効かなくなる問題への対応
- 外部APIやサーバーサイド検索への拡張
日本語サジェストは、検索ロジックだけでなく、入力中の違和感をどれだけ減らせるかも大切です。特にキーボード操作やフォーカス制御は、実際に使ってみると差が出ます。
また、macOSの日本語入力では、最初の1文字だけ英字になるような独特の問題に遭遇することもあります。IMEとイベント処理の関係をもう少し深く見たい場合は、関連する検証記事もあわせて読むと理解しやすくなります。
まとめ
日本語サジェストで「あ」と入力したときに「雨」や「赤」を出すには、IMEの変換候補を取得するのではなく、サイト側で読み検索用の辞書を持つ必要があります。
ブラウザのJavaScriptからIMEの候補そのものにはアクセスできません。これはセキュリティとプライバシーを守るための設計です。そのため、実装では「漢字」と「読み」をセットにしたデータを用意し、入力されたひらがなに対して前方一致検索をかけます。
日本語入力で特に大事なのは、IMEの合成中に検索を走らせないことです。compositionstartとcompositionendを使って合成中かどうかを判定し、確定後に候補を更新すると、表示のちらつきや誤検索を減らせます。
さらに、debounceで検索回数を抑え、候補表示時にはHTMLエスケープを入れておくと、実際のサイトにも組み込みやすくなります。
まずは小さな辞書データで動かしてみると、仕組みがかなり分かりやすいです。ブログ内検索、タグ検索、商品検索、管理画面の入力補助など、日本語サイトでは応用しやすい実装です。
最後に、自分への戒めも込めて書いておきます。今回の実装で一番遠回りしたのは、最初に「IME の変換候補そのものを JavaScript から取りたい」という発想で動き出してしまったことでした。OS や IME 側に閉じている処理を、ブラウザ側で覗こうとしていたわけです。「できるはず」と思って関連 API を探し回ったのですが、当然ながら見つからず、ここで時間を使った分は、そのまま無駄になってしまいました。
JavaScript で日本語処理に取り組むときは、「OS や IME に閉じている処理を、ブラウザ側に持ち込もうとしていないか」を最初に確認しておくと、無駄な調査が減ります。できることとできないことの線引きが、最初に分かっているだけでも、実装の方向性がだいぶスッキリします。今回でいえば、「IME の候補を取る」のではなく「自前の辞書から検索する」と発想を切り替えた瞬間に、コードがすっと書けるようになりました。詰まったときは、そもそも前提を取り違えていないか、を一度疑ってみるのも大事だな、と感じた検証でした。
関連記事
- 日本語サジェストの実装版|キーボード操作とblur競合まで直して、ようやく使える検索UIにした話 ── 本記事の続編。候補リストのキーボード操作と blur 競合への対処、API 連携までを扱った実装版。
- 姓名フォームのフリガナ自動入力をcompositionイベントで自前実装した話 ── 同じ composition イベントを使った別実装。姓名フォームのフリガナ自動入力を自前で組んだ記録。
- 日本語フォームの半角カナ・全角英数を input と blur で使い分けて自動変換する話 ── 日本語フォーム実装シリーズの仕上げ記事。半角カナや全角英数の自動変換を input と blur で役割分担する話。













コメント