白状すると、この実装でいちばん時間を溶かしたのは、最初の30分でした。「IME が内部で持っている『雨』『赤』『青』みたいな変換候補を、JavaScript からそのまま読めれば、辞書なんて自作しなくていいじゃないか」。そう思って、関連していそうな API を探し回ったのです。先に言ってしまうと、そんな API はありません。ブラウザの JavaScript から、IME の変換候補そのものを取得することはできないのです。
あなたも、自分のサイトに Google みたいな日本語サジェストを付けたい、と思ったことはありませんか。検索窓に「あ」と入れたら「雨」「赤」「青」がすっと出る、あれです。私もブログ内検索を使いやすくしたくて、まさにそこから入って、最初の入口で壁にぶつかりました。
でも、ぶつかったおかげで分かったこともあります。IME の候補を取得するのではなく、サイト側で候補を検索する。発想をそう切り替えた瞬間に、コードはすっと書けるようになりました。具体的には、「漢字」と「読み」をセットにした辞書を自分で用意して、その読みを前方一致で検索する。これだけです。この記事では、HTML と JavaScript だけで、その読み検索のサジェストを作る土台を組み立てます。
触れない場所をのぞこうとしていた、最初の私です
完成イメージはこうです。入力欄に「あ」と入れると、読みが「あ」から始まる候補がドロップダウンに並びます。
これが作りたかったもの
この記事で作るもの
IME の変換候補を取得するのではなく、自前の辞書データを使って「読み」で候補を出す日本語サジェストです。キモは、IME の合成中は検索を走らせず、入力が確定したタイミングで候補を更新すること。これを外すと、コードは動いているのに使いにくい、という状態になります。
検証環境:Google Chrome 147 / macOS Tahoe 26.4 / JavaScript ES6+
検証実施:2026年4月 / 記事更新:2026年5月5日
この記事は2部構成です
Part 1(この記事):「あ」で「雨」「赤」を出す日本語サジェストを、HTML と JavaScript だけで作る|IME に頼らない読み検索(基本編)
Part 2:日本語サジェストの実装版|キーボード操作・blur 競合・WAI-ARIA・API 連携まで直して、ようやく使える検索 UI にした話
なぜ IME の変換候補は JavaScript から取れないのか?
最初の壁を、もう少しちゃんと説明します。IME は OS 側で動く入力支援の仕組みです。ブラウザの JavaScript は、IME が内部に持っている変換候補までは見られません。JavaScript が扱えるのは、入力中の文字列と、IME の合成が始まった・終わったといったイベント情報まで。候補そのものは、壁の向こうにあります。
厳しい制限に見えて、よく考えると当然でした。もしサイト側が IME の候補を自由に読めたら、ユーザーがまだ確定していない入力まで覗けてしまう。しかも IME には学習機能があります。過去に打った人名、住所、会社名、メールアドレスの一部が候補に出ることもある。それをサイトが勝手に読めたら、プライバシー上かなり危険です。だから候補は JavaScript から触れない設計になっています。不具合ではなく、守るための線引きです。
ここで発想を切り替えます。取得できないものを取りに行くのをやめて、サイト側で検索する。下の表が、その切り替えの全体像です。
| アプローチ | どこに頼るか | JavaScript から |
|---|---|---|
| IME の候補を取得する(最初の私) | OS / IME の内部 | 触れない |
| 自前の辞書を検索する(正解) | 自分で用意したデータ | 普通に扱える |
辞書はどう持てばいい?
考え方はシンプルです。漢字や単語と、その読みをセットにした辞書を用意する。ユーザーが入れたひらがなで、読みが一致するものを探す。IME の内部には一切触れません。普通の JavaScript として動きます。
壁を越えにいかず、手元で探す
|
1 2 3 4 5 6 7 8 |
辞書データの例: { text: "雨", reading: "あめ" } { text: "赤", reading: "あか" } { text: "青", reading: "あお" } { text: "秋", reading: "あき" } 入力「あ」 → 読みが「あ」で始まるものがヒット → 雨, 赤, 青, 秋 入力「あめ」 → 読みが「あめ」で始まるものがヒット → 雨 |
ブログ内検索なら、記事タイトル、タグ名、カテゴリ名、よく検索される単語あたりを辞書にしておくと便利です。小規模なサイトなら、JavaScript 内に辞書を持たせるだけで十分使えます。本格運用なら、WordPress 側で記事データから辞書を作って API で返す形にもできます。ただ、仕組みを理解する段階では、ローカルの辞書で試すのが一番わかりやすいです。
日本語入力の「合成中」を、なぜ気にするのか?
英語のサジェストなら、input イベントを見るだけでもそれなりに動きます。1文字打つたびに検索して候補を更新すればいい。日本語はそう簡単にいきません。IME には「合成」という段階があるからです。ローマ字で「a」「m」「e」と打って、それが「あめ」になり、Enter で確定するまでは、まだ最終的な入力が決まっていません。この合成中の文字列に毎回検索を走らせると、候補表示が暴れます。
「あめ」と打つとき、input イベントだけを見ると、内部はこう流れます。
| 操作 | 入力欄 | 状態 |
|---|---|---|
| 「a」を押す | あ | 未確定 |
| 「m」を押す | あm | 未確定 |
| 「e」を押す | あめ | 未確定 |
| Enter で確定 | あめ | 確定 |
input のたびに検索すると、「あ」で候補が出て、「あm」で消えて、「あめ」でまた出る。実際に試すと、候補リストがちらついたり、変なタイミングで消えたりします。英語では気にならない処理が、日本語ではかなり使いにくくなる。だから、入力中の全変化に反応せず、IME の合成が終わったタイミングで検索します。
そのために、ブラウザには composition 系のイベントがあります。compositionstart は合成が始まったとき、compositionupdate は合成中の文字列が更新されるたび、compositionend は合成が終わったときに発火します。サジェスト検索で大事なのは、最後の compositionend です。合成中は止めて、確定後に候補を更新する。下の2枚が、その発火タイミングと、合成中フラグの状態です。
どこで検索するか、の一点
on か off か、それだけ管理する
実装では、isComposing というフラグで合成中かどうかを管理します。
|
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 だけに頼らず、自前のフラグも併用しています。地味ですが、ここを雑にすると、コードは動くのに使いにくいサジェストになります。DevTools の Console で発火を見ておくと、安心して進められます。
目で見て、ようやく腑に落ちました
検索は何回走らせる? debounce で間引く
合成中に検索を止められたら、次は検索の回数です。ローカルの小さな辞書なら毎回検索しても平気かもしれません。ただ、候補表示が細かく更新されすぎると、画面が落ち着かない。将来 API 経由にするなら、入力のたびにリクエストが飛ぶのも避けたい。そこで debounce です。最後の入力から少し待って、それ以上続かなければ1回だけ実行する仕組みです。入力が続く間はタイマーをリセットし、止まったところで検索します。下の2枚は、debounce なしと、150ms ありの違いです。
これは打つたびに走っている
待つだけで、ここまで静かになる
|
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: 'てんきよほう' } ]; |
読みが入力で始まるかを 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件に絞っています。候補は多すぎると選びにくい。サジェストは、たくさん出すより選びやすい数にするほうが使いやすいです。ソートは完全一致を優先していて、「あめ」なら「雨」を上に、「あめりか」をその後に出します。ここはサイトの用途で調整できます。記事検索ならアクセスの多い記事を上に、商品検索なら在庫のある商品を優先する、といった具合です。
候補をそのまま innerHTML に入れて大丈夫?
答えは、だめです。候補を画面に出すときは、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 として取り出すと、< や & が安全な文字列に変換されます。サンプルでは省かれがちな部分ですが、実際のサイトに組み込むなら最初から入れておくほうが安心です。
ここまでを1つにまとめると?
辞書データ、debounce、HTML エスケープ、読み検索、IME 合成中の判定。ここまでを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 |
// === 辞書データ === 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 に出しているだけです。まず Console で、期待どおりの候補が取れているかを見ます。いきなり UI まで作り込むより、入力に対して正しい候補が返るかを先に確認したほうが、原因の切り分けが楽になります。
頭の中の地図を、そのまま貼っておきます
日本語と英字、両方ちゃんと動く?
IME で「あめ」と入れたときと、英字で打ったとき。流れを並べると、こうなります。
| IME で「あめ」 | 英字で入力 |
|---|---|
| 「a」で compositionstart → isComposing = true | compositionstart は発火しない |
| 合成中の input は isComposing が true なので検索しない | isComposing は false のまま |
| Enter で compositionend → isComposing = false | input イベントで debouncedUpdate が呼ばれる |
| updateSuggestions(‘あめ’) を実行 → 雨 / アメリカ | 150ms 後に updateSuggestions が実行される |
こうしておくと、日本語入力と英字入力の両方に対応できます。
WordPress やブログ内検索に応用するには?
サンプルでは辞書を JavaScript 内に直接書いています。学習用にはこれが一番わかりやすい。実際のサイトで使うときは、候補データをどう作るかが効いてきます。辞書にしておくと便利なものを、表にしておきます。
| 辞書に入れると便利なもの | ねらい |
|---|---|
| 記事タイトル | 読みたい記事へ最短で誘導 |
| カテゴリ名・タグ名 | まとめ読みの入口を出す |
| よく検索されるキーワード | 需要の高い候補を先に見せる |
| 自作プラグイン名・商品名 | 固有名のタイプミスを救う |
| 読者が間違えやすい表記ゆれ | ヒットしない検索を減らす |
小規模なサイトなら、候補データを JSON で出力しておき、ブラウザ側で検索しても問題ありません。数十件から数百件なら体感速度も十分です。候補が多い場合や、検索ログをもとに候補を変えたい場合は、サーバー側で検索して結果だけ返す設計が向きます。とはいえ、最初から大きく作る必要はありません。まずは小さな辞書で動かして、サイトに合うかを確かめる。これがいちばん遠回りしない順番でした。
UI はどこで作る? 続きは後編で
ここまでで、日本語サジェストの土台はできました。この記事で作ったのは、候補を探す「頭脳」の部分です。ユーザーが使える形にするには、結果をリストで表示して、クリックやキーボードで選べるようにする必要があります。後編で実装するのは、この続きです。
| 後編で作るもの | これが要る理由 |
|---|---|
| 候補リストの HTML と CSS | 候補を実際に画面へ出す |
| 上下キーでの候補移動 | マウスなしで選べる |
| Enter で確定 / Escape で閉じる | キーボードだけで完結させる |
| blur 時にクリックが効かない問題 | 候補を選ぶ前に閉じる事故を防ぐ |
| 外部 API / サーバー検索への拡張 | 候補が増えても耐える形にする |
日本語サジェストは、検索ロジックだけでなく、入力中の違和感をどれだけ減らせるかが大事です。特にキーボード操作とフォーカス制御は、実際に使うと差が出ます。続きは後編にまとめました。
まとめ|「取りに行く」のをやめたら、すっと書けた
最初の30分に戻ります。あのとき私は、IME の変換候補を JavaScript から取りに行こうとしていました。OS や IME に閉じている処理を、ブラウザ側で覗こうとしていた。当然ながら、そんな API は見つからず、探した時間はそのまま消えました。流れが変わったのは、取得をやめて、自前の辞書から検索する、と発想を切り替えた瞬間です。そこからは、読み検索も、IME 合成中の判定も、debounce も、HTML エスケープも、迷わず書けました。
JavaScript で日本語を扱うときは、「OS や IME に閉じている処理を、ブラウザ側に持ち込もうとしていないか」を最初に確認すると、無駄な調査が減ります。できることとできないことの線引きが先に分かっているだけで、実装の向きはだいぶスッキリします。詰まったら、コードを疑う前に、そもそも前提を取り違えていないか、を一度疑ってみる。あの30分が教えてくれたのは、たぶんそれでした。次はあなたの番です。その「あ」で、何を出してあげますか。
関連記事
- 日本語サジェストの実装版|キーボード操作・blur 競合・WAI-ARIA・API 連携まで直して、ようやく使える検索 UI にした話── 本記事の続編。候補リストのキーボード操作と blur 競合への対処、API 連携までを扱った実装版。
- 姓名フォームのフリガナ自動入力をcompositionイベントで自前実装した話 ── 同じ composition イベントを使った別実装。
- 日本語フォームの半角カナ・全角英数を input と blur で使い分けて自動変換する話 ── 日本語フォーム実装シリーズの仕上げ。













コメント